# Retrieval-Uncertainty Loss & Memory-Retrieval Improvements This document gives (1) a concrete, codebase-accurate implementation of the **retrieval-uncertainty loss** you described, and (2) a set of additional methods to improve the model's ability to retrieve preceding (context) frames. > **Loss you asked for** > `L_unc = e^(-um) · sg(MSE(VAE(Target View), predicted_x0)) + um` > where `um` is the model's predicted (log-)uncertainty about the current > prediction and `sg(·)` is stop-gradient. > > This is the **Kendall–Gal heteroscedastic-uncertainty** objective. With the > MSE stop-gradiented, the uncertainty head learns a *per-token retrieval > confidence* (low `um` where the model reconstructs the target well, high `um` > where it fails / "forgets"). That confidence map is then reused to **reweight > the main denoising loss**, focusing capacity on the tokens the memory pathway > currently fails to retrieve. Read `CLAUDE.md` and `attention.md` first for the two-chunk paradigm and the public-repo constraints. --- ## 0. Where the loss lives in this codebase The training loss is **flow-matching MSE**, computed in `diffsynth/pipelines/wan_video_new.py → WanVideoPipeline.training_loss(...)`. The relevant facts (verified against the code): - The scheduler is `FlowMatchScheduler` (`diffsynth/schedulers/flow_match.py`): - `add_noise`: `x_t = (1 - σ)·x0 + σ·noise` - `training_target`: `v = noise − x0` ← **the model predicts velocity**, not x0 - In `training_mode == "context"` (the default for memory baselines), the target tokens are `input_latents` (already VAE-encoded — this **is** `VAE(Target View)` in latent space), and the loss is: ```python noisy_target_latents = self.scheduler.add_noise(target_latents, target_noise, timestep) # x_t training_target = self.scheduler.training_target(target_latents, target_noise, timestep) # v = noise - x0 ... noise_pred = self.model_fn(**inputs, timestep=timestep) # v_pred over [target | context] tokens target_noise_pred = noise_pred[:, :, :target_latents.shape[2], :, :] # suffix layout: target first loss = F.mse_loss(target_noise_pred.float(), training_target.float()) loss = loss * self.scheduler.training_weight(timestep) ``` **Recovering `predicted_x0`** (needed for your loss) is exact under flow matching — no extra forward pass: ``` x0_pred = x_t − σ · v_pred = noisy_target_latents − σ · target_noise_pred ``` So `MSE(VAE(Target View), predicted_x0) = MSE(target_latents, x0_pred)`, computed entirely in latent space — no VAE decode is needed during training. > `σ` for the sampled `timestep` is `self.scheduler.sigmas[timestep_id]` where > `timestep_id = argmin(|scheduler.timesteps − timestep|)`. There are two > equivalent target-token layouts (`context_position == "suffix"` vs `prefix`); > the slice that selects target tokens is already computed as > `target_noise_pred` — reuse it. --- ## 1. The module (already added) `diffsynth/models/memory/uncertainty.py` (exported from `diffsynth/models/memory/__init__.py`). Key pieces: - `UncertaintyHead(in_channels)` — a 1×1×1 Conv3d MLP mapping the per-token x0 prediction `(B, C, T, H, W)` → log-uncertainty `um` `(B, 1, T, H, W)`. The final conv is **zero-initialised**, so `um ≡ 0` at init ⇒ `exp(−um) ≡ 1` (a no-op weighting) ⇒ the existing training dynamics are unchanged on step 0. - `recover_x0_flow_match(noisy_target, v_pred, sigma)` — `x_t − σ·v`. - `per_token_mse_map(x0_pred, x0_target)` — channel-mean MSE → `(B,1,T,H,W)`. - `retrieval_uncertainty_loss(um, mse_map, detach_mse=True)` — returns `mean(exp(−um)·sg(MSE) + um)`. Smoke-tested: `um` is `(B,1,T,H,W)`, `==0` at init, loss is finite, gradients flow into the head. --- ## 2. Wiring it into training Five small edits. All are additive and gated by a new flag so default behaviour is untouched. ### 2a. `diffsynth/pipelines/wan_video_new.py` — `WanVideoPipeline.__init__` Create the head lazily (the latent channel count `C` is known from the DiT `in_dim`, typically 16 for Wan 2.1). Add near where other memory attributes are set: ```python self.use_retrieval_uncertainty = False self.retrieval_uncertainty_weight = 1.0 self.uncertainty_head = None # nn.Module, built on first use ``` ### 2b. `WanVideoPipeline.training_loss` — compute the extra loss In **both** the `"context"` and `"predict"` branches, right after the existing `loss = ... * training_weight(timestep)`, insert: ```python if getattr(self, "use_retrieval_uncertainty", False): from diffsynth.models.memory.uncertainty import ( UncertaintyHead, recover_x0_flow_match, per_token_mse_map, retrieval_uncertainty_loss, ) # σ for this timestep (flow-match scheduler). sched = self.scheduler timestep_id = torch.argmin( (sched.timesteps - timestep.to(sched.timesteps.device)).abs()) sigma = sched.sigmas[timestep_id].to(target_noise_pred.dtype) # x_t and v_pred for the TARGET tokens only (reuse the existing slice). x_t_target = noisy_target_latents # (B,C,T,H,W) x0_pred = recover_x0_flow_match(x_t_target, target_noise_pred, sigma) x0_target = target_latents # = VAE(Target View) # Lazily build the head with the right channel count, on the right device. if self.uncertainty_head is None: self.uncertainty_head = UncertaintyHead(x0_pred.shape[1]).to( device=x0_pred.device, dtype=torch.float32) um = self.uncertainty_head(x0_pred) # (B,1,T,H,W) mse_map = per_token_mse_map(x0_pred, x0_target) loss_unc = retrieval_uncertainty_loss(um, mse_map, detach_mse=True) # (Optional, Method A) reweight the MAIN denoising loss by confidence so the # denoiser/memory pathway focuses on hard-to-retrieve tokens. Detach um here # so this term trains the denoiser, not the head. # per_token_main = (target_noise_pred.float() - training_target.float()).pow(2).mean(1, keepdim=True) # w = torch.exp(-um.detach()).clamp(0.1, 10.0) # loss = (w * per_token_main).mean() * self.scheduler.training_weight(timestep) loss = loss + self.retrieval_uncertainty_weight * loss_unc ``` > `target_latents`, `noisy_target_latents`, `target_noise_pred`, and `timestep` > all already exist as locals in that scope — no signature changes needed. ### 2c. `src/model_training/train.py` — argparse flags Add to the numeric-default tuples (near `--timestep_shift`): ```python ("--retrieval_uncertainty_weight", dict(type=float, default=1.0)), ``` and add `"--use_retrieval_uncertainty"` to the list of store-true flags (alongside `"--use_block_wise_ssm"`, ~line 1510). ### 2d. `src/model_training/train.py` — push flags onto the pipe In the trainer `__init__` (where `self.pipe.use_spatial_memory = ...` is set, ~line 984), add: ```python self.pipe.use_retrieval_uncertainty = bool(use_retrieval_uncertainty) self.pipe.retrieval_uncertainty_weight = float(retrieval_uncertainty_weight) ``` and thread the two values in from `_arg(...)` at the trainer construction call (~line 1671), mirroring `timestep_shift`. ### 2e. Make the head trainable **and saved** The optimizer collects `model.trainable_modules()` = all params with `requires_grad=True`, and `--save_full_model` exports the whole DiT state (otherwise only `requires_grad` params are exported via `export_trainable_state_dict`). The head lives on `self.pipe`, **not** inside `dit`, so: - Its params are created with `requires_grad=True` by default ✔ (so AdamW will pick them up **provided the optimizer is built after the head exists**). The head is built lazily on the first `training_loss` call, which is *after* `torch.optim.AdamW(model.trainable_modules(), ...)` (~line 1794). **Fix:** build the head eagerly so it is registered before the optimizer is created — add this right after the block-replacement section in `train.py`: ```python if _arg('use_retrieval_uncertainty', False): from diffsynth.models.memory.uncertainty import UncertaintyHead _c = int(getattr(model.pipe.dit, "in_dim", 16)) model.pipe.uncertainty_head = UncertaintyHead(_c).to( device=next(model.pipe.dit.parameters()).device, dtype=torch.float32) ``` - For checkpointing, the head is an attribute of `self.pipe`, which is a submodule of the training `model`, so `accelerator.get_state_dict(model)` includes `pipe.uncertainty_head.*` keys. With `--save_full_model` they are saved; the keys are ignored at inference (the head is not needed to generate). If you do **not** use `--save_full_model`, ensure the head params have `requires_grad=True` (they do) so `export_trainable_state_dict` keeps them. ### 2f. Launcher Copy an existing memory launcher (e.g. `train/memory_baselines_basic/run_spatial_memory_baseline.sh`) and add: ```bash --use_retrieval_uncertainty --retrieval_uncertainty_weight 1.0 \ ``` Keep every other hyperparameter identical to the baseline row you are comparing against — this is a controlled ablation; only the loss should change. ### 2g. Sanity check before a full run ```bash PYTHONPATH=. python3 tests/test_two_chunk_anchor_readout.py # plus a tiny head test (identity-at-init + grad flow), e.g.: PYTHONPATH=. python3 - <<'PY' import importlib.util, torch s=importlib.util.spec_from_file_location('u','diffsynth/models/memory/uncertainty.py') u=importlib.util.module_from_spec(s); s.loader.exec_module(u) h=u.UncertaintyHead(16); xt=torch.randn(1,16,21,44,80); v=torch.randn_like(xt) x0=u.recover_x0_flow_match(xt,v,torch.tensor(.7)); um=h(x0) assert float(um.abs().max())==0.0 # identity at init L=u.retrieval_uncertainty_loss(um,u.per_token_mse_map(x0,torch.randn_like(x0))) L.backward(); assert h.net[0].weight.grad is not None print("ok", float(L)) PY ``` --- ## 3. Why this helps retrieval (and how to read the signal) `um` becomes a learned, per-token map of **where the model fails to reconstruct the target from memory**. Two ways to exploit it: - **Diagnostic** — log `um` heatmaps to W&B alongside the two-chunk left/right-rotation monitor (`--sampling_atomic_left_right`). High-`um` regions on the revisit tail localise *what* the memory is dropping (object identity vs background vs camera geometry). - **Loss reweighting (Method A above)** — `exp(−um.detach())` upweights the main denoising loss on tokens the model is *confident and wrong* about, pushing the memory pathway to fix systematic retrieval failures rather than averaging error uniformly. --- ## 4. Additional methods to improve preceding-frame retrieval Ordered roughly by expected impact / effort. All are compatible with the two-chunk setup and the existing memory families. ### Method A — Confidence-reweighted denoising loss Already sketched in §2b. Uses `exp(−um.detach())` to focus the **denoiser** on hard-to-retrieve tokens. Cheap, synergises directly with the uncertainty head. ### Method B — Explicit retrieval-consistency (anchor) loss Add a term that directly penalises drift between the **first/anchor frame** and the **revisit tail** in latent space, since revisit consistency is exactly what the paper measures. After recovering `x0_pred` for the target tokens: ``` L_anchor = MSE( x0_pred[revisit_tail_tokens], context_latents[anchor_token] ) ``` restricted to samples where the trajectory returns near the start pose (the codebase already constructs loop-closure probes; reuse `env/loop_utils.py` / the `replay` context source to identify revisit tokens). This trains the memory pathway to *reproduce* stored content, not merely to denoise plausibly. Gate behind `--use_anchor_consistency_loss`. ### Method C — Contrastive memory read-out (InfoNCE) Make the memory read-out **discriminative**: the target token's retrieved memory feature should match its *own* context frame more than other frames'. Take per-frame pooled features from the context tokens (before they enter the DiT blocks) and the corresponding target query features, and add an InfoNCE loss pulling matched (target-frame ↔ source-frame) pairs together and pushing mismatched pairs apart. This sharpens *which* preceding frame is retrieved — particularly useful for the Spatial and Context-K families. Implement as a small head reading the block hidden state (same hook point as block-wise SSM in `DiTBlock_w_Action`, see `attention.md`). ### Method D — Harder/longer context sampling (curriculum) Retrieval is only as good as the supervision distribution. Levers already in the data path: - Increase `--context_memory_frames` (K) and/or widen the temporal gap between context and target so the model must retrieve *distant* history, not adjacent frames (`--context_source replay`, `--prev_chunk_frames`). - Curriculum: start with short gaps, anneal to longer gaps over training. - Mix revisit-style samples (leave-and-return) more heavily — the two-chunk `--sampling_atomic_left_right` probe shows what to oversample. Pure data/schedule change; no model edits. ### Method E — Memory dropout / robustness regularisation Randomly drop or noise a subset of context tokens during training (`--context_drop_prob`, `--context_noise_std` already exist). Forcing the model to retrieve from partial memory improves robustness and prevents trivial copy-through, which tends to help long-horizon revisit. Tune these existing flags rather than adding code. ### Method F — Cross-attention readout supervision for Spatial memory For the spatial family (`spatial_cross_attn_readout`), add an auxiliary loss that encourages the read-out attention map to concentrate on the spatially corresponding stored region (when camera RT gives a known correspondence). This is a targeted version of Method C for the spatial grid memory. ### Recommended first experiment 1. Implement §1–§2 (uncertainty head + loss), train one row vs its baseline. 2. Turn on **Method A** (confidence reweighting) — likely the largest gain per line of code. 3. Add **Method B** (anchor consistency) if revisit MSE is still the bottleneck. Evaluate all with the existing tiers: ```bash export CKPT=outputs//epoch-0.safetensors bash eval/v2/run_basic_replay_gt.sh bash eval/v2/run_static_consistency_loop_and_revisit.sh PHASE=stage1 OOD_DIR=assets/opendomain_revisit bash eval/v2/revisit_suite/run_one_click_revisit_eval.sh ``` Compare revisit-tail MSE / PSNR / LPIPS against the unmodified baseline row. --- ## 5. Pitfalls - **Predicting x0 vs velocity.** The model outputs **velocity** `v = noise − x0`. Do **not** feed `target_noise_pred` directly as `x0` — always recover via `x0 = x_t − σ·v` (`recover_x0_flow_match`). Getting this wrong silently inverts the uncertainty signal. - **Non-zero `um` at init.** Keep the head's final layer zero-initialised; a non-zero init multiplies the main loss by an arbitrary factor on step 0 and destabilises early training. - **Optimizer misses the head.** Build the head **before** `AdamW(model.trainable_modules())` is constructed (see §2e), or its params won't be optimised. - **Stop-gradient.** With `detach_mse=True` the uncertainty term trains only the head. If you want it to also shape the denoiser, use Method A's `exp(−um.detach())` reweighting of the main loss — don't simply drop the stop-gradient on the MSE (that lets the model lower the loss by inflating `um`, i.e. "predict badly on purpose"). - **dtype/autocast.** Compute the head and the loss in fp32 (the module already casts), and the `um` clamp keeps `exp(−um)` finite under bf16 autocast. - **Public-repo constraints** (`CLAUDE.md`): no machine-local paths, minimal diffs, don't commit `outputs/` or weights. --- ## 6. Where the retrieval target comes from (what "correct retrieval" means) A confidence map is only meaningful relative to a **target** that defines correct retrieval. There is no single target — there is a hierarchy of increasingly strict definitions, and which one you pick decides what your confidence map actually measures. This codebase already computes the geometric ones. ### Background: what is RT? **RT = Rotation + Translation = the camera extrinsics** (the rigid-body pose of the camera). In this repo an RT is a **12-dim row-major vector** `[t_x, t_y, t_z, R_11, R_12, R_13, R_21, R_22, R_23, R_31, R_32, R_33]` — a 3×1 translation `t` followed by a flattened 3×3 rotation `R` (`src/model_training/rt_utils.py` docstring). The `MLP_CamPose(pose_dim=12)` inside `DiTBlock_w_Action` consumes exactly this 12-vector per latent frame. The **relative RT** between a context frame *i* and the reference (target) frame maps points from one camera frame into the other — this is what lets you *reproject* context content into the target view. It is computed by `rt_utils.convert_rt_to_relative(rt_list_all, ref_rt)`: ``` R_rel = R_ref⁻¹ · R_i , t_rel = R_ref⁻¹ · t_i + (−R_ref⁻¹ · t_ref) ``` (`R_ref⁻¹ = R_refᵀ` since rotations are orthonormal). Camera poses come from the per-frame JSONs via `pose_to_rt(pose)` (paper default: XY translation + Z-axis yaw only). Enabled in training by `--use_rt_relative` (env `USE_RT_RELATIVE`). ### Level 0 — Reconstruction target (what the base loss already uses) Weakest definition: *correct retrieval = the token was denoised well.* Target is `x0_target = target_latents = VAE(Target View)`; supervision is the per-token MSE already computed in `training_loss`. **Limitation:** it conflates two failure modes — (a) the memory pathway *failed to retrieve* the right context, vs. (b) the content is *genuinely novel / newly revealed* and no memory could help. For a *retrieval* confidence map you want to isolate (a), so Level 0 alone is the wrong target. ### Level 1 — Geometric co-visibility (already in the repo) The retrieval target here is **not learned** — it is a precomputed geometric label answering *"which past frames actually share field-of-view with the current frame?"*: - **`overlap_labels/{video_name}/{frame_idx}.json`** → `{"overlapping_frames": ["2796", "2797", ...]}`. For each frame, the list of historical frames that co-observe the same scene. **This is the ground-truth retrieval target at frame granularity.** Loaded by `fov_retrieval.load_overlap_frames()` and consumed by `fov_training_integration.retrieve_fov_context_frames()` to *select* the context frames during training. - **`fov_retrieval.compute_fov_overlap_3d(pose1, pose2, fov=52.67°)`** computes a continuous overlap score in [0,1] from the 6-DoF poses (mutual visibility + forward-direction similarity). This is the function that *generates* the labels. Use it as a **target for the confidence head**: a token whose co-visible content the model reproduced ⇒ confidence high; a token that *had* co-visible support but was reproduced wrong ⇒ retrieval failure (what you want to flag). The co-visibility set also gives a **mask**: only ask "did you retrieve correctly?" where retrieval was geometrically possible. ### Level 2 — Reprojection correspondence (the per-token target you asked for) Level 1 is frame-level + coarse-region. Level 2 tightens it to **per latent token**: use the relative RT to warp the co-visible context latent into the current view, then define correct retrieval token-by-token. This removes the Level-0 ambiguity (novel regions are masked out of the retrieval loss). #### 6.1 Token ↔ pixel geometry in this stack (must get this right) To reproject *into latent-token space* you need the compression factors: | Stage | Factor | Source | | --- | --- | --- | | VAE spatial downsample | **÷8** (three 2× `downsample2d/3d` blocks) | `wan_video_vae.py` Resample blocks | | VAE temporal downsample | **÷4** (`temperal_downsample=[True,True,False]`, +1 for the first frame) | `wan_video_vae.py:284` | | DiT patchify | **(1, 2, 2)** | `wan_video_dit.py:511` `patch_size=(1,2,2)` | So one latent **token** covers a `8·2 = 16` px × `16` px region of the original frame (spatially), and the latent grid for a `352×640` frame is `H_lat = 352/8 = 44`, `W_lat = 640/8 = 80`, then patchified by 2 → `22 × 40` token grid per latent frame. **Reproject at the latent-pixel grid (44×80), then patch-pool to the token grid (22×40)** to match `um`'s resolution. #### 6.2 Building the per-token reprojection-confidence map Given a context frame *i* and the target frame, with relative pose `(R_rel, t_rel)` from `convert_rt_to_relative`, the homltography/flow that maps target latent-pixel `(u,v)` ↔ context latent-pixel depends on scene depth. Two regimes: - **Depth available** (SpatialVID has more geometry than the static pool): full reprojection `p_ctx = K · (R_rel · (depth · K⁻¹ · p_tgt) + t_rel)`. - **No depth / planar approximation** (static pool, paper's XY+yaw setting): a **homography** `Hℓ` suffices because motion is dominated by yaw + translation on a plane — exactly the regime `pose_to_rt(constrain_to_xy=True)` encodes. Build `Hℓ` from `(R_rel, t_rel)` and a reference plane normal/depth. The confidence target is then: *warp the context latent into the target view and measure how close the model's `x0_pred` is to that warped evidence, only where co-visibility holds.* ```python # diffsynth/models/memory/reproj_confidence.py (sketch — add as a new module) import torch import torch.nn.functional as F def latent_grid_hw(height_px: int, width_px: int): """Latent-pixel grid before DiT patchify: VAE divides spatial by 8.""" return height_px // 8, width_px // 8 def warp_context_latent(ctx_latent, H_rel): """ Warp a context latent frame into the target view via a 3x3 homography H_rel expressed in *latent-pixel* coordinates (44x80 for 352x640). ctx_latent: (B, C, Hl, Wl) single context latent frame H_rel: (B, 3, 3) target-latent-pixel -> context-latent-pixel returns: (B, C, Hl, Wl) warped context, (B,1,Hl,Wl) valid mask """ B, C, Hl, Wl = ctx_latent.shape ys, xs = torch.meshgrid( torch.arange(Hl, device=ctx_latent.device, dtype=torch.float32), torch.arange(Wl, device=ctx_latent.device, dtype=torch.float32), indexing="ij", ) ones = torch.ones_like(xs) grid = torch.stack([xs, ys, ones], dim=-1).reshape(1, Hl * Wl, 3).expand(B, -1, -1) src = torch.bmm(grid, H_rel.transpose(1, 2)) # (B, Hl*Wl, 3) src = src[..., :2] / src[..., 2:3].clamp(min=1e-6) # homogeneous divide sx, sy = src[..., 0], src[..., 1] # Normalise to grid_sample's [-1, 1] coordinates. gx = (sx / (Wl - 1)) * 2 - 1 gy = (sy / (Hl - 1)) * 2 - 1 samp = torch.stack([gx, gy], dim=-1).reshape(B, Hl, Wl, 2) warped = F.grid_sample(ctx_latent, samp, mode="bilinear", padding_mode="zeros", align_corners=True) valid = ((gx >= -1) & (gx <= 1) & (gy >= -1) & (gy <= 1)).float() return warped, valid.reshape(B, 1, Hl, Wl) # in-FOV co-visibility mask def reprojection_confidence_map(x0_pred, ctx_latents, H_rels, patch=2, tau=1.0): """ Per-token retrieval-confidence target from reprojection correspondence. x0_pred: (B, C, T, Hl, Wl) recovered x0 for TARGET tokens (per latent frame) ctx_latents: (B, C, K, Hl, Wl) clean context latents (VAE-encoded history) H_rels: (B, T, K, 3, 3) target-frame t <- context-frame k homographies (latent-pixel coords), from convert_rt_to_relative Returns: conf_tok: (B, 1, T, Hl//patch, Wl//patch) in [0,1], token-resolution mask_tok: (B, 1, T, Hl//patch, Wl//patch) co-visibility (any context covers token) """ B, C, T, Hl, Wl = x0_pred.shape K = ctx_latents.shape[2] best_err = x0_pred.new_full((B, 1, T, Hl, Wl), float("inf")) any_valid = x0_pred.new_zeros((B, 1, T, Hl, Wl)) for t in range(T): for k in range(K): warped, valid = warp_context_latent(ctx_latents[:, :, k], H_rels[:, t, k]) err = (x0_pred[:, :, t] - warped).pow(2).mean(dim=1, keepdim=True) # (B,1,Hl,Wl) err = torch.where(valid > 0, err, best_err[:, :, t]) best_err[:, :, t] = torch.minimum(best_err[:, :, t], err) # best matching ctx frame any_valid[:, :, t] = torch.maximum(any_valid[:, :, t], valid) best_err = torch.where(torch.isfinite(best_err), best_err, torch.zeros_like(best_err)) conf = torch.exp(-best_err / tau) # low reprojection error -> high confidence conf = conf * any_valid # undefined where nothing is co-visible # Patch-pool latent-pixel grid (44x80) down to token grid (22x40) to match `um`. conf_tok = F.avg_pool3d(conf, kernel_size=(1, patch, patch)) mask_tok = (F.avg_pool3d(any_valid, kernel_size=(1, patch, patch)) > 0).float() return conf_tok, mask_tok ``` **Where `H_rels` comes from.** In `training_loss` you already have (or can pass through `inputs`) the per-frame RTs. For target latent frame *t* and context frame *k*: `rel = convert_rt_to_relative([rt_k], ref_rt=rt_t)[0]`, parse into `(R_rel, t_rel)`, and convert to a latent-pixel homography with the intrinsics scaled by 1/8 (latent) — under the paper's XY+yaw planar setting a homography is the correct first-order model. Precompute `H_rels` on CPU/numpy in the dataloader (the RTs are already loaded for the action MLP) and pass them in as a tensor; avoid per-step Python geometry in the hot loop. #### 6.3 Two ways to use the reprojection map This is the bridge to Category B / Category C from the discussion: - **(B) As a near-ground-truth confidence map directly** — `conf_tok` *is* a retrieval-confidence map, no learning required. Use it to reweight the main loss (`w = conf_tok` upweights tokens that *should* be retrievable, focusing the memory pathway on co-visible content) or as an eval-time diagnostic over the revisit tail. - **(C) As the supervision target for a predictive head** — train the `UncertaintyHead` (or a dedicated `ConfidenceHead`) to **predict `conf_tok` before generation**, supervised only on `mask_tok` tokens: ```python pred_conf = torch.sigmoid(-um) # head's confidence in [0,1] loss_conf = (mask_tok * (pred_conf - conf_tok.detach()).pow(2)).sum() \ / mask_tok.sum().clamp(min=1.0) loss = loss + lambda_conf * loss_conf ``` This gives a **calibrated, forward-time** confidence signal grounded in geometry, instead of the self-supervised heteroscedastic target — and it cleanly answers "do we know, during training, whether we retrieved the correct context?": *yes, because geometry tells us which tokens had retrievable support and reprojection tells us whether the model reproduced it.* ### How to know, during training, if retrieval is correct — summary | Target level | Source in repo | "Correct" means | Strength / caveat | | --- | --- | --- | --- | | **0 Reconstruction** | `target_latents` (base loss) | low token MSE | weak — confounds forgetting vs. novelty | | **1 Co-visibility** | `overlap_labels/*.json`, `compute_fov_overlap_3d` | model uses the geometrically co-visible frames | frame/region-level; FOV-frustum, **not depth/occlusion aware** | | **2 Reprojection** | relative RT (`convert_rt_to_relative`) + warp | warped co-visible evidence matches `x0_pred`, masked to co-visible tokens | per-token, strongest; needs depth or planar/homography assumption | **Honesty caveat (state this in any writeup):** the overlap labels and `compute_fov_overlap_3d` are **camera-frustum** co-visibility from poses, *not* depth-aware occlusion — two frames can be marked co-visible when an occluder blocks the shared content. Level-2 reprojection inherits this: the homography regime assumes near-planar / yaw-dominant motion (the paper's `constrain_to_xy` setting). For truly metric per-token correspondence, use depth (better available in the SpatialVID dynamic pool) and a full reprojection rather than a homography. --- ## 7. Depth-aware per-token confidence (the accurate version) With depth you replace the §6.2 **homography** (planar, yaw-dominant approximation) by a **full metric reprojection with occlusion reasoning**. This removes the two failure modes of the homography path: (i) it handles arbitrary 3D scene geometry and 6-DoF motion, not just a reference plane, and (ii) it can *detect occlusion* — telling apart "co-visible and the model retrieved it" from "the frustum overlaps but an occluder hides the content" (the exact blind spot of the FOV-frustum labels in §6, Level 1). ### 7.1 Pose convention in this repo (get the direction right) `fov_retrieval.compute_fov_overlap_3d` treats `position` as the **camera centre in world coordinates** `C` and the third column `R[:,2]` as the world-space **forward** axis. So the stored 12-dim RT `[t | R]` is **camera-to-world**: ``` X_world = R · X_cam + C # R = R_cam→world, C = camera centre = t X_cam = Rᵀ · (X_world − C) # world → camera (inverse) ``` (`R⁻¹ = Rᵀ` for a rotation). This is the opposite direction from a "world-to-camera extrinsic" `[R|t]` convention — using the wrong one silently flips the reprojection, so anchor on this. ### 7.2 The reprojection (target token → 3D → context frame) For a target latent token at pixel `p_t=(u,v)` in latent-pixel coords with metric depth `d`: 1. **Back-project to the target camera ray, scale by depth:** `X_cam_t = d · K⁻¹ · [u, v, 1]ᵀ` 2. **Target camera → world** (camera-to-world): `X_world = R_t · X_cam_t + C_t` 3. **World → context camera k:** `X_cam_k = R_kᵀ · (X_world − C_k)` 4. **Project into context frame k:** `p_k = K · X_cam_k / z_k`, where `z_k = X_cam_k.z` `K` is the **latent-resolution** intrinsic: build it from the FOV (`fov=52.67°`, the same constant `compute_fov_overlap_3d` uses) and divide focal length + principal point by the VAE spatial factor 8 (so it acts on the 44×80 latent-pixel grid, matching §6.1). Then sample the context latent at `p_k` and, crucially, **also sample the context depth at `p_k`** for the occlusion test. ### 7.3 Occlusion test (what depth buys you) A target point is genuinely visible in context frame k only if its reprojected depth `z_k` matches the context frame's own recorded depth at `p_k`. If the context depth is *closer* than `z_k`, something else occludes the point — mark it **not co-visible** even though the frustum overlaps: ``` visible_k = (z_k ≤ depth_ctx_k(p_k) · (1 + occ_thresh)) ``` This is a forward z-buffer check (`occ_thresh` ~0.05–0.1 absorbs depth noise). It is exactly the discriminator the §6 Level-1 labels lack. ### 7.4 Sketch (syntax-checked) ```python # diffsynth/models/memory/reproj_confidence_depth.py (sketch) import torch import torch.nn.functional as F def intrinsics_latent(width_px, height_px, fov_deg=52.67, vae_down=8): """Latent-resolution pinhole intrinsics K (focal & principal point ÷ VAE factor).""" import math Wl, Hl = width_px // vae_down, height_px // vae_down f_px = (width_px / 2.0) / math.tan(math.radians(fov_deg) / 2.0) f_lat = f_px / vae_down K = torch.tensor([[f_lat, 0.0, Wl / 2.0], [0.0, f_lat, Hl / 2.0], [0.0, 0.0, 1.0]]) return K, Hl, Wl def reproject_target_to_context(depth_t, R_t, C_t, R_k, C_k, K, Kinv): """ Map every target latent-pixel into context frame k via depth + camera-to-world RT. depth_t: (B,1,Hl,Wl) metric depth of TARGET latent frame R_t,R_k: (B,3,3) camera->world rotations ; C_t,C_k: (B,3) camera centres returns: grid (B,Hl,Wl,2) for grid_sample, in_fov mask (B,1,Hl,Wl), z_k_map (B,1,Hl,Wl) reprojected depth in context camera """ B, _, Hl, Wl = depth_t.shape dev = depth_t.device ys, xs = torch.meshgrid(torch.arange(Hl, device=dev, dtype=torch.float32), torch.arange(Wl, device=dev, dtype=torch.float32), indexing="ij") ones = torch.ones_like(xs) pix = torch.stack([xs, ys, ones], -1).reshape(1, Hl * Wl, 3).expand(B, -1, -1) ray = torch.bmm(pix, Kinv.transpose(1, 2)) # K^-1 [u,v,1] d = depth_t.reshape(B, Hl * Wl, 1) Xc_t = ray * d # target camera coords Xw = torch.bmm(Xc_t, R_t.transpose(1, 2)) + C_t.reshape(B, 1, 3) # cam->world Xc_k = torch.bmm(Xw - C_k.reshape(B, 1, 3), R_k) # world->context cam (R_k^T via right-mul) z_k = Xc_k[..., 2:3].clamp(min=1e-6) proj = torch.bmm(Xc_k / z_k, K.transpose(1, 2)) u, v = proj[..., 0], proj[..., 1] gx = (u / (Wl - 1)) * 2 - 1 gy = (v / (Hl - 1)) * 2 - 1 grid = torch.stack([gx, gy], -1).reshape(B, Hl, Wl, 2) in_fov = ((gx >= -1) & (gx <= 1) & (gy >= -1) & (gy <= 1)).float().reshape(B, 1, Hl, Wl) return grid, in_fov, z_k.reshape(B, 1, Hl, Wl) def depth_aware_confidence(x0_pred, ctx_latents, ctx_depths, depth_t, R_t, C_t, R_k_list, C_k_list, K, patch=2, tau=1.0, occ_thresh=0.1): """ Per-token retrieval-confidence map via metric reprojection + occlusion test. x0_pred: (B,C,T,Hl,Wl) recovered x0 for target tokens ctx_latents:(B,C,K,Hl,Wl) clean context latents ; ctx_depths:(B,1,K,Hl,Wl) depth_t: (B,1,T,Hl,Wl) target-frame metric depth R_t,C_t: (B,T,3,3),(B,T,3) target cam->world per latent frame R_k_list,C_k_list: lists of (B,3,3),(B,3) per context frame """ B, C, T, Hl, Wl = x0_pred.shape Kb = K.unsqueeze(0).expand(B, -1, -1) Kinv = torch.inverse(K).unsqueeze(0).expand(B, -1, -1) best_err = x0_pred.new_full((B, 1, T, Hl, Wl), float("inf")) any_valid = x0_pred.new_zeros((B, 1, T, Hl, Wl)) for t in range(T): for k in range(len(R_k_list)): grid, in_fov, z_proj = reproject_target_to_context( depth_t[:, :, t], R_t[:, t], C_t[:, t], R_k_list[k], C_k_list[k], Kb, Kinv) warped = F.grid_sample(ctx_latents[:, :, k], grid, mode="bilinear", padding_mode="zeros", align_corners=True) ctx_z = F.grid_sample(ctx_depths[:, :, k], grid, mode="bilinear", padding_mode="zeros", align_corners=True) visible = (z_proj <= ctx_z * (1.0 + occ_thresh)).float() # z-buffer occlusion test valid = in_fov * visible err = (x0_pred[:, :, t] - warped).pow(2).mean(1, keepdim=True) err = torch.where(valid > 0, err, best_err[:, :, t]) best_err[:, :, t] = torch.minimum(best_err[:, :, t], err) any_valid[:, :, t] = torch.maximum(any_valid[:, :, t], valid) best_err = torch.where(torch.isfinite(best_err), best_err, torch.zeros_like(best_err)) conf = torch.exp(-best_err / tau) * any_valid conf_tok = F.avg_pool3d(conf, (1, patch, patch)) mask_tok = (F.avg_pool3d(any_valid, (1, patch, patch)) > 0).float() return conf_tok, mask_tok ``` ### 7.5 Getting depth into latent-token space - **Source.** The SpatialVID dynamic pool carries richer geometry than the static pool; if per-frame metric depth is not already exported, run a monocular depth estimator offline and cache it (do **not** add it to the training hot loop). The static in-domain pool only has camera poses, so depth-aware confidence is primarily a **dynamic-pool** technique. - **Resolution.** Downsample depth to the **latent-pixel grid** (÷8 → 44×80) by *area/min pooling* (min-pool preserves near surfaces for the occlusion test; avoid bilinear across depth discontinuities, which invents mid-air depths). - **Scale.** Metric consistency matters — `z_k` (reprojected) and `depth_ctx_k` must be in the **same units**. If depth is up-to-scale (monocular), fit a per-video scale so it is consistent with the RT translation units, or make the occlusion test **relative** (compare normalised depth ranks) instead of absolute. - **Plumbing.** Precompute and pass `depth_t`, `ctx_depths`, and the per-frame `(R, C)` through `inputs` (the RTs are already loaded for the action MLP — see §6). Keep the double loop over `T×K` out of the innermost step by vectorising over `k`, or restrict `k` to the top-N co-visible frames from the §6 Level-1 overlap labels (cheaper and removes obviously-irrelevant frames first). ### 7.6 Accuracy ladder (how the targets compare) | Variant | Geometry model | Occlusion | Needs | Accuracy | | --- | --- | --- | --- | --- | | §6 Level-1 co-visibility | camera frustum (poses only) | ✗ | poses | frame/region | | §6.2 homography | planar / yaw-dominant | ✗ | poses + plane | per-token, approx | | **§7 depth reprojection** | full 6-DoF metric | **✓ (z-buffer)** | poses + **depth** | **per-token, metric** | The depth-aware map plugs into the **same two consumers** as §6.3: use `conf_tok` directly to reweight the main loss, or as the supervision target for a forward-time `ConfidenceHead` (masked on `mask_tok`). The only change is a strictly more accurate, occlusion-aware target. **Caveats specific to depth.** Reprojection confidence is now bounded by *depth quality*: noisy/biased monocular depth produces false occlusions and warp errors. Mitigate with a tolerant `occ_thresh`, min-pooled latent depth, and — when in doubt — fall back to the §6.2 homography or §6 Level-1 mask for frames whose depth is low-confidence. Dynamic/independently-moving objects also break the static-scene assumption of any reprojection (the point moved between frames); mask known-dynamic regions out of the retrieval loss where you can detect them.