Converter (scripts/convert.py)¶
This page mirrors scripts/jepa_convert/README.md from the repository.
jepa_convert — checkpoint → GGUF converters¶
Python side of jepa.cpp. Turns the reference PyTorch checkpoints into the GGUF layout
defined in [docs/gguf-schema.md(https://github.com/aselimc/jepa.cpp/blob/main/docs/gguf-schema.md). Inference only; no training code.
scripts/convert.py CLI entry point (dispatches on --family)
scripts/jepa_convert/common.py JepaWriter, sincos tables, qkv fusing, BN folding, preprocessing keys
scripts/jepa_convert/ijepa.py facebook/ijepa_* (HF IJepaModel safetensors)
scripts/jepa_convert/hfvit.py OK-AI/lejepa-*, DINOv2-style (timm layout safetensors) + HF-ViT source mapper
scripts/jepa_convert/lewm.py quentinll/lewm-* (weights.pt: HF ViT encoder + adaLN predictor)
scripts/jepa_convert/selftest.py numpy forward straight from a GGUF vs. the PyTorch reference
scripts/jepa_convert/vjepa2.py, vjepa2_1.py standalone video-family converters (separate owner)
scripts/jepa_convert/levjepa.py galilai-group/LeVJEPA-* (custom HF architecture; reuses vjepa2.py's writer)
Usage¶
PY=.venv/bin/python
$PY scripts/convert.py --family ijepa --src models/facebook/ijepa_vith14_1k --ftype f16
$PY scripts/convert.py --family hfvit --src models/OK-AI/lejepa-vits16-pretrain-in1k --ftype f32
$PY scripts/convert.py --family lewm --src models/quentinll/lewm-pusht --ftype f16
$PY scripts/convert.py --family levjepa --src models/galilai-group/LeVJEPA-VideoMix-Large \
--out models/gguf/levjepa-vitl16-f32.gguf --ftype f32
# default output: models/gguf/<basename of --src>-<ftype>.gguf ; override with --out
# --name overrides general.name ; --allow-unmapped tolerates unknown source tensors (default: hard error)
--ftype f32 writes every tensor as F32. --ftype f16 writes the quantizable weights
(*.attn_*, *.ffn_*, enc.proj.*, pred.proj*, head.cls) as F16 and everything else
(patch embed, norms, biases, position tables, tokens, adaLN, action embed) as F32. Q8_0 / Q4_K
come from tools/jepa-quantize, never from Python.
Every converter fails loudly if a source tensor is neither mapped nor on its explicit skip list, and cross-checks every hparam against tensor shapes (layer count, ffn dim, patch kernel, pos rows).
Self-test¶
$PY scripts/jepa_convert/selftest.py --gguf models/gguf/lejepa-vits16-pretrain-in1k-f32.gguf --src models/OK-AI/lejepa-vits16-pretrain-in1k
$PY scripts/jepa_convert/selftest.py --gguf models/gguf/lewm-pusht-f32.gguf --src models/quentinll/lewm-pusht \
--lewm-src tmp/<branch>/stable-worldmodel # git clone --depth 1 https://github.com/galilai-group/stable-worldmodel
$PY scripts/jepa_convert/selftest.py --gguf models/gguf/ijepa_vith14_1k-f16.gguf --src models/facebook/ijepa_vith14_1k --n-images 2
$PY scripts/jepa_convert/selftest.py --gguf models/gguf/levjepa-vitl16-f32.gguf --src models/galilai-group/LeVJEPA-VideoMix-Large --n-images 1
It reads only the GGUF (tensors + jepa.* keys), runs the architecture.md graph in numpy on
fixture images, and compares against timm / transformers / stable-worldmodel. The numpy code in
selftest.py (encoder_forward, lewm_predictor_forward, levjepa_encoder_forward) is the
executable spec of what the C++ graph builder has to do. Observed: f32 files agree to ~1e-6 relative, f16 to cosine ≥ 0.99999.
common.py¶
| helper | what it does |
|---|---|
JepaWriter(path, ftype, name=, family=, modality=, license=, source_url=) |
gguf.GGUFWriter(arch="jepa") + general.* + jepa.schema_version/family/modality |
.add_hparams(dict) |
writes every jepa.* key with the type from HPARAM_TYPES (u32 / f32 / bool / str / [u32] / [f32] / [str]); unknown keys are inferred with a warning |
.add_tensor(name, arr, quantizable) |
dtype rule above; rejects NaN/Inf and scalars |
.add_linear / .add_norm |
weight + optional bias shortcuts |
sincos_2d(gh, gw, dim, w_first=False) |
bit-identical to get_2d_sincos_pos_embed (vjepa2 pos_embs.py); w_first=True gives the [emb_w \| emb_h] variant that the HF I-JEPA checkpoints contain |
sincos_3d(gt, gh, gw, dim, uniform_power) |
bit-identical to get_3d_sincos_pos_embed |
fuse_qkv(q, k, v) |
rows [q; k; v] for weights and biases |
flatten_patch_conv(w) |
[D,C,(T,)P,P] → [D, C*T*P*P], C-T-H-W order |
fold_batchnorm(w, b, γ, β, mean, var, eps) |
eval-mode BN folded into the preceding Linear (float64 math) |
fold_linear_pair(w1, b1, w2, b2) |
Linear2∘Linear1 → one Linear |
preproc_from_hf(preprocessor_config.json) |
jepa.pre.mean/std/resize_short/crop/resample/resize_mode |
SourceTensors |
dict-like checkpoint view that records consumed keys (take, take_opt, drop, unused, layer_count) |
Family: ijepa (facebook/ijepa_vith14_1k)¶
| source (safetensors) | GGUF | note |
|---|---|---|
embeddings.patch_embeddings.projection.weight [1280,3,14,14] |
enc.patch_embed.weight [1280,588] |
flattened |
embeddings.patch_embeddings.projection.bias |
enc.patch_embed.bias |
|
embeddings.position_embeddings [1,256,1280] |
enc.pos_embed [256,1280] |
stored verbatim; it is a 2-D sincos table (bit-identical to sincos_2d(16,16,1280, w_first=True), max diff 0.0; the [emb_h\|emb_w] ordering of vjepa2/pos_embs.py differs by 2.0) |
encoder.layer.{i}.layernorm_before.* |
enc.blk.{i}.ln1.* |
|
encoder.layer.{i}.attention.attention.{query,key,value}.* |
enc.blk.{i}.attn_qkv.* |
fused [3840,1280] |
encoder.layer.{i}.attention.output.dense.* |
enc.blk.{i}.attn_out.* |
|
encoder.layer.{i}.layernorm_after.* |
enc.blk.{i}.ln2.* |
|
encoder.layer.{i}.intermediate.dense.* |
enc.blk.{i}.ffn_up.* |
[5120,1280] |
encoder.layer.{i}.output.dense.* |
enc.blk.{i}.ffn_down.* |
|
layernorm.* |
enc.norm.* |
transformers ≥ 5 names (layers.{i}.attention.q_proj, mlp.fc1, …) are accepted too.
hparams: D 1280, 32 layers, 16 heads, ffn 5120, patch 14, img 224, LN eps 1e-6, gelu_erf,
pos_type=sincos2d, no CLS, qkv_fused. Features = mean of the 256 patch tokens.
Preprocessing as shipped by HF (ViTImageProcessor): direct resize to 224×224 (resize_mode=squash),
bilinear, mean/std 0.5/0.5/0.5 — not the ImageNet statistics. general.license = cc-by-nc-4.0.
Family: hfvit (OK-AI/lejepa-vits16-pretrain-in1k)¶
Source is the OK-AI "ViTv2" (DINOv2 ViT without registers, from Open-Knowledge-AI/lite_ssl),
stored with a backbone. prefix in timm layout. Verified from the source: LayerNorm(eps=1e-6),
nn.GELU (erf), qkv_bias=proj_bias=ffn_bias=True, learned pos_embed with the CLS slot at row 0,
num_register_tokens=0, init_values=null → no LayerScale.
| source | GGUF | note |
|---|---|---|
backbone.patch_embed.proj.{weight,bias} [384,3,16,16] |
enc.patch_embed.* [384,768] |
|
backbone.cls_token [1,1,384] |
enc.cls_token [384] |
|
backbone.pos_embed [1,197,384] |
enc.pos_embed [197,384] |
row 0 = CLS |
backbone.register_tokens |
enc.reg_tokens |
absent here |
backbone.blocks.{i}.norm1 / norm2 |
enc.blk.{i}.ln1 / ln2 |
|
backbone.blocks.{i}.attn.qkv.* [1152,384] |
enc.blk.{i}.attn_qkv.* |
already fused (q,k,v row blocks) |
backbone.blocks.{i}.attn.proj.* |
enc.blk.{i}.attn_out.* |
|
backbone.blocks.{i}.mlp.fc1 / fc2 |
enc.blk.{i}.ffn_up / ffn_down |
[1536,384] / [384,1536] |
backbone.blocks.{i}.ls1.gamma / ls2.gamma |
enc.blk.{i}.ls1 / ls2 |
only when layer_scale |
backbone.norm.* |
enc.norm.* |
|
backbone.cva_module_proj.mlp.{0,2,4}.* |
— | DINOHead of the SIGReg/CVA loss; listed and skipped |
150 of 156 source tensors mapped; the 6 unmapped are exactly cva_module_proj.*.
Preprocessing (BitImageProcessor): short side 256 bicubic, centre crop 224, ImageNet mean/std.
Features: CLS token (latent) or patch tokens (patch_latent), both after enc.norm.
map_hf_vit() in the same file maps a transformers ViTModel state dict
(embeddings.cls_token, encoder.layer.{i}.attention.attention.query, …) to the identical
enc.* layout; it is used by the LeWM converter.
Family: lewm (quentinll/lewm-pusht)¶
weights.pt is a plain LeWM state dict. Sub-modules and their GGUF names:
| source | GGUF | note |
|---|---|---|
encoder.* (HF ViTModel, tiny/14: D 192, 12 L, 3 heads, ffn 768, 257 tokens) |
enc.* via map_hf_vit |
LN eps 1e-12 (ViTConfig default), gelu_erf, CLS + learned pos |
projector.net.0 Linear 192→2048 + net.1 BatchNorm1d(2048) |
enc.proj.0.{weight,bias} [2048,192] |
BN folded (eps 1e-5) |
projector.net.3 Linear 2048→192 |
enc.proj.2.{weight,bias} |
GELU between (jepa.enc.proj_act) |
predictor.pos_embedding [1,3,192] |
pred.pos_embed [3,192] |
|
predictor.transformer.layers.{i}.adaLN_modulation.1.* [1152,192] |
pred.blk.{i}.adaln.* |
applied to silu(action_emb); chunks = shift/scale/gate (attn), shift/scale/gate (mlp) |
predictor.transformer.layers.{i}.attn.norm.* |
pred.blk.{i}.ln1.* |
affine LN eps 1e-5 |
predictor.transformer.layers.{i}.attn.to_qkv.weight [3072,192] |
pred.blk.{i}.attn_qkv.weight |
no bias; 16 heads × 64 |
predictor.transformer.layers.{i}.attn.to_out.0.* [192,1024] |
pred.blk.{i}.attn_out.* |
|
predictor.transformer.layers.{i}.mlp.net.0.* |
pred.blk.{i}.ln2.* |
affine LN eps 1e-5 |
predictor.transformer.layers.{i}.mlp.net.1 / net.4 |
pred.blk.{i}.ffn_up / ffn_down |
[2048,192] / [192,2048] |
predictor.transformer.norm.* |
pred.norm.* |
|
action_encoder.patch_embed Conv1d(10,10,k=1) + embed.0 Linear 10→768 |
pred.action_embed.0.* [768,10] |
Conv1d folded into the Linear |
action_encoder.embed.2 Linear 768→192 |
pred.action_embed.2.* |
SiLU between (jepa.pred.action_act) |
pred_proj.net.0 + net.1 BatchNorm1d |
pred.proj.0.* [2048,192] |
BN folded |
pred_proj.net.3 |
pred.proj.2.* [192,2048] |
GELU between (jepa.pred.proj_act) |
The non-affine adaLN LayerNorms (norm1/norm2, eps 1e-6, jepa.pred.adaln_eps) have no tensors.
Full inference graph: see the lewm subsection of docs/gguf-schema.md and
lewm_predictor_forward in selftest.py. Preprocessing (training pipeline): ImageNet mean/std,
bilinear resize to 224 (PushT renders are square).
Family: levjepa (galilai-group/LeVJEPA-VideoMix-Large)¶
model.safetensors is the EMA copy of the encoder and nothing else: 293 tensors, all F32, no
predictor, no head, no projector. The architecture is custom and ships with the weights
(modeling_levjepa.py + configuration_levjepa.py), so trust_remote_code=True is what loads the
reference — and levjepa.py reuses the writer, the safetensors reader and the dtype policy of
vjepa2.py, since the rules are identical.
| source | GGUF | note |
|---|---|---|
encoder.patch_embed.proj.weight [1024,3,1,16,16] |
enc.patch_embed.weight [1024,768] |
Conv3d with tubelet 1, flattened C-T-H-W like the other video families |
encoder.patch_embed.proj.bias |
enc.patch_embed.bias |
|
encoder.cls_token [1,1,1024] |
enc.cls_token [1024] |
prepended after the patch embedding; no pos-embed row and no RoPE |
encoder.blocks.{i}.norm1/norm2.* |
enc.blk.{i}.ln1/ln2.* |
LN eps 1e-6 (hard-coded in LeVJEPAModel, not in the config) |
encoder.blocks.{i}.attn.qkv.* [3072,1024] |
enc.blk.{i}.attn_qkv.* |
already fused as [q; k; v], nothing to concatenate |
encoder.blocks.{i}.attn.proj.* |
enc.blk.{i}.attn_out.* |
|
encoder.blocks.{i}.mlp.fc1/fc2.* |
enc.blk.{i}.ffn_up/ffn_down.* |
[4096,1024] / [1024,4096] |
encoder.norm.* |
enc.norm.* |
Two config keys carry into the graph and one does not. attn_mode = "block_causal" becomes
jepa.enc.attn_mode and is mandatory — the same weights under full attention give CLS cosine 0.945
against the reference. use_rope = True selects the tiled 3-D RoPE of vjepa2
(rotate_queries_or_keys uses Meta's repeat(1, 1, 1, 2) expansion verbatim). token_drop_rate is a
training-time regulariser that eval() disables, so it is not converted; the converter refuses
use_rope=False and qkv_bias=False rather than guessing.
There is no preprocessor_config.json, so jepa.pre.* comes from the model card: ImageNet mean/std,
short side → 224, centre crop 224, bicubic, rescale 1/255. general.license = cc-by-nc-4.0.
Full inference graph: the levjepa section of docs/gguf-schema.md and levjepa_encoder_forward in
selftest.py.