chomeed commited on
Commit
8acc5ae
·
verified ·
1 Parent(s): 8efcc4c

DINO-WM multi-view dynamics, MimicGen coffee_d0, 60k steps

Browse files
README.md ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - robotics
4
+ - world-model
5
+ - dynamics-model
6
+ - dinov2
7
+ - mimicgen
8
+ ---
9
+
10
+ # DINO-WM multi-view dynamics — MimicGen coffee_d0
11
+
12
+ A [DINO-WM](https://arxiv.org/abs/2411.04983) style world model for two-camera robot manipulation.
13
+ It predicts **future DINOv2 patch features**, not pixels:
14
+
15
+ ```
16
+ {I_{t-8}, I_t}^agentview,eye_in_hand, a[t:t+8], s_t -> DINOv2 features of {I_{t+8}}^both views
17
+ ```
18
+
19
+ One model step advances **8 environment actions** (0.4 s at 20 fps). The forecast is a single
20
+ deterministic forward pass — no diffusion, no sampling loop.
21
+
22
+ ## Results
23
+
24
+ Held-out episodes (20 per source dataset, 16k windows). The bar to beat is **copy-the-current-frame**,
25
+ which is strong here because t+8 is only 0.4 s ahead.
26
+
27
+ | | this model | copy current frame |
28
+ |---|---|---|
29
+ | feature MSE | **0.457** | 1.451 |
30
+ | PSNR | **23.25** | 18.06 |
31
+ | SSIM | **0.9167** | 0.9014 |
32
+ | LPIPS | 0.1185 | **0.0977** |
33
+
34
+ Latent error is 68% below the baseline. Converged by ~step 51k of 60k; no overfitting
35
+ (train 0.409 vs val 0.457).
36
+
37
+ **The LPIPS loss is the decoder, not the dynamics.** Decoding *ground-truth* features through the
38
+ same head gives LPIPS 0.1023 — already worse than copying. The prediction sits within 0.016 of that
39
+ ceiling, so no dynamics improvement can win LPIPS through this decoder. Same story on PSNR
40
+ (23.25 vs a 24.80 ceiling) and SSIM (0.9167 vs 0.9218).
41
+
42
+ For context, a Stable Diffusion 3.5 canvas-conditioned model trained on the same data reached
43
+ PSNR 19.01 / SSIM 0.9061 / LPIPS 0.0685 in 7 h without converging. This model reaches PSNR 23.25 in
44
+ 5.6 h at half the memory. The split — this model wins L2 metrics, the diffusion model wins LPIPS —
45
+ is the expected regression-vs-diffusion trade: an MSE-trained predictor outputs the conditional
46
+ mean (accurate, blurry), diffusion samples a mode (sharp, less L2-accurate).
47
+
48
+ ## Ablations
49
+
50
+ 10k steps each, all scored on one common window set (see `eval_common.py` — per-run validation sets
51
+ differ because history length changes window validity, which makes raw numbers incomparable).
52
+
53
+ | arm | feature MSE | Δ vs baseline |
54
+ |---|---|---|
55
+ | history 3 | 0.5252 | −0.6% |
56
+ | **history 2 (this model)** | **0.5282** | — |
57
+ | no state conditioning | 0.5386 | +2.0% |
58
+ | history 1 | 0.5419 | +2.5% |
59
+ | **no action conditioning** | **0.5748** | **+8.8%** |
60
+
61
+ Removing the action chunk costs the most, which is the check that matters: the model is genuinely
62
+ action-conditioned rather than extrapolating visual motion. History helps slightly and saturates by
63
+ 2 frames. History 1 is 28% faster and 2.3 GB lighter for 2.5% more error — a good trade when iterating.
64
+
65
+ ## Architecture
66
+
67
+ - **Encoder**: `facebook/dinov2-small` (ViT-S/14, 384-d), **frozen**. Images resized 224→196, giving
68
+ 14×14 = 196 patch tokens per view per frame; CLS dropped.
69
+ - **Predictor**: 6-layer pre-norm transformer, 6 heads, MLP 2048, over
70
+ `[2 frames × 2 views × 196 patches] + [action token, state token] + [2 × 196 query tokens]`
71
+ = 784 context + 2 conditioning + 392 query. Camera identity is a learned view embedding; the
72
+ target is read from explicit query tokens.
73
+ - **Loss**: plain MSE in feature space.
74
+ - **Decoder** (visualisation/metrics only, trained on *detached* features): predicted features →
75
+ SD3 VAE latent → frozen SD3.5 VAE → RGB.
76
+
77
+ Trainable: **13.4M**. Peak VRAM 12.5 GB at batch 32, 96 samples/s on one RTX 5090.
78
+
79
+ ### Differences from the reference implementation
80
+
81
+ - **Multi-view.** Patch tokens of both cameras share one sequence with learned view embeddings, and
82
+ both views are predicted jointly. The reference is single-view.
83
+ - **Explicit query tokens.** The reference feeds frames 0..N−1 with full (non-causal) attention and
84
+ scores against frames 1..N, so every position but the last has its target visible in the input.
85
+ - **Proprio is input-only.** The reference predicts proprio as part of `z` and uses it in the
86
+ planning objective (`loss_visual + alpha * loss_proprio`). Here state conditions the prediction
87
+ but is not a prediction target — so this checkpoint is **not** directly usable with that objective.
88
+
89
+ ## Training data
90
+
91
+ `chomeed/mimicgen_coffee_d0_224x224_{success,failure}` and `chomeed/mimicgen_coffee_d0_224x224`
92
+ (1,200 episodes total, 285k frames, 20 fps). Cameras `agentview` and `eye_in_hand` at 224×224.
93
+ Robot state is `observation.state[:9]` = eef_pos(3) + eef_quat(4) + gripper_qpos(2).
94
+ `observation.object` and `observation.sim_state` are never read.
95
+
96
+ ## Usage
97
+
98
+ The checkpoint holds only the trainable parts (13.4M params, fp32) — the DINOv2 encoder and SD3 VAE
99
+ are downloaded separately and stay frozen.
100
+
101
+ ```python
102
+ import torch
103
+ from diffusers import AutoencoderKL
104
+ from transformers import AutoModel
105
+ from dino_dynamics import DinoDynamics
106
+
107
+ dino = AutoModel.from_pretrained("facebook/dinov2-small")
108
+ vae = AutoencoderKL.from_pretrained("stabilityai/stable-diffusion-3.5-medium",
109
+ subfolder="vae", torch_dtype=torch.bfloat16)
110
+ model = DinoDynamics(dino, vae, n_views=2, action_dim=7, state_dim=9, history=2).cuda()
111
+ model.load_state_dict(torch.load("dino_step_60000.pt"), strict=False) # dino./vae. keys absent
112
+ model.eval()
113
+
114
+ pred = model({"context": ctx, # (B, 2, 2, 3, 224, 224), frames t-8 and t, both cameras
115
+ "action": actions, # (B, 8, 7)
116
+ "state": state}) # (B, 9)
117
+ # pred: (B, 2, 196, 384) DINOv2 features at t+8
118
+ img = model.features_to_image(pred) # (B, 2, 3, 224, 224), for inspection only
119
+ ```
120
+
121
+ For planning, `predict_from_features` skips re-encoding fixed context.
122
+
123
+ ## Limitations
124
+
125
+ - **Single-step only.** Trained for one 8-action jump; multi-step rollout error compounding is
126
+ untested.
127
+ - **No planning evaluation.** The paper's headline metric is task success under CEM/MPC in the
128
+ simulator. This model is validated on prediction error only — good latent MSE does not
129
+ guarantee it plans well.
130
+ - **Reconstructions are soft.** DINOv2 features were never trained to be invertible; the decoder is
131
+ a 0.2M-parameter head. Use images for sanity checks, not as output.
132
+ - **Single seed**, one task (coffee_d0). No cross-task or cross-embodiment claims.
133
+ - **Action-gradient cost**: ~123 ms for a batch of 32 (fp32, unoptimised), and OOM at batch 256 on
134
+ 32 GB. A 3×256 MLP state-space model does the same gradient ~20,000× faster per candidate. If your
135
+ planning objective only needs state, this model is the wrong tool.
136
+
137
+ ## Dependencies and licensing
138
+
139
+ The checkpoint contains no third-party weights, but running it downloads:
140
+ `facebook/dinov2-small` (CC-BY-NC 4.0) and the `stabilityai/stable-diffusion-3.5-medium` VAE
141
+ (Stability AI Community License, gated — you must accept its terms). Their licences govern those
142
+ components, not this checkpoint.
bench_action_grad.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Cost of d(loss)/d(action) -- the inner loop of gradient-based planning.
3
+
4
+ Three regimes:
5
+ dino-wm full encode context frames, predict, backprop to the action chunk
6
+ dino-wm cached context features precomputed once (what a planner actually does across
7
+ CEM/GD iterations, since the context is fixed), predict + backprop only
8
+ mlp 3x256 a 3-layer 256-wide state-space dynamics model, (state, action) -> next state
9
+
10
+ policy_learning/lerobot/.venv/bin/python dynamics_model/bench_action_grad.py
11
+ """
12
+
13
+ import time
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ from transformers import AutoModel
19
+
20
+ from dino_dynamics import DINO_ID, DinoDynamics
21
+ from sd3_dynamics import HORIZON
22
+
23
+ V, ACT, ST, HIST, SZ = 2, 7, 9, 2, 224
24
+ BATCHES = (1, 32, 256, 1024)
25
+ REPS = 10
26
+
27
+
28
+ def timeit(fn, reps=REPS, warmup=3):
29
+ for _ in range(warmup):
30
+ fn()
31
+ torch.cuda.synchronize()
32
+ t0 = time.time()
33
+ for _ in range(reps):
34
+ fn()
35
+ torch.cuda.synchronize()
36
+ return (time.time() - t0) / reps * 1000 # ms
37
+
38
+
39
+ def main():
40
+ dev = "cuda"
41
+ torch.manual_seed(0)
42
+ dino = AutoModel.from_pretrained(DINO_ID, dtype=torch.float32)
43
+ model = DinoDynamics(dino, None, V, ACT, ST, history=HIST).to(dev).eval()
44
+ for p in model.parameters():
45
+ p.requires_grad_(False) # planning optimises actions, not weights
46
+
47
+ mlp = nn.Sequential(
48
+ nn.Linear(ST + HORIZON * ACT, 256), nn.ReLU(),
49
+ nn.Linear(256, 256), nn.ReLU(),
50
+ nn.Linear(256, 256), nn.ReLU(),
51
+ nn.Linear(256, ST),
52
+ ).to(dev).eval()
53
+ for p in mlp.parameters():
54
+ p.requires_grad_(False)
55
+ print(f"dino-wm predictor params: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M "
56
+ f"(incl. frozen DINOv2) mlp params: {sum(p.numel() for p in mlp.parameters()) / 1e3:.0f}k")
57
+
58
+ print(f"\n{'batch':>6} {'dino full':>12} {'dino cached':>13} {'mlp 3x256':>11} "
59
+ f"{'cached/mlp':>11} {'full/cached':>12}")
60
+ for b in BATCHES:
61
+ ctx = torch.rand(b, HIST, V, 3, SZ, SZ, device=dev)
62
+ state = torch.randn(b, ST, device=dev)
63
+ goal = torch.randn(b, V, model.n_patches, model.dim, device=dev)
64
+ goal_s = torch.randn(b, ST, device=dev)
65
+
66
+ def dino_full():
67
+ act = torch.randn(b, HORIZON, ACT, device=dev, requires_grad=True)
68
+ loss = F.mse_loss(model({"context": ctx, "action": act, "state": state}), goal)
69
+ return torch.autograd.grad(loss, act)[0]
70
+
71
+ # cached: encode once, reuse the features across every planner iteration
72
+ with torch.no_grad():
73
+ z = model.encode_views(ctx)
74
+
75
+ def dino_cached():
76
+ act = torch.randn(b, HORIZON, ACT, device=dev, requires_grad=True)
77
+ loss = F.mse_loss(model.predict_from_features(z, act, state), goal)
78
+ return torch.autograd.grad(loss, act)[0]
79
+
80
+ def mlp_grad():
81
+ act = torch.randn(b, HORIZON, ACT, device=dev, requires_grad=True)
82
+ loss = F.mse_loss(mlp(torch.cat([state, act.flatten(1)], 1)), goal_s)
83
+ return torch.autograd.grad(loss, act)[0]
84
+
85
+ def safe(fn):
86
+ try:
87
+ return timeit(fn)
88
+ except torch.OutOfMemoryError:
89
+ torch.cuda.empty_cache()
90
+ return float("nan")
91
+
92
+ t_full, t_cached, t_mlp = safe(dino_full), safe(dino_cached), safe(mlp_grad)
93
+ fmt = lambda x, w, p: ("OOM" if x != x else f"{x:.{p}f}ms").rjust(w)
94
+ ratio = lambda x, y: "-" if (x != x or y != y) else f"{x / y:.0f}x"
95
+ print(f"{b:>6} {fmt(t_full, 12, 2)} {fmt(t_cached, 13, 2)} {fmt(t_mlp, 11, 3)} "
96
+ f"{ratio(t_cached, t_mlp):>11} {ratio(t_full, t_cached):>12}")
97
+ del ctx, z
98
+ torch.cuda.empty_cache()
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
dino_dynamics.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """DINO-WM style multi-view dynamics: predict future DINOv2 patch features, not pixels.
3
+
4
+ {I_{t-8}, I_t}^1..V, a[t:t+8], s_t -> DINOv2 features of {I_{t+8}}^1..V
5
+
6
+ Follows DINO-WM (arXiv:2411.04983): a frozen DINOv2 encoder, a small ViT predictor trained with
7
+ plain MSE in feature space, and a decoder trained separately on detached features for visualisation
8
+ only. No diffusion, no sampling -- the forecast is deterministic.
9
+
10
+ Differences from the reference implementation, both deliberate:
11
+ * multi-view: patch tokens of every camera share one sequence, each with a learned view embedding.
12
+ * the target is read from explicit query tokens. The reference feeds frames 0..N-1 with full
13
+ (non-causal) attention and scores them against frames 1..N, so every position except the last
14
+ has its own target visible in the input; only the final position is a true forecast.
15
+
16
+ Pixel metrics need a decoder: predicted DINO features -> SD3 VAE latent -> frozen SD3 VAE -> image.
17
+ Reusing the SD3 VAE avoids training a pixel decoder from scratch.
18
+
19
+ P=policy_learning/lerobot/.venv/bin/python
20
+ PYTHONUNBUFFERED=1 $P dino_dynamics.py --data <success> <failure> --steps 20000
21
+ """
22
+
23
+ import argparse
24
+ import time
25
+ from pathlib import Path
26
+
27
+ import torch
28
+ import torch.nn as nn
29
+ import torch.nn.functional as F
30
+
31
+ from sd3_dynamics import (
32
+ HORIZON,
33
+ build_datasets,
34
+ dataset_features,
35
+ object_state_keys,
36
+ per_camera_metrics,
37
+ reconstruction_table,
38
+ )
39
+
40
+ DINO_ID = "facebook/dinov2-small" # ViT-S/14, 384-dim, as in the reference config
41
+ DINO_MEAN = (0.485, 0.456, 0.406)
42
+ DINO_STD = (0.229, 0.224, 0.225)
43
+
44
+
45
+ class DinoDynamics(nn.Module):
46
+ """Frozen DINOv2 encoder + ViT predictor over [history x views] patch tokens."""
47
+
48
+ def __init__(
49
+ self,
50
+ dino,
51
+ vae,
52
+ n_views,
53
+ action_dim,
54
+ state_dim,
55
+ history=2,
56
+ depth=6,
57
+ heads=6,
58
+ mlp_dim=2048,
59
+ horizon=HORIZON,
60
+ dino_size=196,
61
+ image_size=224,
62
+ use_action=True,
63
+ use_state=True,
64
+ ):
65
+ super().__init__()
66
+ self.dino = dino.eval().requires_grad_(False)
67
+ self.vae = vae.eval().requires_grad_(False) if vae is not None else None
68
+ d = dino.config.hidden_size
69
+ self.dim, self.n_views, self.history = d, n_views, history
70
+ # ablations zero the embedding rather than drop the token, so token count and
71
+ # model capacity are unchanged and the only variable is the information itself
72
+ self.use_action, self.use_state = use_action, use_state
73
+ self.patch = dino.config.patch_size
74
+ self.dino_size = dino_size # multiple of patch size; 196 -> 14x14 = 196 tokens
75
+ self.side = dino_size // self.patch
76
+ self.n_patches = self.side**2
77
+
78
+ self.view_emb = nn.Parameter(torch.zeros(n_views, d))
79
+ self.frame_emb = nn.Parameter(torch.zeros(history + 1, d)) # last entry = target frame
80
+ self.pos_emb = nn.Parameter(torch.zeros(self.n_patches, d))
81
+ self.query = nn.Parameter(torch.randn(self.n_patches, d) * 0.02)
82
+ self.act_tok = nn.Linear(horizon * action_dim, d)
83
+ self.state_tok = nn.Linear(state_dim, d)
84
+ layer = nn.TransformerEncoderLayer(
85
+ d, heads, mlp_dim, dropout=0.0, batch_first=True, norm_first=True, activation="gelu"
86
+ )
87
+ self.predictor = nn.TransformerEncoder(layer, depth)
88
+ self.norm = nn.LayerNorm(d)
89
+
90
+ # feature -> SD3 latent head, for pixel metrics only; trained on detached features
91
+ if vae is not None:
92
+ c = vae.config.latent_channels
93
+ # the head upsamples the patch grid 2x, so it must land exactly on the VAE latent grid
94
+ lat_side = image_size // 2 ** (len(vae.config.block_out_channels) - 1)
95
+ if self.side * 2 != lat_side:
96
+ raise ValueError(
97
+ f"patch grid {self.side}x2={self.side * 2} != VAE latent grid {lat_side} "
98
+ f"(image_size={image_size}, dino_size={dino_size}); pick dino_size = "
99
+ f"{lat_side // 2 * self.patch}"
100
+ )
101
+ self.to_latent = nn.Sequential(
102
+ nn.Conv2d(d, 4 * c, 3, padding=1), nn.GELU(), nn.PixelShuffle(2), nn.Conv2d(c, c, 3, padding=1)
103
+ )
104
+
105
+ # -- features --
106
+ @torch.no_grad()
107
+ def encode(self, imgs):
108
+ """(N,3,H,W) in [0,1] -> (N, n_patches, d) DINOv2 patch tokens."""
109
+ x = F.interpolate(imgs, self.dino_size, mode="bilinear", antialias=True, align_corners=False)
110
+ mean = torch.tensor(DINO_MEAN, device=x.device).view(1, 3, 1, 1)
111
+ std = torch.tensor(DINO_STD, device=x.device).view(1, 3, 1, 1)
112
+ out = self.dino(pixel_values=((x - mean) / std).to(self.dino.dtype)).last_hidden_state
113
+ return out[:, 1:].float() # drop CLS
114
+
115
+ def encode_views(self, imgs):
116
+ """(B,R,V,3,H,W) -> (B,R,V,P,d)."""
117
+ b, r, v = imgs.shape[:3]
118
+ z = self.encode(imgs.flatten(0, 2))
119
+ return z.reshape(b, r, v, self.n_patches, self.dim)
120
+
121
+ # -- prediction --
122
+ def forward(self, batch):
123
+ """-> predicted target features (B,V,P,d)."""
124
+ return self.predict_from_features(
125
+ self.encode_views(batch["context"]), batch["action"], batch["state"]
126
+ )
127
+
128
+ def predict_from_features(self, z, action, state):
129
+ """Context features (B,R,V,P,d) -> predicted target features (B,V,P,d).
130
+
131
+ Split out from forward so a planner can encode the (fixed) context once and reuse it
132
+ across every CEM/gradient iteration; only this part is on the action-gradient path.
133
+ """
134
+ b, r, v = z.shape[:3]
135
+ z = z + self.view_emb[None, None, :, None] + self.pos_emb[None, None, None]
136
+ z = z + self.frame_emb[:r][None, :, None, None]
137
+ tokens = z.reshape(b, r * v * self.n_patches, self.dim)
138
+
139
+ q = self.query[None, None] + self.view_emb[None, :, None] + self.pos_emb[None, None]
140
+ q = q + self.frame_emb[-1][None, None, None]
141
+ q = q.expand(b, v, self.n_patches, self.dim).reshape(b, v * self.n_patches, self.dim)
142
+
143
+ act = self.act_tok(action.flatten(1)) * float(self.use_action)
144
+ st = self.state_tok(state) * float(self.use_state)
145
+ cond = torch.stack([act, st], 1) # (B,2,d)
146
+ out = self.predictor(torch.cat([tokens, cond, q], 1))
147
+ return self.norm(out[:, -v * self.n_patches :]).reshape(b, v, self.n_patches, self.dim)
148
+
149
+ def loss(self, batch):
150
+ pred = self(batch)
151
+ with torch.no_grad():
152
+ tgt = self.encode_views(batch["future"][:, None])[:, 0] # (B,V,P,d)
153
+ return F.mse_loss(pred, tgt), tgt
154
+
155
+ # -- pixels, for metrics and W&B only --
156
+ def features_to_image(self, feats):
157
+ """(B,V,P,d) -> (B,V,3,H,W) in [0,1], through the frozen SD3 VAE."""
158
+ b, v = feats.shape[:2]
159
+ x = feats.reshape(b * v, self.side, self.side, self.dim).permute(0, 3, 1, 2)
160
+ lat = self.to_latent(x)
161
+ z = lat.to(self.vae.dtype) / self.vae.config.scaling_factor + self.vae.config.shift_factor
162
+ img = (self.vae.decode(z).sample / 2 + 0.5).clamp(0, 1).float()
163
+ return img.reshape(b, v, *img.shape[1:])
164
+
165
+ def decoder_loss(self, batch, tgt_feats):
166
+ """Train the feature->latent head against the true frames. Detached from the predictor."""
167
+ with torch.no_grad():
168
+ imgs = batch["future"].flatten(0, 1)
169
+ z = self.vae.encode(imgs.to(self.vae.dtype) * 2 - 1).latent_dist.mode()
170
+ z = ((z - self.vae.config.shift_factor) * self.vae.config.scaling_factor).float()
171
+ b, v = batch["future"].shape[:2]
172
+ x = tgt_feats.detach().reshape(b * v, self.side, self.side, self.dim).permute(0, 3, 1, 2)
173
+ return F.mse_loss(self.to_latent(x), z)
174
+
175
+
176
+ def main():
177
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
178
+ ap.add_argument("--data", nargs="+", required=True)
179
+ ap.add_argument("--dino", default=DINO_ID)
180
+ ap.add_argument("--sd3", default="stabilityai/stable-diffusion-3.5-medium", help="VAE for decoding")
181
+ ap.add_argument("--state-key", default="observation.state")
182
+ ap.add_argument("--state-dim", type=int, default=9)
183
+ ap.add_argument("--image-size", type=int, nargs=2, default=(224, 224))
184
+ ap.add_argument("--history", type=int, default=2, help="context frames incl. current")
185
+ ap.add_argument("--history-stride", type=int, default=HORIZON, help="default = horizon: evenly spaced")
186
+ ap.add_argument("--no-action", action="store_true", help="ablation: zero the action conditioning")
187
+ ap.add_argument("--no-state", action="store_true", help="ablation: zero the state conditioning")
188
+ ap.add_argument("--depth", type=int, default=6)
189
+ ap.add_argument("--heads", type=int, default=6)
190
+ ap.add_argument("--batch-size", type=int, default=32)
191
+ ap.add_argument("--steps", type=int, default=20000)
192
+ ap.add_argument("--lr", type=float, default=5e-4, help="reference predictor_lr")
193
+ ap.add_argument("--decoder-lr", type=float, default=3e-4)
194
+ ap.add_argument("--log-every", type=int, default=100)
195
+ ap.add_argument("--val-episodes", type=int, default=20)
196
+ ap.add_argument("--val-every", type=int, default=500)
197
+ ap.add_argument("--val-batches", type=int, default=4)
198
+ ap.add_argument("--log-images", type=int, default=4)
199
+ ap.add_argument("--workers", type=int, default=8)
200
+ ap.add_argument("--out", default=str(Path(__file__).parent / "outputs"))
201
+ ap.add_argument("--wandb-project", default="sd3-dynamics")
202
+ ap.add_argument("--run-name", default=None)
203
+ args = ap.parse_args()
204
+
205
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
206
+ torch.manual_seed(0)
207
+ image_size = tuple(args.image_size)
208
+ train_ds, val_ds, cameras = build_datasets(
209
+ args.data, image_size, args.state_key, args.state_dim, args.val_episodes,
210
+ args.history, args.history_stride,
211
+ )
212
+ feats = dataset_features(args.data[0])
213
+ assert args.state_key not in object_state_keys(feats), "object state must not reach the model"
214
+ action_dim = feats["action"]["shape"][0]
215
+ print(f"cameras={cameras} action_dim={action_dim} state={args.state_key}[:{args.state_dim}]")
216
+ print(f"excluded from the model: {object_state_keys(feats)}")
217
+ print(f"train windows={len(train_ds)} val windows={len(val_ds) if val_ds else 0}")
218
+
219
+ from diffusers import AutoencoderKL
220
+ from transformers import AutoModel
221
+
222
+ dino = AutoModel.from_pretrained(args.dino, torch_dtype=torch.float32)
223
+ vae = AutoencoderKL.from_pretrained(args.sd3, subfolder="vae", torch_dtype=torch.bfloat16)
224
+ model = DinoDynamics(
225
+ dino, vae, len(cameras), action_dim, args.state_dim, args.history, args.depth, args.heads,
226
+ image_size=image_size[0], use_action=not args.no_action, use_state=not args.no_state,
227
+ ).to(dev)
228
+ pred_params = [p for n, p in model.named_parameters() if p.requires_grad and not n.startswith("to_latent")]
229
+ dec_params = list(model.to_latent.parameters())
230
+ print(f"predictor params: {sum(p.numel() for p in pred_params) / 1e6:.1f}M "
231
+ f"decoder head: {sum(p.numel() for p in dec_params) / 1e6:.1f}M "
232
+ f"tokens: {args.history * len(cameras) * model.n_patches} ctx + {len(cameras) * model.n_patches} query")
233
+ opt = torch.optim.AdamW(
234
+ [{"params": pred_params, "lr": args.lr}, {"params": dec_params, "lr": args.decoder_lr}]
235
+ )
236
+
237
+ lpips = None
238
+ if val_ds is not None:
239
+ from torchmetrics.image.lpip import LearnedPerceptualImagePatchSimilarity
240
+
241
+ lpips = LearnedPerceptualImagePatchSimilarity(net_type="alex", normalize=True).to(dev)
242
+
243
+ run = None
244
+ if args.wandb_project:
245
+ import wandb
246
+
247
+ run = wandb.init(
248
+ project=args.wandb_project, name=args.run_name, config=vars(args) | {"cameras": cameras}
249
+ )
250
+
251
+ def loader(ds, shuffle):
252
+ return torch.utils.data.DataLoader(
253
+ ds, batch_size=args.batch_size, shuffle=shuffle, num_workers=args.workers,
254
+ drop_last=True, pin_memory=True,
255
+ )
256
+
257
+ def to_dev(b):
258
+ return {k: v.to(dev, non_blocking=True) for k, v in b.items()}
259
+
260
+ def log(d, step):
261
+ print(f"step {step}: " + " ".join(f"{k}={v:.4f}" for k, v in d.items() if isinstance(v, float)))
262
+ if run:
263
+ run.log(d, step=step)
264
+
265
+ @torch.no_grad()
266
+ def validate(step):
267
+ model.eval()
268
+ sums, n = {}, 0
269
+ for i, batch in enumerate(loader(val_ds, False)):
270
+ if i >= args.val_batches:
271
+ break
272
+ batch = to_dev(batch)
273
+ pred = model(batch)
274
+ tgt = model.encode_views(batch["future"][:, None])[:, 0]
275
+ cur = model.encode_views(batch["context"][:, -1:])[:, 0]
276
+ sums["feat_mse"] = sums.get("feat_mse", 0.0) + F.mse_loss(pred, tgt).item()
277
+ sums["copy_feat_mse"] = sums.get("copy_feat_mse", 0.0) + F.mse_loss(cur, tgt).item()
278
+ img = model.features_to_image(pred)
279
+ current = batch["context"][:, -1]
280
+ for k, v in per_camera_metrics(img, batch["future"], cameras, lpips).items():
281
+ sums[k] = sums.get(k, 0.0) + v
282
+ for k, v in per_camera_metrics(current, batch["future"], cameras, lpips).items():
283
+ sums[f"copy_{k}"] = sums.get(f"copy_{k}", 0.0) + v
284
+ # decoder ceiling: what the feature->pixel head gives on GROUND-TRUTH features
285
+ for k, v in per_camera_metrics(
286
+ model.features_to_image(tgt), batch["future"], cameras, lpips
287
+ ).items():
288
+ sums[f"oracle_{k}"] = sums.get(f"oracle_{k}", 0.0) + v
289
+ n += 1
290
+ if i == 0 and run:
291
+ k = min(args.log_images, img.shape[0])
292
+ run.log(
293
+ {"val/reconstructions": reconstruction_table(
294
+ current[:k], batch["future"][:k], img[:k], cameras)},
295
+ step=step,
296
+ )
297
+ model.train()
298
+ log({f"val/{k}": v / max(n, 1) for k, v in sums.items()}, step)
299
+
300
+ out = Path(args.out)
301
+ out.mkdir(parents=True, exist_ok=True)
302
+ step, running, rdec, t0 = 0, 0.0, 0.0, time.time()
303
+ model.train()
304
+ while step < args.steps:
305
+ for batch in loader(train_ds, True):
306
+ batch = to_dev(batch)
307
+ feat_loss, tgt = model.loss(batch)
308
+ dec_loss = model.decoder_loss(batch, tgt)
309
+ (feat_loss + dec_loss).backward()
310
+ opt.step()
311
+ opt.zero_grad(set_to_none=True)
312
+ running += feat_loss.item()
313
+ rdec += dec_loss.item()
314
+ step += 1
315
+ if step % args.log_every == 0:
316
+ dt = (time.time() - t0) / args.log_every
317
+ log(
318
+ {
319
+ "train/feat_mse": running / args.log_every,
320
+ "train/dec_mse": rdec / args.log_every,
321
+ "train/peak_gb": torch.cuda.max_memory_allocated() / 1e9 if dev == "cuda" else 0.0,
322
+ "train/s_per_step": dt,
323
+ "train/samples_per_s": args.batch_size / dt,
324
+ },
325
+ step,
326
+ )
327
+ running, rdec, t0 = 0.0, 0.0, time.time()
328
+ if val_ds is not None and step % args.val_every == 0:
329
+ validate(step)
330
+ if step % 5000 == 0 or step == args.steps:
331
+ torch.save(
332
+ {k: v for k, v in model.state_dict().items() if not k.startswith(("dino.", "vae."))},
333
+ out / f"dino_step_{step}.pt",
334
+ )
335
+ if step >= args.steps:
336
+ break
337
+
338
+
339
+ if __name__ == "__main__":
340
+ main()
dino_step_60000.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dfaf37eada0a586159d373b61faa03c874cae5c76b8b5d0a1bb427e34d203f7d
3
+ size 53675521
eval_common.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Score every ablation on ONE common validation set.
3
+
4
+ The training runs each used their own valid-window set (requiring more history invalidates more
5
+ windows near episode starts), so their feat_mse values are not comparable. This evaluates every
6
+ checkpoint on the windows valid for the largest history, feeding each model only the context
7
+ frames it was trained with.
8
+
9
+ policy_learning/lerobot/.venv/bin/python dynamics_model/eval_common.py
10
+ """
11
+
12
+ import torch
13
+ import torch.nn.functional as F
14
+ from diffusers import AutoencoderKL
15
+ from transformers import AutoModel
16
+
17
+ from dino_dynamics import DINO_ID, DinoDynamics
18
+ from sd3_dynamics import build_datasets, dataset_features, per_camera_metrics
19
+
20
+ DATA = [
21
+ "chomeed/mimicgen_coffee_d0_224x224_mtdit_flow_60k_success",
22
+ "chomeed/mimicgen_coffee_d0_224x224_mtdit_flow_60k_failure",
23
+ "chomeed/mimicgen_coffee_d0_224x224",
24
+ ]
25
+ SD3 = "stabilityai/stable-diffusion-3.5-medium"
26
+ COMMON_HISTORY = 3 # most restrictive -> its windows are a subset of every arm's
27
+ BATCHES, BS = 8, 32
28
+
29
+ ARMS = [ # name, checkpoint, history it was trained with, use_action, use_state
30
+ ("h1", "outputs/abl_h1/dino_step_10000.pt", 1, True, True),
31
+ ("h2 (baseline)", "outputs/dino_step_10000.pt", 2, True, True),
32
+ ("h3", "outputs/abl_h3/dino_step_10000.pt", 3, True, True),
33
+ ("noaction", "outputs/abl_noaction/dino_step_10000.pt", 2, False, True),
34
+ ("nostate", "outputs/abl_nostate/dino_step_10000.pt", 2, True, False),
35
+ ]
36
+
37
+
38
+ def main():
39
+ dev = "cuda"
40
+ torch.manual_seed(0)
41
+ _, val, cameras = build_datasets(DATA, (224, 224), "observation.state", 9, 20, COMMON_HISTORY, 8)
42
+ print(f"common val windows: {len(val)} cameras: {cameras}")
43
+ action_dim = dataset_features(DATA[0])["action"]["shape"][0]
44
+
45
+ dino = AutoModel.from_pretrained(DINO_ID, torch_dtype=torch.float32)
46
+ vae = AutoencoderKL.from_pretrained(SD3, subfolder="vae", torch_dtype=torch.bfloat16)
47
+ from torchmetrics.image.lpip import LearnedPerceptualImagePatchSimilarity
48
+
49
+ lpips = LearnedPerceptualImagePatchSimilarity(net_type="alex", normalize=True).to(dev)
50
+
51
+ # fixed batches, identical for every arm
52
+ dl = torch.utils.data.DataLoader(val, batch_size=BS, shuffle=False, num_workers=8, drop_last=True)
53
+ batches = []
54
+ for i, b in enumerate(dl):
55
+ if i >= BATCHES:
56
+ break
57
+ batches.append({k: v.to(dev) for k, v in b.items()})
58
+
59
+ print(f"\n{'arm':<15}{'feat_mse':>10}{'copy':>9}{'ratio':>8}{'psnr':>8}{'ssim':>8}{'lpips':>8}")
60
+ for name, ckpt, hist, use_act, use_st in ARMS:
61
+ model = DinoDynamics(
62
+ dino, vae, len(cameras), action_dim, 9, history=hist,
63
+ use_action=use_act, use_state=use_st,
64
+ ).to(dev)
65
+ sd = torch.load(ckpt, map_location=dev)
66
+ missing, unexpected = model.load_state_dict(sd, strict=False)
67
+ assert not unexpected, f"{name}: unexpected keys {unexpected[:3]}"
68
+ assert all(k.startswith(("dino.", "vae.")) for k in missing), f"{name}: missing {missing[:3]}"
69
+ model.eval()
70
+
71
+ sums = {}
72
+ with torch.no_grad():
73
+ for b in batches:
74
+ b = dict(b, context=b["context"][:, -hist:]) # only the frames this arm was trained on
75
+ pred = model(b)
76
+ tgt = model.encode_views(b["future"][:, None])[:, 0]
77
+ cur = model.encode_views(b["context"][:, -1:])[:, 0]
78
+ sums["feat"] = sums.get("feat", 0.0) + F.mse_loss(pred, tgt).item()
79
+ sums["copy"] = sums.get("copy", 0.0) + F.mse_loss(cur, tgt).item()
80
+ for k, v in per_camera_metrics(
81
+ model.features_to_image(pred), b["future"], cameras, lpips
82
+ ).items():
83
+ if "mean" in k:
84
+ sums[k] = sums.get(k, 0.0) + v
85
+ n = len(batches)
86
+ f, c = sums["feat"] / n, sums["copy"] / n
87
+ print(f"{name:<15}{f:>10.4f}{c:>9.4f}{f / c:>8.3f}"
88
+ f"{sums['mean/psnr'] / n:>8.2f}{sums['mean/ssim'] / n:>8.4f}{sums['mean/lpips'] / n:>8.4f}")
89
+ del model
90
+ torch.cuda.empty_cache()
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
sd3_dynamics.py ADDED
@@ -0,0 +1,595 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Multi-view SD3 dynamics model.
3
+
4
+ {I_{t-km..t}^1..V}, a[t:t+8], s_t -> {I_{t+8}^1..V}
5
+
6
+ Conditioning follows Ctrl-World (arXiv:2510.10125): context frames and the noisy target live in
7
+ ONE token sequence so they interact through full self-attention, instead of the target attending to
8
+ frame features through the text/cross-attention pathway. Here that is done by tiling latents into a
9
+ single canvas -- rows are timesteps (history..current, then the target), columns are camera views --
10
+ and letting the unmodified MMDiT attend over it. Loss and sampling touch the target row only.
11
+
12
+ An earlier design fed frames in as `encoder_hidden_states` only; it trained to a validation PSNR of
13
+ 7.9 dB against 19.0 dB for simply copying the current frame, i.e. worse than doing nothing.
14
+
15
+ Env: policy_learning/lerobot/.venv (lerobot + diffusers + torchmetrics + wandb + peft).
16
+
17
+ P=policy_learning/lerobot/.venv/bin/python
18
+ $P sd3_dynamics.py --data <success> <failure> --inspect
19
+ PYTHONUNBUFFERED=1 $P sd3_dynamics.py --data <success> <failure> --steps 20000
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import math
25
+ import time
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+ import torch
30
+ import torch.nn as nn
31
+ import torch.nn.functional as F
32
+ from diffusers import AutoencoderKL, FlowMatchEulerDiscreteScheduler, SD3Transformer2DModel
33
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
34
+
35
+ HORIZON = 8 # action chunk length AND image lead time, per spec
36
+
37
+
38
+ # ── data ──────────────────────────────────────────────────────────────────────
39
+
40
+
41
+ def dataset_info(spec) -> tuple[str, Path | None, dict]:
42
+ """Accepts a local dataset root or an HF repo id -> (repo_id, root|None, info.json)."""
43
+ root = Path(spec)
44
+ if (root / "meta" / "info.json").exists():
45
+ return f"local/{root.name}", root, json.loads((root / "meta" / "info.json").read_text())
46
+ from huggingface_hub import hf_hub_download
47
+
48
+ return spec, None, json.loads(Path(hf_hub_download(spec, "meta/info.json", repo_type="dataset")).read_text())
49
+
50
+
51
+ def dataset_features(spec) -> dict:
52
+ return dataset_info(spec)[2]["features"]
53
+
54
+
55
+ def discover_cameras(features: dict) -> list[str]:
56
+ """Every RGB camera feature, deterministically ordered. No hard-coded names."""
57
+ cams = sorted(k for k, v in features.items() if v.get("dtype") in ("video", "image"))
58
+ if not cams:
59
+ raise ValueError(f"no image/video features found among {sorted(features)}")
60
+ return cams
61
+
62
+
63
+ def object_state_keys(features: dict) -> list[str]:
64
+ """Simulator/object-state features. Present in MimicGen data; must never reach the model."""
65
+ return sorted(k for k in features if "object" in k.lower() or "sim_state" in k.lower())
66
+
67
+
68
+ class MultiViewWindows(torch.utils.data.Dataset):
69
+ """(history+current frames, future frames, 8-action chunk, state) windows from one dataset."""
70
+
71
+ def __init__(
72
+ self,
73
+ spec,
74
+ cameras,
75
+ state_key,
76
+ state_dim,
77
+ image_size,
78
+ episodes=None,
79
+ horizon=HORIZON,
80
+ history=1,
81
+ stride=4,
82
+ ):
83
+ self.cameras, self.state_key, self.state_dim = cameras, state_key, state_dim
84
+ self.image_size, self.horizon = image_size, horizon
85
+ self.history, self.stride = history, stride
86
+ repo_id, root, info = dataset_info(spec)
87
+ fps = info["fps"]
88
+ # oldest history frame first, then the current frame, then the target
89
+ self.offsets = [-(history - 1 - i) * stride for i in range(history)] + [horizon]
90
+ dt = {"action": [i / fps for i in range(horizon)]}
91
+ dt |= {c: [o / fps for o in self.offsets] for c in cameras}
92
+ self.ds = LeRobotDataset(repo_id, root=root, delta_timestamps=dt)
93
+
94
+ # episode boundaries: every offset, past and future, must land in the same episode
95
+ ep = np.asarray(self.ds.hf_dataset.select_columns("episode_index")["episode_index"])
96
+ back, fwd = (history - 1) * stride, horizon
97
+ idx = np.arange(back, len(ep) - fwd)
98
+ valid = idx[(ep[idx - back] == ep[idx]) & (ep[idx + fwd] == ep[idx])]
99
+ if episodes is not None:
100
+ valid = valid[np.isin(ep[valid], list(episodes))]
101
+ self.indices = valid
102
+
103
+ def __len__(self):
104
+ return len(self.indices)
105
+
106
+ def __getitem__(self, i):
107
+ item = self.ds[int(self.indices[i])]
108
+ frames = [
109
+ # antialiased resize overshoots [0,1] slightly; LPIPS and the VAE both want it clamped
110
+ F.interpolate(item[c], self.image_size, mode="bilinear", antialias=True, align_corners=False)
111
+ .clamp(0, 1)
112
+ for c in self.cameras
113
+ ] # each (history+1, 3, h, w)
114
+ stacked = torch.stack(frames, 1) # (history+1, V, 3, h, w)
115
+ return {
116
+ "context": stacked[:-1], # (history, V, 3, h, w), oldest first
117
+ "future": stacked[-1], # (V, 3, h, w)
118
+ "action": item["action"],
119
+ # first state_dim entries only: eef_pos(3) + eef_quat(4) + gripper_qpos(2) on MimicGen.
120
+ # Object/sim state lives in separate features and is never read.
121
+ "state": item[self.state_key][: self.state_dim],
122
+ }
123
+
124
+
125
+ def build_datasets(specs, image_size, state_key, state_dim, val_episodes, history=1, stride=4):
126
+ """Concat all datasets; hold out the last `val_episodes` episodes of each for validation."""
127
+ cameras = None
128
+ train, val = [], []
129
+ for spec in specs:
130
+ _, _, info = dataset_info(spec)
131
+ cams = discover_cameras(info["features"])
132
+ if cameras is None:
133
+ cameras = cams
134
+ elif cams != cameras:
135
+ raise ValueError(f"camera mismatch: {spec} has {cams}, expected {cameras}")
136
+ n_ep = info["total_episodes"]
137
+ holdout = set(range(max(0, n_ep - val_episodes), n_ep))
138
+ args = (cams, state_key, state_dim, image_size)
139
+ kw = dict(history=history, stride=stride)
140
+ train.append(MultiViewWindows(spec, *args, set(range(n_ep)) - holdout, **kw))
141
+ if holdout:
142
+ val.append(MultiViewWindows(spec, *args, holdout, **kw))
143
+ cat = torch.utils.data.ConcatDataset
144
+ return cat(train), (cat(val) if val else None), cameras
145
+
146
+
147
+ # ── model ─────────────────────────────────────────────────────────────────────
148
+
149
+
150
+ class SD3MultiViewDynamics(nn.Module):
151
+ """SD3 MMDiT over a canvas of [history..current | target] frames × camera views.
152
+
153
+ Camera identity is positional: view v always occupies column v of the canvas, in the sorted
154
+ camera order, so it is consistent between training and evaluation. Because every view sits in
155
+ one sequence, the predicted views also attend to each other -- they are denoised jointly, not
156
+ in independent batch rows.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ vae,
162
+ transformer,
163
+ n_views,
164
+ action_dim,
165
+ state_dim,
166
+ ctx_noise=0.0,
167
+ aux_weight=0.0,
168
+ aux_layer=None,
169
+ action_dropout=0.2,
170
+ horizon=HORIZON,
171
+ ):
172
+ super().__init__()
173
+ self.vae = vae.eval().requires_grad_(False)
174
+ self.transformer = transformer
175
+ self.n_views, self.ctx_noise = n_views, ctx_noise
176
+ self.action_dim, self.horizon = action_dim, horizon
177
+ self.aux_weight, self.action_dropout = aux_weight, action_dropout
178
+ d = transformer.config.joint_attention_dim
179
+ p = transformer.config.pooled_projection_dim
180
+ # actions and state are not spatial, so they stay on the cross-attention pathway
181
+ self.act_tok = nn.Linear(action_dim, d)
182
+ self.state_tok = nn.Linear(state_dim, d)
183
+ self.pool = nn.Linear(d, p)
184
+ # zero-init: step 0 is the pretrained MMDiT on empty context, not on random noise
185
+ for m in (self.act_tok, self.state_tok, self.pool):
186
+ nn.init.zeros_(m.weight)
187
+ nn.init.zeros_(m.bias)
188
+
189
+ self._aux_feat = None
190
+ if aux_weight:
191
+ inner = transformer.config.num_attention_heads * transformer.config.attention_head_dim
192
+ self.aux_head = nn.Sequential(
193
+ nn.LayerNorm(inner), nn.Linear(inner, inner), nn.GELU(), nn.Linear(inner, horizon * action_dim)
194
+ )
195
+ blocks = transformer.transformer_blocks
196
+ self.aux_layer = len(blocks) // 2 if aux_layer is None else aux_layer
197
+ # JointTransformerBlock returns (encoder_hidden_states, hidden_states); grab the image stream
198
+ blocks[self.aux_layer].register_forward_hook(lambda m, i, o: setattr(self, "_aux_feat", o[-1]))
199
+
200
+ @classmethod
201
+ def from_pretrained(cls, sd3_id, n_views, action_dim, state_dim, dtype=torch.bfloat16, **kw):
202
+ vae = AutoencoderKL.from_pretrained(sd3_id, subfolder="vae", torch_dtype=dtype)
203
+ tr = SD3Transformer2DModel.from_pretrained(sd3_id, subfolder="transformer", torch_dtype=dtype)
204
+ return cls(vae, tr, n_views, action_dim, state_dim, **kw)
205
+
206
+ # -- latents --
207
+ def encode(self, imgs, sample=False):
208
+ """(N,3,H,W) in [0,1] -> scaled latents (N,C,h,w)."""
209
+ dist = self.vae.encode(imgs.to(self.vae.dtype) * 2 - 1).latent_dist
210
+ z = dist.sample() if sample else dist.mode()
211
+ return (z - self.vae.config.shift_factor) * self.vae.config.scaling_factor
212
+
213
+ def decode(self, z):
214
+ z = z.to(self.vae.dtype) / self.vae.config.scaling_factor + self.vae.config.shift_factor
215
+ return (self.vae.decode(z).sample / 2 + 0.5).clamp(0, 1)
216
+
217
+ def encode_grid(self, imgs, **kw):
218
+ """(B,R,V,3,H,W) -> (B,R,V,C,h,w)."""
219
+ b, r, v = imgs.shape[:3]
220
+ z = self.encode(imgs.flatten(0, 2), **kw)
221
+ return z.reshape(b, r, v, *z.shape[1:])
222
+
223
+ # -- canvas: rows are timesteps, columns are views --
224
+ @staticmethod
225
+ def to_canvas(z):
226
+ """(B,R,V,C,h,w) -> (B,C,R*h,V*w)."""
227
+ b, r, v, c, h, w = z.shape
228
+ return z.permute(0, 3, 1, 4, 2, 5).reshape(b, c, r * h, v * w)
229
+
230
+ @staticmethod
231
+ def from_canvas(canvas, v):
232
+ """(B,C,h,V*w) single row -> (B,V,C,h,w)."""
233
+ b, c, h, vw = canvas.shape
234
+ return canvas.reshape(b, c, h, v, vw // v).permute(0, 3, 1, 2, 4)
235
+
236
+ def conditioning(self, action, state, drop=None):
237
+ """Action chunk and robot state as cross-attention tokens -> (ctx, pooled).
238
+
239
+ `drop` is a (B,) bool mask zeroing the action tokens, so the auxiliary head has to recover
240
+ the action from the frames instead of reading it straight off the conditioning.
241
+ """
242
+ dt = self.act_tok.weight.dtype
243
+ act = self.act_tok(action.to(dt))
244
+ if drop is not None:
245
+ act = act * (~drop).to(dt).view(-1, 1, 1)
246
+ ctx = torch.cat([act, self.state_tok(state.to(dt))[:, None]], 1)
247
+ return ctx, self.pool(ctx.mean(1))
248
+
249
+ def denoise(self, canvas, timestep, ctx, pooled):
250
+ dt = self.transformer.dtype # conditioning heads stay fp32; cast at the MMDiT boundary
251
+ return self.transformer(
252
+ hidden_states=canvas.to(dt),
253
+ encoder_hidden_states=ctx.to(dt),
254
+ pooled_projections=pooled.to(dt),
255
+ timestep=timestep,
256
+ return_dict=False,
257
+ )[0]
258
+
259
+ def _context_rows(self, context):
260
+ """Encoded history+current rows, optionally noise-perturbed for robustness (Ctrl-World §3)."""
261
+ z = self.encode_grid(context)
262
+ if self.ctx_noise:
263
+ z = z + self.ctx_noise * torch.randn_like(z)
264
+ return z
265
+
266
+ # -- training / sampling --
267
+ def loss(self, batch):
268
+ """Rectified-flow matching on the target row of the canvas."""
269
+ z_ctx = self._context_rows(batch["context"])
270
+ z_tgt = self.encode_grid(batch["future"][:, None], sample=True) # (B,1,V,C,h,w)
271
+ b, _, v, c, h, w = z_tgt.shape
272
+ t = torch.sigmoid(torch.randn(b, device=z_tgt.device, dtype=z_tgt.dtype)) # logit-normal
273
+ noise = torch.randn_like(z_tgt)
274
+ tt = t.view(-1, 1, 1, 1, 1, 1) # (B,R,V,C,h,w) is 6-D; a 5-D view broadcasts into the row axis
275
+ z_t = (1 - tt) * z_tgt + tt * noise
276
+ canvas = self.to_canvas(torch.cat([z_ctx, z_t], 1))
277
+ drop = torch.rand(b, device=canvas.device) < self.action_dropout if self.aux_weight else None
278
+ ctx, pooled = self.conditioning(batch["action"], batch["state"], drop)
279
+ pred = self.denoise(canvas, t * 1000, ctx, pooled)[:, :, -h:, :] # target row only
280
+ flow = F.mse_loss(pred.float(), self.to_canvas(noise - z_tgt).float())
281
+ if not self.aux_weight or not drop.any():
282
+ return flow
283
+ # inverse dynamics on the action-dropped rows only: with the action still conditioned in,
284
+ # the head would just read it back off the context tokens and learn nothing.
285
+ feat = self._aux_feat[drop].float().mean(1)
286
+ a_pred = self.aux_head(feat).view(-1, self.horizon, self.action_dim)
287
+ return flow + self.aux_weight * F.mse_loss(a_pred, batch["action"][drop].float())
288
+
289
+ @torch.no_grad()
290
+ def predict(self, batch, scheduler, steps=20):
291
+ """-> predicted future frames (B,V,3,H,W) in [0,1]."""
292
+ z_ctx = self._context_rows(batch["context"])
293
+ b, _, v, c, h, w = z_ctx.shape
294
+ ctx, pooled = self.conditioning(batch["action"], batch["state"])
295
+ z = torch.randn((b, 1, v, c, h, w), device=z_ctx.device, dtype=z_ctx.dtype)
296
+ scheduler.set_timesteps(steps, device=z_ctx.device)
297
+ for t in scheduler.timesteps:
298
+ canvas = self.to_canvas(torch.cat([z_ctx, z], 1))
299
+ vel = self.denoise(canvas, t.expand(b), ctx, pooled)[:, :, -h:, :]
300
+ row = scheduler.step(vel.float(), t, self.to_canvas(z).float(), return_dict=False)[0]
301
+ z = self.from_canvas(row.to(z_ctx.dtype), v)[:, None]
302
+ imgs = self.decode(z.flatten(0, 2))
303
+ return imgs.float().reshape(b, v, *imgs.shape[1:])
304
+
305
+
306
+ # ── evaluation ────────────────────────────────────────────────────────────────
307
+
308
+
309
+ def per_camera_metrics(pred, gt, cameras, lpips=None):
310
+ """pred/gt: (B,V,3,H,W) in [0,1]. -> {'<cam>/mse': ..., 'mean/mse': ...}"""
311
+ from torchmetrics.functional import structural_similarity_index_measure as ssim_fn
312
+
313
+ out = {}
314
+ for i, cam in enumerate(cameras):
315
+ p, g = pred[:, i], gt[:, i]
316
+ mse = F.mse_loss(p, g).item()
317
+ out[f"{cam}/mse"] = mse
318
+ out[f"{cam}/psnr"] = 10 * math.log10(1.0 / max(mse, 1e-12)) # data range 1.0
319
+ out[f"{cam}/ssim"] = ssim_fn(p, g, data_range=1.0).item()
320
+ if lpips is not None:
321
+ out[f"{cam}/lpips"] = lpips(p, g).item()
322
+ for m in ("mse", "psnr", "ssim", "lpips"):
323
+ vals = [v for k, v in out.items() if k.endswith(f"/{m}")]
324
+ if vals:
325
+ out[f"mean/{m}"] = sum(vals) / len(vals)
326
+ return out
327
+
328
+
329
+ def to_wandb_image(img):
330
+ import wandb
331
+
332
+ return wandb.Image((img.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8))
333
+
334
+
335
+ def reconstruction_table(current, gt, pred, cameras, start_id=0):
336
+ """One row per example, one Current/GT/Pred column group per camera."""
337
+ import wandb
338
+
339
+ cols = ["sample_id"] + [f"{c}/{k}" for c in cameras for k in ("current", "gt", "pred")]
340
+ table = wandb.Table(columns=cols)
341
+ for b in range(current.shape[0]):
342
+ row = [start_id + b]
343
+ for i in range(len(cameras)):
344
+ row += [to_wandb_image(x[b, i]) for x in (current, gt, pred)]
345
+ table.add_data(*row)
346
+ return table
347
+
348
+
349
+ # ── dataset safety checks (spec §15 / §22) ────────────────────────────────────
350
+
351
+
352
+ def inspect(specs, state_key, state_dim, image_size, history=1, stride=4, horizon=HORIZON):
353
+ ref_cams = None
354
+ for spec in specs:
355
+ _, _, info = dataset_info(spec)
356
+ feats = info["features"]
357
+ cams = discover_cameras(feats)
358
+ print(f"\n=== {spec}")
359
+ print(f" fps={info['fps']} episodes={info['total_episodes']} frames={info['total_frames']}")
360
+ print(f" splits={info.get('splits')}")
361
+ print(f" all keys: {sorted(feats)}")
362
+ print(f" cameras (deterministic order): {cams}")
363
+ for c in cams:
364
+ f = feats[c]
365
+ print(f" {c}: shape={f['shape']} names={f.get('names')} dtype={f['dtype']}")
366
+ print(f" action dim: {feats['action']['shape']}")
367
+ full = feats[state_key]["shape"][0]
368
+ print(f" state: {state_key}[:{state_dim}] of {full}D")
369
+ assert full >= state_dim, f"{state_key} is only {full}D, need {state_dim}"
370
+ objs = object_state_keys(feats)
371
+ print(f" object/sim-state keys present but NOT read by the model: {objs}")
372
+ assert state_key not in objs, f"{state_key} is an object-state feature"
373
+ if ref_cams is None:
374
+ ref_cams = cams
375
+ assert cams == ref_cams, f"camera mismatch vs first dataset: {cams} != {ref_cams}"
376
+
377
+ ds = MultiViewWindows(spec, cams, state_key, state_dim, image_size, history=history, stride=stride)
378
+ item = ds[0]
379
+ print(f" frame offsets (history..current, target): {ds.offsets}")
380
+ print(f" valid windows: {len(ds)} / {info['total_frames']} frames")
381
+ for c, ctx, fut in zip(cams, item["context"].transpose(0, 1), item["future"]):
382
+ print(f" {c}: context {tuple(ctx.shape)} future {tuple(fut.shape)}")
383
+ print(f" action {tuple(item['action'].shape)} state {tuple(item['state'].shape)}")
384
+
385
+ i0 = int(ds.indices[0])
386
+ raw = ds.ds[i0]
387
+ fi = int(raw["frame_index"])
388
+ nxt = ds.ds.hf_dataset[i0 + horizon]
389
+ print(f" frame_index {fi} -> {int(nxt['frame_index'])} (must differ by exactly {horizon})")
390
+ assert int(nxt["frame_index"]) - fi == horizon
391
+ assert int(nxt["episode_index"]) == int(raw["episode_index"])
392
+ for c in cams:
393
+ assert not raw[f"{c}_is_pad"].any(), f"{c} padded at a supposedly valid window"
394
+ assert not raw["action_is_pad"].any()
395
+ print(f"\nOK: {len(specs)} dataset(s), cameras={ref_cams}")
396
+
397
+
398
+ # ── training ──────────────────────────────────────────────────────────────────
399
+
400
+
401
+ def main():
402
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
403
+ ap.add_argument(
404
+ "--data", nargs="+", required=True, help="local dataset roots or HF repo ids (success, failure, ...)"
405
+ )
406
+ ap.add_argument("--inspect", action="store_true", help="run dataset safety checks and exit")
407
+ ap.add_argument("--sd3", default="stabilityai/stable-diffusion-3.5-medium")
408
+ ap.add_argument("--state-key", default="observation.state")
409
+ ap.add_argument("--state-dim", type=int, default=9, help="use the first N dims of --state-key (spec: 9)")
410
+ ap.add_argument("--image-size", type=int, nargs=2, default=(224, 224))
411
+ ap.add_argument("--history", type=int, default=1, help="context frames incl. current (1 = current only)")
412
+ ap.add_argument("--history-stride", type=int, default=4, help="frames between history frames")
413
+ ap.add_argument("--ctx-noise", type=float, default=0.0, help="noise on context latents (Ctrl-World)")
414
+ ap.add_argument("--aux-weight", type=float, default=0.0, help="auxiliary action-reconstruction loss")
415
+ ap.add_argument("--aux-layer", type=int, default=None, help="MMDiT block to read (default: middle)")
416
+ ap.add_argument("--action-dropout", type=float, default=0.2, help="action-drop rate the aux loss uses")
417
+ ap.add_argument("--batch-size", type=int, default=16)
418
+ ap.add_argument("--grad-accum", type=int, default=1)
419
+ ap.add_argument("--steps", type=int, default=20000)
420
+ ap.add_argument("--lr", type=float, default=1e-4)
421
+ ap.add_argument("--lora-rank", type=int, default=32, help="0 = full finetune of the MMDiT")
422
+ ap.add_argument("--log-every", type=int, default=50)
423
+ ap.add_argument(
424
+ "--no-grad-ckpt",
425
+ action="store_true",
426
+ help="28%% faster per sample but far more activation memory; needs a small batch",
427
+ )
428
+ ap.add_argument("--val-episodes", type=int, default=20, help="held-out episodes per dataset")
429
+ ap.add_argument("--val-every", type=int, default=1000)
430
+ ap.add_argument("--val-batches", type=int, default=8)
431
+ ap.add_argument("--log-images", type=int, default=4, help="examples logged to the W&B table")
432
+ ap.add_argument("--sample-steps", type=int, default=20)
433
+ ap.add_argument("--workers", type=int, default=8)
434
+ ap.add_argument("--out", default=str(Path(__file__).parent / "outputs"))
435
+ ap.add_argument("--wandb-project", default="sd3-dynamics", help="empty string disables W&B")
436
+ ap.add_argument("--run-name", default=None, help="W&B run name (default: auto-generated)")
437
+ args = ap.parse_args()
438
+
439
+ image_size = tuple(args.image_size)
440
+ if args.inspect:
441
+ inspect(args.data, args.state_key, args.state_dim, image_size, args.history, args.history_stride)
442
+ return
443
+
444
+ torch.manual_seed(0)
445
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
446
+ train_ds, val_ds, cameras = build_datasets(
447
+ args.data, image_size, args.state_key, args.state_dim, args.val_episodes,
448
+ args.history, args.history_stride,
449
+ )
450
+ feats = dataset_features(args.data[0])
451
+ state_dim = args.state_dim
452
+ full = feats[args.state_key]["shape"][0]
453
+ if full < state_dim:
454
+ raise SystemExit(f"{args.state_key} is only {full}D, cannot take the first {state_dim}D")
455
+ assert args.state_key not in object_state_keys(feats), "object state must not reach the model"
456
+ action_dim = feats["action"]["shape"][0]
457
+ print(f"cameras={cameras} action_dim={action_dim} state={args.state_key}[:{state_dim}] of {full}D")
458
+ print(f"excluded from the model: {object_state_keys(feats)}")
459
+ print(f"canvas: {args.history + 1} rows x {len(cameras)} views, history stride {args.history_stride}")
460
+ print(f"train windows={len(train_ds)} val windows={len(val_ds) if val_ds else 0}")
461
+
462
+ model = SD3MultiViewDynamics.from_pretrained(
463
+ args.sd3,
464
+ len(cameras),
465
+ action_dim,
466
+ state_dim,
467
+ ctx_noise=args.ctx_noise,
468
+ aux_weight=args.aux_weight,
469
+ aux_layer=args.aux_layer,
470
+ action_dropout=args.action_dropout,
471
+ ).to(dev)
472
+ if not args.no_grad_ckpt:
473
+ model.transformer.enable_gradient_checkpointing()
474
+ if args.lora_rank:
475
+ from diffusers.training_utils import cast_training_params
476
+ from peft import LoraConfig
477
+
478
+ model.transformer.requires_grad_(False)
479
+ model.transformer.add_adapter(
480
+ LoraConfig(
481
+ r=args.lora_rank,
482
+ lora_alpha=args.lora_rank,
483
+ init_lora_weights="gaussian",
484
+ target_modules=["to_q", "to_k", "to_v", "to_out.0"],
485
+ )
486
+ )
487
+ cast_training_params(model.transformer, dtype=torch.float32) # bf16 Adam states diverge
488
+ for m in (model.act_tok, model.state_tok, model.pool):
489
+ m.to(torch.float32)
490
+ if args.aux_weight:
491
+ model.aux_head.to(torch.float32)
492
+ print(f"aux action head on block {model.aux_layer}/{len(model.transformer.transformer_blocks)}")
493
+ trainable = {n for n, p in model.named_parameters() if p.requires_grad}
494
+ params = [p for p in model.parameters() if p.requires_grad]
495
+ print(f"trainable params: {sum(p.numel() for p in params) / 1e6:.1f}M")
496
+ opt = torch.optim.AdamW(params, lr=args.lr)
497
+ scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(args.sd3, subfolder="scheduler")
498
+
499
+ lpips = None
500
+ if val_ds is not None:
501
+ from torchmetrics.image.lpip import LearnedPerceptualImagePatchSimilarity
502
+
503
+ lpips = LearnedPerceptualImagePatchSimilarity(net_type="alex", normalize=True).to(dev)
504
+
505
+ run = None
506
+ if args.wandb_project:
507
+ import wandb
508
+
509
+ run = wandb.init(
510
+ project=args.wandb_project, name=args.run_name, config=vars(args) | {"cameras": cameras}
511
+ )
512
+
513
+ def loader(ds, bs, shuffle):
514
+ return torch.utils.data.DataLoader(
515
+ ds, batch_size=bs, shuffle=shuffle, num_workers=args.workers, drop_last=True, pin_memory=True
516
+ )
517
+
518
+ def to_dev(batch):
519
+ return {k: v.to(dev, non_blocking=True) for k, v in batch.items()}
520
+
521
+ def log(d, step):
522
+ print(f"step {step}: " + " ".join(f"{k}={v:.4f}" for k, v in d.items() if isinstance(v, float)))
523
+ if run:
524
+ run.log(d, step=step)
525
+
526
+ @torch.no_grad()
527
+ def validate(step):
528
+ model.eval()
529
+ sums, n = {}, 0
530
+ for i, batch in enumerate(loader(val_ds, args.batch_size, False)):
531
+ if i >= args.val_batches:
532
+ break
533
+ batch = to_dev(batch)
534
+ pred = model.predict(batch, scheduler, args.sample_steps)
535
+ current = batch["context"][:, -1] # newest context frame
536
+ for k, v in per_camera_metrics(pred, batch["future"], cameras, lpips).items():
537
+ sums[k] = sums.get(k, 0.0) + v
538
+ # copy-the-current-frame baseline on the same batches: the number to beat
539
+ for k, v in per_camera_metrics(current, batch["future"], cameras, lpips).items():
540
+ sums[f"copy_{k}"] = sums.get(f"copy_{k}", 0.0) + v
541
+ n += 1
542
+ if i == 0 and run:
543
+ k = min(args.log_images, pred.shape[0])
544
+ run.log(
545
+ {
546
+ "val/reconstructions": reconstruction_table(
547
+ current[:k], batch["future"][:k], pred[:k], cameras
548
+ )
549
+ },
550
+ step=step,
551
+ )
552
+ model.train()
553
+ log({f"val/{k}": v / max(n, 1) for k, v in sums.items()}, step)
554
+
555
+ out = Path(args.out)
556
+ out.mkdir(parents=True, exist_ok=True)
557
+ step, running, t0 = 0, 0.0, time.time()
558
+ model.train()
559
+ while step < args.steps:
560
+ for batch in loader(train_ds, args.batch_size, True):
561
+ loss = model.loss(to_dev(batch)) / args.grad_accum
562
+ loss.backward()
563
+ running += loss.item() * args.grad_accum # undo the accumulation scaling for reporting
564
+ if (step + 1) % args.grad_accum == 0:
565
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
566
+ opt.step()
567
+ opt.zero_grad(set_to_none=True)
568
+ step += 1
569
+ if step % args.log_every == 0:
570
+ peak = torch.cuda.max_memory_allocated() / 1e9 if dev == "cuda" else 0.0
571
+ dt = (time.time() - t0) / args.log_every
572
+ log(
573
+ {
574
+ "train/loss": running / args.log_every,
575
+ "train/peak_gb": peak,
576
+ "train/s_per_step": dt,
577
+ "train/samples_per_s": args.batch_size / dt,
578
+ },
579
+ step,
580
+ )
581
+ running, t0 = 0.0, time.time()
582
+ if val_ds is not None and step % args.val_every == 0:
583
+ validate(step)
584
+ if step % 5000 == 0 or step == args.steps:
585
+ # trainable tensors only: ~93MB of LoRA + conditioning heads, not 5GB of frozen MMDiT
586
+ torch.save(
587
+ {k: v for k, v in model.state_dict().items() if k in trainable},
588
+ out / f"step_{step}.pt",
589
+ )
590
+ if step >= args.steps:
591
+ break
592
+
593
+
594
+ if __name__ == "__main__":
595
+ main()
test_dino_dynamics.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Self-check for dino_dynamics with a tiny stand-in DINO/VAE. No downloads.
3
+
4
+ policy_learning/lerobot/.venv/bin/python dynamics_model/test_dino_dynamics.py
5
+ """
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ from diffusers import AutoencoderKL
10
+ from transformers import Dinov2Config, Dinov2Model
11
+
12
+ from dino_dynamics import DinoDynamics
13
+ from sd3_dynamics import HORIZON
14
+
15
+ V, B, SZ, DSZ, ACT, ST, HIST = 2, 2, 112, 196, 7, 9, 2 # DSZ/14=14 patches -> x2 = 28 = SZ/4
16
+
17
+
18
+ def tiny():
19
+ dino = Dinov2Model(
20
+ Dinov2Config(hidden_size=32, num_hidden_layers=1, num_attention_heads=2,
21
+ intermediate_size=64, patch_size=14, image_size=DSZ)
22
+ )
23
+ vae = AutoencoderKL(
24
+ in_channels=3, out_channels=3,
25
+ down_block_types=("DownEncoderBlock2D", "DownEncoderBlock2D", "DownEncoderBlock2D"),
26
+ up_block_types=("UpDecoderBlock2D", "UpDecoderBlock2D", "UpDecoderBlock2D"),
27
+ block_out_channels=(8, 8, 8), layers_per_block=1, latent_channels=16, norm_num_groups=8,
28
+ )
29
+ vae.register_to_config(shift_factor=0.0609, scaling_factor=1.5305)
30
+ return DinoDynamics(dino, vae, V, ACT, ST, history=HIST, depth=1, heads=2, mlp_dim=64, dino_size=DSZ, image_size=SZ)
31
+
32
+
33
+ def batch():
34
+ return {
35
+ "context": torch.rand(B, HIST, V, 3, SZ, SZ),
36
+ "future": torch.rand(B, V, 3, SZ, SZ),
37
+ "action": torch.randn(B, HORIZON, ACT),
38
+ "state": torch.randn(B, ST),
39
+ }
40
+
41
+
42
+ def main():
43
+ m = tiny()
44
+ b = batch()
45
+ P = m.n_patches
46
+ assert P == (DSZ // 14) ** 2 == 196, P
47
+
48
+ z = m.encode_views(b["context"])
49
+ assert z.shape == (B, HIST, V, P, m.dim), z.shape
50
+
51
+ pred = m(b)
52
+ assert pred.shape == (B, V, P, m.dim), pred.shape
53
+
54
+ # the encoder is frozen: no gradients, and its weights carry none after backward
55
+ loss, tgt = m.loss(b)
56
+ dec = m.decoder_loss(b, tgt)
57
+ (loss + dec).backward()
58
+ assert all(p.grad is None for p in m.dino.parameters()), "DINOv2 must stay frozen"
59
+ assert all(p.grad is None for p in m.vae.parameters()), "VAE must stay frozen"
60
+ assert m.query.grad is not None and m.act_tok.weight.grad is not None
61
+ # the decoder head must not backprop into the predictor (DINO-WM trains it on detached feats)
62
+ assert m.to_latent[0].weight.grad is not None
63
+
64
+ # conditioning actually matters: different actions -> different prediction
65
+ torch.manual_seed(0)
66
+ a = m(b)
67
+ torch.manual_seed(0)
68
+ c = m(dict(b, action=torch.randn_like(b["action"])))
69
+ assert not torch.allclose(a, c), "prediction ignores the action chunk"
70
+
71
+ # and so do the context frames
72
+ torch.manual_seed(0)
73
+ d = m(dict(b, context=torch.rand_like(b["context"])))
74
+ assert not torch.allclose(a, d), "prediction ignores the context frames"
75
+
76
+ img = m.features_to_image(pred)
77
+ assert img.shape == (B, V, 3, SZ, SZ), img.shape
78
+ assert (img >= 0).all() and (img <= 1).all()
79
+
80
+ print("ok", {"patches": P, "dim": m.dim, "pred": tuple(pred.shape), "img": tuple(img.shape)})
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()