Repository navigation
feat: shift the scan inside the patch and fill what lies outside it with EM-synthesised background - #144
Draft
Hendrik-code wants to merge 3 commits into
Draft
feat: shift the scan inside the patch and fill what lies outside it with EM-synthesised background#144Hendrik-code wants to merge 3 commits into
Hendrik-code wants to merge 3 commits into
Conversation
…ith EM-synthesised background A model trained on sagittal spine slabs has never been shown what lies beside them. nnU-Net centres the ~8 cm slab in a 128 mm patch and pads the rest with 0, so every training window contains spine and everything beyond +-4 cm is a flat slab labelled background. On whole-body axial NAKO VIBE the Ours setup then predicts vertebrae in muscle, gluteals, fat and bowel: vertebra instance precision 0.01 with ~100 false-positive components per subject, and 60% of the false-positive voxels more than 5 cm from the spine. That is missing context, not a contrast failure, and no intensity augmentation reaches it. `RandomFOVExtensionGPU` (GEO, placed right after `RandomAffineGPU`) does, per selected sample: * shifts image and mask by the same whole-voxel offset, up to `shift` of the patch per axis, so the spine reaches the window edge or leaves it; * fills everything that is not scan -- the exact-`pad_value` padding plus the vacated area -- SynthSeg-style: EM splits the patch's own background (label 0, beyond `margin` of any label) into 3-10 intensity regions, a majority filter and a random diffeomorphic field post-process the region map, warped copies of it are composited into the fill, and each region is rendered from its own mean and std with spatially correlated texture, then blurred as SynthSeg blurs. The fill's label is 0. Row selection is pinned per sample: without a `pipeline` section the trainer forces `same_on_batch=True` onto every child, which would make `p` one coin per batch and could take the spine out of both samples of a step. The EM is a batched, weighted form of `_em_gmm_1d` with no boolean indexing and no host read. Its moment accumulation is a broadcast product rather than `resp.mT @ design`: cuBLAS takes ~7.5 ms for that reduction shape, the product ~0.04 ms. `transform_params_one-sequence-to-segment-them-all-fov.json` (ae065824) is the paper's Ours config (c69b9872) plus the transform at its defaults and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t a label at the window face Two changes to RandomFOVExtensionGPU, both found reading the 803 screen of #144. The fill was fitted on the patch after the shift. A far shift leaves 3-8% of the scan in the window, so the EM, the atlas and the render statistics came from that sliver, and each layer warped what the previous layers had written: on real 803 patches one region filled 75-99% of a far-shifted window, and a window with nothing left stayed flat 0. Under shift 1.0 about 60% of shifted samples keep less than 10% of the scan, so most spine-free training windows were one homogeneous blob. The EM, majority filter, deformation and region statistics now run on the patch as it arrives, and every layer warps that fixed full map. spare_label_faces (default true) stops the shift vacating a window face that a label comes within `margin` voxels of. A structure may still leave through that face, as at any window edge, but never gets synthetic tissue against it. The spine touches both faces along its length, so it is no longer shifted along that axis: on NAKO VIBE the shift-1.0 model lost recall at the superior end of the spine and in the posterior elements. Offsets are computed on the device from params["seg"] in both hooks, so image and mask cannot disagree and sparing costs no host sync; `_shift` is now a per-axis gather for that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shipped -fov config is now byte-identical (d35b9d91) to fov2_spare, the best arm of the Dataset803 screen: the paper config plus RandomFOVExtensionGPU at the defaults, except shift 1.0. spare_label_faces is written out although it is the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
This branch has not been deployed
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.


