Fix SAM v1 off-by-one in hidden_states indexing - #67
Open
LevanBokeria wants to merge 2 commits into
Open
Conversation
SAM v1 read hidden_states[idx] with the raw SAM_CONFIGS layer_indices, but hidden_states[0] is the patch embedding, so every layer was shifted one block earlier and the final block was never read. DINOv3's get_intermediate_layers is genuinely block-indexed, so the head-to-head comparison was skewed; SAM v1 classification (cache_sam_cls_features) already used the true final block, so the two tasks disagreed. SAM_CONFIGS layer_indices are now block indices, with the +1 applied at read time via features.py::_select_hidden_state, which raises a named IndexError rather than silently reading the wrong entry. sam-vit-base moves to (3, 6, 9, 11) to match DINOV3_CONFIGS at equal depth; this also changes the layers_ cache tag so a stale cache cannot be silently reused. SAM2 is NOT affected and is unchanged behaviourally: its layer_indices were deliberately chosen in hidden_states space so all four land inside Stage 3. Because extract_sam_volume_features serves both families, it now takes a required hidden_state_offset carried in load_backbone metadata (1 for SAM v1, 0 for SAM2) instead of guessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
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.
Fixes #66.
The bug
extract_sam_2d_features/extract_sam_volume_featuresreadhidden_states[idx]with theraw
SAM_CONFIGSlayer_indices, buthidden_states[0]is the patch embedding. Every SAM v1layer was therefore shifted one block earlier and the final block was never read:
(2, 5, 8, 11)read blocks 1, 4, 7, 10.Two things made this worth fixing beyond tidiness:
get_intermediate_layers(n=list(...))is genuinely block-indexed (
for i, blk in enumerate(self.blocks): ... if i in blocks_to_take).cache_sam_cls_featuresloops all12 blocks manually), so segmentation and classification meant different things by "SAM's last
layer".
Changes
SAM_CONFIGSlayer_indicesare now block indices, with the+1applied at read time.sam-vit-basemoves(2, 5, 8, 11)→(3, 6, 9, 11), matchingDINOV3_CONFIGSat equaldepth;
large/hugevalues are unchanged but now resolve one block later, reaching blocks 23and 31. This also changes the
layers_cache tag, so a stale cache cannot be silently reused.features.py::_select_hidden_state— the single place the convention lives. Raises anamed
IndexError(config index and resolved position) instead of silently reading the wrongentry or a bare
IndexError.extract_sam_volume_featuresnow takes a required keyword-onlyhidden_state_offset. Itserves both families (
run_segmentation.pyroutes--backbone samand--backbone sam2through it), so a bare
idx + 1there would have broken SAM2. The offset is carried inload_backbonemetadata (1for SAM v1,0for SAM2) rather than guessed at the call site.hidden_state_offsetrecorded in stdout and in the committed results JSON, so old and newruns are distinguishable after the fact.
tests/test_feature_layer_indexing.py— 7 synthetic tests (no weights or data). The keyone asserts SAM v1 reads
[hs[4], hs[7], hs[10], hs[12]]for(3, 6, 9, 11); it fails on theold code. Two more pin SAM2 to offset 0 through both extractors.
AGENTS.mdgotcha on the two conventions, and a resolution note on N2 in the2026-09-01 refresher so the "decide this before extracting" instruction isn't re-litigated.
SAM2 is unchanged
Verified on
sam2.1-hiera-base-plus: 25 hidden states / 24 blocks, Stage 3 (448ch, 64×64) spanshs[6..21], and the configured(6, 11, 16, 21)land inside Stage 3 withhs[21]= its lastStage-3 block. Applying
+1would readhs[22](896ch, 32×32) and break the decoder.extract_sam2_2d_featuresnow routes through the helper withoffset=0— explicit, andbehaviourally identical.
Verification
facebook/sam-vit-baseweights: the last extracted layer now equalshidden_states[12](block 11) and no longer equalshidden_states[11].pytest tests/test_feature_layer_indexing.py tests/test_segmentation_metrics.py→ 14 passed.pre-commit run --files <changed>→ ruff, ruff-format and mypy pass on every changed file.Notes for the reviewer
SAM cache exists, so nothing stale is left behind. Expect Dice to move slightly: on
sam-vit-basethe previously-skipped block sits at cosine 0.983 / ‖Δ‖/‖h‖ ≈ 0.24 from the onethat was read, so this is a correctness fix, not an expected win for SAM.
re-cache-all-features:tests/test_classification_multi_dataset.pyfails at collection (MONAI'storch.jit.interfaceDeprecationWarningvsfilterwarnings = ["error"]), and the pinnedruff-format(0.5.5)reformats files the repo has committed under a newer ruff —
pre-commit run -arewrites ~130results JSONs and several scripts untouched by this PR.