v13 diagnostic — why 116 mm is unacceptable, and what v13 does about it

Date: 2026-07-10 · Trigger: user pushed back on v12 champion (116 mm) — "hand tracking is too bad, this isn't even generated video, overfit was fine, why does scale collapse?" Verdict: it is bad, and it's not a capacity problem. Four concrete failures in the v10/v11/v12 training recipe. v13 fixes all four.


The overfit vs. scale gap

The Iter 208 overfit proved the architecture, losses, and data pipeline all work: 2 clips, trunk fully frozen, 500 steps → loss_hand 20.56 → 0.31 (−98.5 %), loss_pix 0.128 → 0.00035 (~11 px, at Iter 206 σ=10 target). Every head converged.

At scale (v10/v11/v12 on 33 k clips), the training loss on hand does not decrease:

stage trunk state loss_hand start loss_hand end val_hand trajectory
overfit v10 fully frozen 20.56 0.31 (n/a, 2 clips)
v12_gtmask last-4 blocks + patch_embed unfrozen 34.9 25–42 (oscillates) 32 → 40 (up!)

The eval MPJPE (116 mm) is a combination of untrained hand head + still-improving pixel + s-scale + partly-broken depth. It's not a proof that the model learned; it's a proof the peripherals kept working.


Four concrete failures in v10–v12

1. Axis-angle MSE causes mean-collapse at scale

Current loss:

loss_hand = masked_mse(hand_pred, hand_gt, hv_l)
# hand_pred, hand_gt: (B, T, H, 61) axis-angle MANO

The 61-d MANO layout is [orient(3) + body(45) + betas(10) + trans(3)]. The 3-d orient and 45-d body are axis-angle (rodrigues) — the angle magnitude is ‖v‖ and the axis is v/‖v‖. This has:

At scale (33 k varied poses), the collapse pins loss_hand at 25–42. On overfit (2 clips, near-identical poses), collapse doesn't happen because the target is essentially a single point.

Fix: convert both pred and GT orient + body axis-angle to rotation matrices via Rodrigues formula (differentiable everywhere), then use Frobenius MSE ‖R_pred − R_gt‖²_F. This is what the MANO paper recommends and what most modern hand-pose regressors use. See train_egowm_v13.py matrix_hand_loss.

2. Trunk drift outpaces heads

Current recipe unfreezes patch_embed + last-4 DiT blocks at LR 5e-6 for 3000 steps. Heads are trained at LR 1e-4 (20 × higher). Effect: the DiT features slowly shift each step, so heads are chasing a moving target. Overfit worked because the trunk was fully frozen — heads had a static feature to fit.

Symptoms: - val_pix NaN at 4 of the last 6 eval steps (soft-argmax numerical instability when features drift into unusual regions) - val_hand increasing in the last third of training (32 → 40) - Overfit-scale gap 20× on loss_hand