Why
A model trained on a narrow field of view has never been shown what lies outside it. On whole-body axial NAKO VIBE, the SpineGAN-trained models lose most of their Dice to false positives far from the spine. The contrast augmentations are not failing here: the network has never been asked to reject that context.
Diagnosis, measured on
Ours (fp32)(paper configc69b9872on01bf0b3), NAKO VIBE test split:The cause is the training geometry. Preprocessed 803 has a median of 85 (LR) × 297 × 187 voxels at 1 mm, with a 128³ patch. The foreground leaves only 22 mm of LR free, so nnU-Net centres the slab and pads the rest:
What
RandomFOVExtensionGPUis a new GEO transform insmauglab/transforms/gpu/context.py. It runs directly afterRandomAffineGPUand before every intensity transform. For each selected sample:shiftof the patch per axis. Atshift: 1.0the spine can leave the window entirely.spare_label_faces(defaulttrue), the shift never vacates a window face that a label comes withinmarginvoxels of. A structure can still leave through that face, but synthetic tissue is never placed against it.pad_value) plus whatever the shift vacated.marginof any label and off the padding seam are used.random_svf_field) post-process the region map.layersrandomly rotated, scaled, mirrored and translated copies of that fixed map are composited into the fill.Everything outside the fill region is the shifted input, bit for bit, and with
shift: 0only the padding changes.Commits:
6d889b9adds the transform.85b971bfixes two defects that the first screen exposed (see Evaluation):spare_label_facesis added;_shiftnow gathers on the device, so sparing adds no host sync.5a8b044sets the shipped config to the best arm.Other details:
pipelinesection the trainer forcessame_on_batch=Trueonto every child, which would makepone coin per batch and could empty both samples of a step of spine._em_gmm_1dwith no boolean indexing and no host read.resp.mT @ design: 0.04 ms against ~7.5 ms on this shape. Without it the transform cost 190 ms per batch.synthseg.functional._em_gmm_1dhas the same pattern and probably the same cost. It is untouched here and left for a separate PR.transform_params_one-sequence-to-segment-them-all-fov.json(d35b9d91). It isc69b9872plus the transform withshift 1.0,regions [3, 10],margin 4,layers 6,spare_label_faces trueandp 0.5, and nothing else. It is byte-identical to the screen'sfov2_sparearm. FOV stays opt-in, and the paper config is unchanged.all_augmentations.jsonare regenerated.Checks
All on
5a8b044.pytest unit_tests, including the slow tests, with one A40 visible: 497 passed, 13 skipped, 1570 subtests.main(7169464) in the same venv: 469 passed, 13 skipped, 1519 subtests.The 28 new tests in
test_fov_extension.pycover:same_on_batch=True;margin;The fill and sparing tests fail on
6d889b9.The registry sweeps pick the transform up automatically.
Pre-commit hooks (ruff) pass over
7169464..5a8b044.mypy smauglab/reports no issues, run locally with mypy 2.1.0 (CI pins 2.4.0).Fill check on 8 real 803 patches shifted so that about 11% or 0% of the scan stays in the window:
6d889b985b971bThe SI axis was spared on both faces in 8/8 patches, the other two axes in 0/8.
Cost on an A40, from the training logs: median epoch 62 s, against 51-52 s for four paper-config runs on the same card model (about +20%).
Evaluation
Dataset803 (SpineGAN Dixon in-phase), fold 0, one run per arm. The references are four runs of the paper config
c69b9872:ms0_base, the paired reference, on7169464;Ours (fp32);7169464.Their spread is the noise floor. The class-mean Dice is panoptica's global Dice for the paper cohorts. For NAKO VIBE it is recomputed against VibeSeg-100, four parts pooled (436 subject-parts on test, 62 on val). Arms are chosen on val and reported on test. TOST is at ±0.01, alpha 0.05.
First screen (
6d889b9, L40S)Dice delta vs
ms0_base, test:fov_fill(shift 0)fov_shift050(shift 0.5)fov_shift100(shift 1.0)fov_shift100removed 94% of the far FPs: voxels more than 20 mm from the reference spine fell from 37.9k to 2.30k per subject-part.ms0_basewith largest-connected-component (LCC) post-processing still beat it on every class;85b971bfixes both. The criteria for the second screen were written down before it launched.Second screen (
85b971b, A40)Both arms use
fov_shift100's settings.fov2_nospareturns sparing off, sofov2_spare - fov2_nospareisolates it. NAKO VIBE test, means per subject-part:fov_shift100fov2_nosparefov2_spare(shipped)ms0_basefov2_sparefov2_nosparems0_base+ LCC on vertebra, IVD and canalms0_baseSparing is the change that recovers recall.
fov2_spare - fov2_nospare, all FN: -5259 [-5816, -4701].The fill fix alone makes the model more conservative than v1: FN +3283 and far FP -1665.
Against v1,
fov2_sparerecovers part of the recall it lost, measured as the share of v1's excess over the replicate mean:Far FP stays at v1's level (+21 [-327, 368] voxels), and the in-domain cost is cut from -0.0092 to -0.0056.
Criterion 3 fails on the upper side. On SpineGAN CT,
fov2_spareis +0.0053 with interval [-0.0015, +0.0122] (n=27), so equivalence cannot be shown. Every other paper cohort is equivalent with p < 0.001.Criterion 2 fails on IVD. With LCC applied to every model,
fov2_spareis:About two thirds of its lost IVD recall is IVD predicted as vertebra (17.6% of reference IVD voxels, vs 14.1-14.6% for
ms0_baseand one replicate). That is a class-boundary error, not missed tissue.What this transform is, on this evidence. It is in-model removal of far false positives on wide-field-of-view data. On NAKO VIBE it takes raw class-mean Dice from
ms0_base's 0.715 to 0.795, with no post-processing. It does not beat the paper config plus LCC post-processing on class-mean Dice. Its in-domain cost (-0.0056 on Dixon in-phase) is inside the ±0.01 margin, but outside replicate noise.Known limits
🤖 Generated with Claude Code