EgoHOI Layer-Injection Ablation β HOT3D + H2O (Wan 2.1 I2V-14B)
Six training runs that vary which Wan DiT blocks receive camera and object conditioning, on a frozen Wan 2.1 I2V-14B-720P base. Everything else β data, schedule, optimizer, seed β is held fixed, so the runs are directly comparable to each other.
Code: edisiondyli/egohoi_extension, branch obj_rope, commit 6ea62eb
("obj layer ablations", 2026-08-13). Every run directory carries a PROVENANCE.txt with the
exact branch and commit it was launched from.
This is a separate suite from the earlier
egohoi-control-hot3d-h2o repo,
which trained at commit 563354c for 13000 steps to ask a different question (does conditioning
help at all, vs. a frozen-Wan baseline). The two repos' numbers are not comparable: this
suite runs 15000 steps, on refreshed captions, and evaluates on a held-out test split rather
than the training-set validation grid.
The six runs
hand_encoder is disabled in all six. Wan has 40 self-attention blocks; "shallow" means blocks
0β19, "deep" means 20β39.
| run | camera blocks | object blocks | val loss | train cum-avg |
|---|---|---|---|---|
train_cam_all_obj_all_layers |
0β39 (all) | 0β39 (all) | 0.04656 | 0.12229 |
train_cam_0_19_obj_0_19_layers |
0β19 shallow | 0β19 shallow | 0.04774 | 0.12342 |
train_cam_0_19_obj_20_39_layers |
0β19 shallow | 20β39 deep | 0.04782 | 0.12326 |
train_camera |
0β19 shallow | β (disabled) | 0.04890 | 0.12350 |
train_cam_20_39_obj_20_39_layers |
20β39 deep | 20β39 deep | 0.05473 | 0.12616 |
train_obj |
β (disabled) | 20β39 deep | 0.07448 | 0.13318 |
Validation uses fixed timesteps [357, 682, 833, 921, 978] with deterministic noise over 106
samples, so these are directly comparable.
What the losses say
- Camera conditioning carries the result.
train_obj(object-only) is far worse than everything else; the gap to camera-only is much larger than any gap among the camera-enabled runs. - Shallow beats deep for camera. Holding the object branch at 20β39, moving camera from 0β19 to 20β39 is the largest effect of any placement change.
- Where the object branch goes barely matters. Shallow vs deep differ by well under a thousandth β indistinguishable.
- All-layers wins, but it is not a fair comparison β see the caveat below.
Caveat on
cam_all_obj_all. Injecting camera residuals into 40 blocks requirescamera_encoder.num_layers=40, because the trainer refuses to inject into more Wan blocks than the ControlNet has residual blocks. That makes this run's camera branch twice the size of every other run's (25.1 GB vs 12.6 GB checkpoints). Its win therefore confounds placement with capacity, and cannot be read as "all-layer injection is better" on its own.
Results β PSNR / SSIM on the held-out test split
Generation is 81 frames at 40 sampling steps, unipc, guide_scale 5.0, seed 23, compared to
ground truth over the same interval at 480Γ480. Each HOT3D clip is generated at two start frames
(0-80 and 69-149), so a full run is ~192 HOT3D videos plus ~45 H2O videos.
All six runs are scored.
| run | camera / object blocks | PSNR (dB) | SSIM | n |
|---|---|---|---|---|
train_cam_all_obj_all_layers |
0β39 / 0β39 | 21.124 | 0.6779 | 235 |
train_camera |
0β19 / β | 20.888 | 0.6654 | 237 |
train_cam_0_19_obj_20_39_layers |
0β19 / 20β39 | 20.779 | 0.6651 | 235 |
train_cam_0_19_obj_0_19_layers |
0β19 / 0β19 | 20.755 | 0.6637 | 235 |
train_cam_20_39_obj_20_39_layers |
20β39 / 20β39 | 19.890 | 0.6434 | 235 |
train_obj |
β / 20β39 | 13.796 | 0.4010 | 238 |
The object-only run collapses β roughly seven dB below anything with camera conditioning. Camera placement is worth about 1 dB, shallow over deep, the same direction the validation losses point. Note that adding the object branch does not improve pixel metrics: camera-only sits at the top of this table even though the object branch did lower validation loss. The two metrics disagree, and PSNR/SSIM is the one measured against held-out ground truth.
Per-video numbers are in metrics/<run>_per_video.csv; metrics/psnr_ssim_summary.json carries
the overall and per-dataset aggregates.
Repository layout
<run>/ # the six training runs, named as above
checkpoints/final.pt # step 15000; full training checkpoint
train.log # per-step loss + every validation block
resolved_config.yaml # the fully resolved config actually used
submit.sbatch # the exact job script
PROVENANCE.txt # branch + commit + submission time
slurm-*.out # scheduler logs, one per leg
tensorboard/ # training curves
wandb/ # offline W&B run
infer_<run>/
hot3d/<clip>/<clip>_<span>_*.mp4 # generated videos
h2o/<clip>/<clip>_<span>_*.mp4
inference_summaries.jsonl # GT β generated pairing, caption, seed, checkpoint
slurm-*.out, submit.sbatch
metrics/
psnr_ssim_summary.json # overall + per-dataset aggregates
<run>_per_video.csv # per-video PSNR/SSIM
eval_psnr_ssim.py # the driver that produced them
Compare all six training curves at once:
tensorboard --logdir .
On the checkpoints
Each run's checkpoints/final.pt is the step-15000 full training checkpoint (model +
optimizer state), not merged weights. The trainer also keeps the last three rotating step_*.pt
snapshots on disk; those are resume points only β step_00015000.pt holds the same training
state as final.pt β and are not mirrored here.
train_obj's checkpoint is much smaller (0.63 GB) than the rest because it carries only the
Stand-In object LoRA, with no camera ControlNet branch. cam_all_obj_all's is twice the usual
size for the reason given in the caveat above.
Training setup
Identical across all six runs except the injection layers and the enable flags.
| Base model | Wan 2.1 I2V-14B-720P (frozen) |
| Steps | 15000 |
| Optimizer | AdamW, lr 1e-5 decayed to 0, weight_decay 0 |
| Precision | bf16 + gradient checkpointing |
| Datasets | HOT3D + H2O interleaved β 19 HOT3D steps : 1 H2O step |
| Batch size | 1 per GPU, 8 GPUs |
| Video | 81 frames @ 480Γ480 |
| View renders | hand_view_render for both datasets |
| Validation | every 500 steps, 106 samples, fixed timesteps |
| Checkpointing | every 1000 steps, last 3 kept |
| obj rope | tss-rope (temporal β1, shifted spatial grid) |
| Object stages | A 1500 / B 1500 / C remainder |
| Seed | 23 |
| Hardware | 8Γ NVIDIA H200, 2 CPUs per GPU |
Conditioning modules
| module | configuration |
|---|---|
camera_encoder |
ControlNet-style residuals; PlΓΌcker rays (256 hidden, 6 ch) + masks; control_dim 1024, 20 residual blocks (40 for cam_all), 16 heads, ffn 4096, rms_norm |
obj_encoder |
Stand-In image-branch LoRA r128 / Ξ±128 on self_attn.{q,k,v}, zero init |
hand_encoder |
disabled in every run |
Data
Preprocessed HOT3D + H2O from EndeavourDD/Jichen.
- Train:
outputs/hot3d_preprocessed(HOT3D clips outside the test grid) + H2Osubject1_ego,subject2_ego - Test:
outputs/hot3d_preprocessed_testβ 96 clips, exactly the config'sval_clipsgrid, packaged separately. H2O test issubject3_ego. - Captions: refreshed
hot3d_qwen_captions.json(4548 entries; 207 differ from the previous revision, same key set) +h2o_action_label_captions.json
Reproducing this
Two things in the upstream README do not work as written:
- The "camera all, obj all" command crashes. It sets 40 camera injection layers but leaves
camera_encoder.num_layersat its default of 20, and the trainer raisesValueError: Cannot inject more Wan layers than CameraEncoder residual blocks. Add--set camera_encoder.num_layers=40. This is what doubles that run's camera branch. - The inference commands silently use the training set. Section 4 is titled "use
hot3d_preprocessed_test", but the commands omit
--hot3d-root, which defaults tooutputs/hot3d_preprocessed. Pass--hot3d-root outputs/hot3d_preprocessed_testto actually evaluate on the held-out split.
Further environment notes:
- H2O
object_render_metadata.jsonstores absolute/home/jc/...paths. Onlyframes[].image_pathis read by the training and inference loaders, so that one field must be rewritten tooutputs/h2o_preprocessed/...;mask_path,sequence_dirandobject_rootare read only byscripts/preprocess/objects/render_canonical_views.pyand can be left alone. CUDA_HOMEmust point at a real toolkit (module load CUDA/12.6.0), ortransformersβdeepspeedraisesMissingCUDAExceptionat startup.scripts/eval.pyis empty (0 bytes). The working metric tool isscripts/compute_video_psnr_ssim.py, which handles one pair at a time.
Run notes
- 15000 steps exceeds the 48 h scheduler limit, so each run was submitted as chained legs with
auto-resume from the newest step checkpoint; the
slurm-*.outfiles cover the separate legs. train_cam_0_19_obj_20_39_layerslost a leg to a node fault on dgx073 (CUDA reportednum_gpus=empty, NVML unavailable) and was resubmitted from its step-13000 checkpoint. Its inference was then run as a six-way clip shard to catch up.