Spatially-grounded depth head (opt-in) + depth-range guards (I1-B/I1-D/I1-G)#50
Open
sibocw wants to merge 1 commit into
Open
Spatially-grounded depth head (opt-in) + depth-range guards (I1-B/I1-D/I1-G)#50sibocw wants to merge 1 commit into
sibocw wants to merge 1 commit into
Conversation
…D/I1-G) Addresses the primary cause of "3D keypoints worse than body-seg" from audit #48. The depth head was the asymmetric weak link: it AdaptiveAvgPool2d's the whole decoder map to one global vector and regresses ALL keypoints' depths from it, with no spatial localization, while the x-y head is fully spatial. Changes: - I1-B: add an opt-in spatially-grounded depth head. New config field `depth_head_type` ("global" default = unchanged/checkpoint-compatible; "spatial" = new). The spatial head bilinearly samples the decoder feature map d0 at each keypoint's predicted (x, y) (soft-argmax of its heatmap, same 128x128 grid as d0) via F.grid_sample, then a shared small MLP maps the per-keypoint feature vector to depth bins. Stored on `self.depth_head` so the pipeline's optimizer LR group and AMP check pick it up unchanged (no pipeline.py edit needed; param-coverage assertion verified for both heads). - I1-G: fix the disjoint default depth window (was [-70,-63], disjoint from the real ~[-102,-99] mm label range -> every keypoint OOB -> zero depth loss under oob_treatment="drop"). New default [-103,-99] overlaps the data, with comments that it must match the labels. Added a runtime ERROR-level log in Pose2p5DLoss when >50% of a batch has OOB depth. - I1-D: per-keypoint OOB masking deferred to future work (whole-sample drop only loses ~0.18% in practice; adding a "mask" oob_treatment would require threading masks through both depth loss reductions, not clean here). Backward compatibility: existing "global" checkpoints load fine with the default. Switching to "spatial" requires training a new model (depth-head state_dict keys differ); encoder/decoder/heatmap head are unaffected. New tests (tests/test_depth_head.py, CPU): both head types forward to (N, n_kp, depth_n_bins) finite logits; grounding test that the spatial head's output depends on the predicted (x, y) location (global pool would be invariant); full-model peak-follows-location; gradients flow to the new head. All 5 pass. Refs #48 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This work was implemented by Claude Code (Opus 4.8) upon user instruction.
Rationale
Audit #48 found 3D keypoints performing worse than body-seg. The root cause (finding I1-B) is the depth head:
_build_depth_headdoesAdaptiveAvgPool2d(1)on the(N, 64, 128, 128)decoder map BEFORE its conv, collapsing it to a single global descriptor. Every keypoint's depth is then regressed from that one global vector with no spatial localization — whereas the x-y heatmap head is fully spatial. Depth is the asymmetric weak link, so this is the primary driver of weak 3D.Changes
I1-B — spatially-grounded depth head (opt-in)
ModelArchitectureConfig.depth_head_typefield:"global"(default) — the existing global-pool head; unchanged behavior and checkpoint-compatible."spatial"— the new head. After the model computes the heatmaps and the soft-argmax keypoint locationsxy_px_out(heatmap-pixel coords on the same 128x128 grid asd0), it bilinearly samplesd0at each keypoint's predicted (x, y) viaF.grid_sample(coords normalized to [-1, 1],align_corners=False), giving per-keypoint feature vectors(N, n_kp, C). A shared small MLP (Linear -> GroupNorm -> ReLU -> Linear) mapsC -> depth_n_binsper keypoint. This grounds each keypoint's depth at its own image location, mirroring the spatial x-y head.self.depth_headregardless of type, so the training pipeline's optimizer LR group (model.depth_head.parameters()) and the AMP status check pick it up unchanged — nopipeline.pyedit was needed. I verified the pipeline'sn_params_optimizer == n_params_modelassertion holds for both head types.pose_head_init_std); the hidden layer uses Kaiming (it precedes ReLU).I1-G — fix disjoint depth-range default + runtime guard
[-70, -63]is disjoint from the measured label range (~[-102, -99]mm). A run that forgets to override it makes every keypoint OOB and, underoob_treatment="drop", the loss returns 0 (trains nothing). Production configs override it, but it's a landmine. New default:depth_min=-103.0,depth_max=-99.0(overlaps the data), with comments that it MUST match the labels.Pose2p5DLosswhen >50% of a batch has OOB depth, telling the user the depth window is likely misconfigured. This surfaces I1-G/I1-D at runtime.I1-D — deferred
Per-keypoint OOB masking (a new
"mask"oob_treatment) was not added: whole-sample drop (.any(dim=1)) only loses ~0.18% in practice (not a starvation problem), and adding it cleanly would require threading per-keypoint masks through both depth loss reductions (_compute_depth_ce_loss,_compute_depth_l1_loss), which risks changing existing loss magnitudes. Noted as future work. Existingdrop/clamp/ignorebehavior is unchanged.Checkpoint compatibility (important)
depth_head_type="global"."spatial"requires training a new model — the depth-headstate_dictkeys differ between the two heads (depth_head.0.weightetc. vsdepth_head.fc1.weightetc.), so a global checkpoint cannot load into a spatial model (and vice versa). The encoder / decoder / heatmap head are byte-for-byte unaffected.Tests
New
tests/test_depth_head.py(CPU, tiny config; shims the missing private pvio symbol and pinsposeforgeto this worktree'ssrcbefore importing):"global"and"spatial"forward on a random batch ->depth_logitsof shape(N, n_kp, depth_n_bins), finite;Refs #48
🤖 Generated with Claude Code