Fix: freeze the entire trunk. Only heads train. LR 3e-4 on heads only. Bonus: forward-only trunk in no_grad() context → 2× training speedup (smoke test: 0.8 ips vs. v12's 0.42 ips), so 3000 steps takes ~1 h instead of ~4 h.

3. HO3Dv3 pollutes both train and val

Three independent diagnostics now converge on the same conclusion:

diagnostic ho3dv3 signal
Iter 204 oracle-orient oracle made ho3dv3 worse (245 → 250 mm) — GT orient is noisier than model prediction
Iter 206 oracle-lift oracle 2D-lift made ho3dv3 worse — GT joints are noisier than model prediction
v12 per-source (today) v11 → v12 arctic −27 %, h2o −2 %, ho3dv3 +5 % — improvements on ARCTIC-side signal actively regress on ho3dv3

Training on ho3dv3 pulls the model toward noise. Val'ing on ho3dv3 makes the eval metric penalize good predictions. The evidence is overwhelming.

Fix: drop ho3dv3 from both train_idx and val_idx. Effect: train 29 770 → 28 948 clips (−2.8 %), val 3307 → 3219 (−2.6 %). Small pool loss, big signal gain.

4. NaN val batches poison the running mean

v12 val log shows val_pix=NaN and val_s=NaN at steps 1750, 2000, 2500. The current eval aggregation is np.mean([d[k] for d in vp]), which propagates NaN into the reported mean. Any downstream analysis that compares val losses across steps then sees garbage for those steps.

Fix: skip batches where any part is non-finite. Report n_valid/16 so we can see if this is happening.


What v13 does concretely

Diff from v10-scale to v13:

Aspect v10/v11/v12 v13
trunk grad last-4 blocks + patch_embed all frozen
trunk forward with_grad no_grad (2× speedup)
hand loss masked_mse on 61-d axis-angle matrix_hand_loss: Frobenius on R(orient) + R(body) + MSE(betas) + MSE(trans), orient weighted 2×
train_idx all 7 sources drop ho3dv3
val_idx all 7 sources drop ho3dv3
val agg mean of everything skip non-finite batches, report n_valid/16
warm-start v9b step 200 v12 step 3000 (current champion)
LR heads 1e-4 3e-4 (compensating for no trunk update)
LR trunk 5e-6 0

Training cost: ~1 h @ 0.8 ips (measured on smoke) vs. v12's ~4 h @ 0.42 ips.


Expected outcome

We already know the ceilings:

Current: 116 mm. Gap to Iter 206 oracle: 62 mm.

v13 targets the two biggest levers directly: - Rotation-matrix loss fixes the ~48 mm orientation contribution quantified in Iter 204. Not all 48 mm — mean-collapse recovery gives some fraction, plausibly 15–30 mm. - Trunk freeze + no-drift eliminates the val-instability that was costing 5–10 mm at the end of v12 training.

Rough forecast: 60–80 mm abs MPJPE on ARCTIC-lite. If we hit that, we're within 2× of the Iter 206 oracle and the remaining gap is depth ambiguity + fingertip lift (which is what Iter 206 lift's per-joint analysis flagged as the residual bottleneck: fingertips have irreducible ~60–80 mm depth ambiguity from a single view).

If v13 lands at 60–80 mm, the next step is v14 with SomantisModel's lift representation (predict 2D + per-clip scalar → PnP → wrist) for the 54 mm oracle floor. If v13 lands at 90+ mm, we know rotation representation was not the dominant issue and depth ambiguity is the true floor.


What "generation" would look like — noted, deferred

The user is correct that our demo videos are RGB-input + prediction overlay, not generated video. Wan 2.1 is currently used as a cached-latent feature encoder for tracking heads. The generative capability (prompt → video) is neither exercised nor demonstrated.

If we go to a true grounded-generation demo (option C): - Sample noise → run Wan 2.1 diffusion → decode → new video - Each generated frame has a hand pose in it (either injected via conditioning or predicted post-hoc) - The heads become a conditioning source, not a prediction target: "generate a video where the hand does X at frame Y"

That's a fundamentally different pipeline. It's on the backlog but option A (fix tracking) is what user picked. If v13 gets us to 60-80 mm, generation-style demos become much more sellable because the underlying model actually knows where the hand is.


Reproducibility

# v13 training (~1 h on H200)
/home/ubuntu/miniforge3/envs/ml/bin/python data_engine/train_egowm_v13.py \
    --steps 3000 --val-every 250 --save-every 500 \
    --out _ckpt/lane_b/v13_stable

# eval same protocol as v12 for fair comparison
/home/ubuntu/miniforge3/envs/ml/bin/python data_engine/eval_v10_scale.py \
    --ckpt _ckpt/lane_b/v13_stable/ckpt_step003000.pt \
    --n-clips 40 --source-filter arctic \
    --out benchmark/results/v13_stable_step3000.arctic-lite.json

Meta