Skip to content

fix: do not count nnU-Net's ignore label as background in the EM histogram - #141

Open
Hendrik-code wants to merge 1 commit into
mainfrom
hm/synthseg-em-ignore-label
Open

Hendrik-code wants to merge 1 commit into
mainfrom
hm/synthseg-em-ignore-label

Conversation

@Hendrik-code

Copy link
Copy Markdown
Collaborator

The bug

em_subdivide_labels (SynthSeg EM label completion) takes each label's voxel count from a histogram of label_map.clamp_min(0), then selects that label's voxels with label_map == p.

A negative value is not a label: nnU-Net writes -1 for voxels outside the annotated region. The clamp counted every -1 as background; the selection that follows excludes them. So the count exceeded the number of voxels gathered, and the randint(0, count) subsample that fits the background GMM could draw an index past the end of x:

cnt = int(counts_per_sample[b][p])     # includes the -1 voxels
mask = lab_b == p                      # excludes them
x = ref_b[mask]                        # len(x) < cnt
sel = torch.randint(0, cnt, (max_fit_voxels,))
x_fit = x[sel]                         # out of bounds

On CPU that raises IndexError. On CUDA it is an asynchronous index out of bounds device-side assert, reported at whichever kernel happens to run next — in my case inside _em_gmm_1d, nine frames from the actual fault. It only fires when an oversized index is actually drawn, so it is probabilistic: for a 128³ patch with six ignored voxels and the shipped em_max_fit_voxels: 100000, about two runs in five.

Reproduced on a preprocessed Dataset804 patch: the histogram reports 1219605 background voxels where label == 0 selects 1219599, the difference being exactly the six ignored voxels.

Scope

No published training is affected. nnUNetTrainerDAExt.get_training_transforms puts RemoveLabelTansform(-1, 0) in the CPU dataloader pipeline, ahead of the GPU augmentations, so a GPU transform is never handed a negative label in training.

It is reachable from RandomSynthSegGPU and from em_subdivide_labels directly, both of which take a label map and document no such precondition. I hit it in an offline harness that reads the preprocessed .npy files straight off disk.

Introduced in 20360d8 ("perf: remove the per-sample loops and host stalls from the augmentations"), which replaced the correct cnt = int(mask.sum()) with the batched histogram.

The fix

  1. _label_counts_per_sample excludes negative values from the histogram, keeping the single-bincount shape that commit was after. The clamp stays on the row offset only, so a negative label cannot land in another sample's bins.
  2. em_subdivide_labels returns one undivided region when no non-negative label is present at all. That case was previously unreachable — the clamp turned an all-ignore patch into all-background — and would otherwise index an empty parents.

Behaviour is unchanged for any label map without negative values, which is every label map the trainer produces.

Tests

New unit_tests/test_synthseg_em_ignore_label.py, four tests. The first pins the invariant the crash violated (counted == selected) and is deterministic; the others exercise the call that raised, and the all-ignore patch.

Verified both directions on this branch:

  • with the fix: 4 passed, 4 subtests passed
  • with functional.py reverted: 5 failed, 1 passed — IndexError: index 2272 is out of bounds for dimension 0 with size 1840

Full suite: 473 passed, 13 skipped, 1523 subtests passed.

🤖 Generated with Claude Code

…ogram

`em_subdivide_labels` took each label's voxel count from a histogram of
`label_map.clamp_min(0)` and then selected that label's voxels with
`label_map == p`. A negative value is not a label -- nnU-Net writes -1 for
voxels outside the annotated region -- so every -1 was counted as background
while the selection excluded it. The count was therefore larger than the
number of voxels gathered, and the `randint(0, count)` subsample that fits
the background GMM could draw an index past the end.

On CPU that raises `IndexError`. On CUDA it is an asynchronous
`index out of bounds` device-side assert, reported at whichever kernel runs
next, and it only fires when an oversized index is actually drawn: for a
128^3 patch with six ignored voxels and the shipped `max_fit_voxels: 100000`
it is about two runs in five. It presented as an occasional crash pointing at
innocent code.

Reproduced on a preprocessed Dataset804 patch: the histogram reported
1219605 background voxels where `label == 0` selects 1219599, the difference
being the six ignored voxels.

Not reachable from `nnUNetTrainerDAExt`, which puts
`RemoveLabelTansform(-1, 0)` in the dataloader ahead of the GPU
augmentations, so no published training is affected. It is reachable from
`RandomSynthSegGPU` and `em_subdivide_labels` directly, which take a label
map and document no such precondition.

The histogram now excludes negative values, and `em_subdivide_labels`
returns one undivided region when no non-negative label is present at all --
previously unreachable, because the clamp mapped such a patch to background.

Introduced in 20360d8 ("perf: remove the per-sample loops and host stalls
from the augmentations"), which replaced `cnt = int(mask.sum())` with the
batched histogram.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant