DINO-WM multi-view dynamics, MimicGen coffee_d0, 60k steps
Browse files- README.md +142 -0
- bench_action_grad.py +102 -0
- dino_dynamics.py +340 -0
- dino_step_60000.pt +3 -0
- eval_common.py +94 -0
- sd3_dynamics.py +595 -0
- test_dino_dynamics.py +84 -0
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()
|