Skip to content

fix+perf: audit pass over the post-model phases (10 bugs, 2-6x faster, -50% instance RSS) - #66

Merged
Hendrik-code merged 1 commit into
mainfrom
bugfinder
Aug 28, 2026
Merged

fix+perf: audit pass over the post-model phases (10 bugs, 2-6x faster, -50% instance RSS)#66
Hendrik-code merged 1 commit into
mainfrom
bugfinder

Conversation

@Hendrik-code

Copy link
Copy Markdown
Owner

A full read of the package looking for correctness bugs, memory bumpers, CPU hotspots and dead code. Ten real defects, a 2–6× speed-up of the two post-model phases, and ~2.4k lines of dead code removed.

Everything here is gated by new tests: unit_tests/test_regression_golden.py pins the instance and post-processing phases on two fixtures by digest, and unit_tests/test_bugfixes.py has one test per defect below — each verified to fail on the unfixed code. 219 tests pass, ruff clean, mkdocs builds.

Benchmarks

Two fixtures, measured on this branch vs main (44fe667) with everything else identical.

Synthetic whole spine — 24 vertebrae, 8.1M voxels, 0.75 × 0.75 × 1.65 mm (PIR):

Phase Before After
Instance (predict_instance_mask) 2.22 s 0.97 s 2.3×
Instance peak RSS +312 MB +157 MB −50%
Post-processing (phase_postprocess_combined) 3.20 s 0.56 s 5.7×

Real TPTBox test fixture upsampled to 0.75 mm — 1.4M voxels, 3 vertebrae:

Phase Before After
Post-processing 6.62 s 1.44 s 4.6×

The post-processing gain grows with vertebra count and resolution: add_ivd_ep_vert_label was 86% of the phase, and 8.4 s of a 10.6 s profile sat in growing-radius whole-volume dilations.

Where the time and memory went

  • The instance phase held a dense (n_vertebrae, 3, *volume) uint8 array that was almost entirely zeros — every prediction only ever covers one cutout_size window. It now stores each cutout where it lives (SparsePrediction), and Dice runs on the intersection of two bounding boxes instead of two whole volumes (~1100 whole-volume passes per scan before).
  • The endplate splitter re-dilated each vertebra from scratch at radius 1…14 and did ~6 whole-volume NII operators per vertebra per round — and every NII operator copies the array twice (NII_Math._binary_opt). It now works per vertebra on numpy arrays inside that vertebra's own window, grows each dilation by one voxel per round, and uses scipy's binary dilation instead of TPTBox's per-voxel Python loop.
  • clean_cc_artifacts asked np_connected_components_per_label for every label present and held one full-volume component array per label (~25 during the instance merge). It now does one label at a time and works inside each component's bounding box.
  • The input volume was read from disk up to three times per image (BIDS_FILE.open_nii() does not cache; the compatibility checks each opened it). Now loaded once and passed in.
  • Debug copies are no longer built when they will be discarded — NoOpDebugSink dropped the value, but Python still evaluated nii.copy().

None of these change output. That is the point of the golden tests: the instance mask and both post-processing outputs are byte-identical across the refactor.

Bugs fixed

These change output, because the previous behaviour was wrong.

Bug Effect
add_ivd_ep_vert_label used extract_label() without keep_label=True The superior/inferior endplate split never reached the semantic mask. It was computed, then binarised away — endplate voxels came out labelled 1 instead of 52/53.
clean_cc_artifacts aliased its dilation with its component mask np_dilate_msk mutates and returns its input, so dilated_m[mask_cc_l != 0] = 0 re-read the already-dilated mask and zeroed the shell and the component. The entire only_delete=False path was a no-op that still logged "cleaned".
get_separating_components dilated spart/tpart in place Returned two overlapping blobs as "the two separated components"; get_plane_split then took its normal vector between their smeared centers of mass. Its erosion loop also collapsed vol/vol_old/vol_erode onto one array, so the fallback branch could only ever raise.
semantic_bounding_box_clean ignored the region it grew It accumulated the bounding boxes of incorporated components and then cropped to the largest component's box anyway. Spines split across components (gaps, implants) lost mask.
detect_and_solve_merged_vertebra did subreg_cc += 100 Lifted the background out of 0, adding a volume-sized phantom IVD to the height-sorted list the split-C2 heuristic reads.
--model-semantic auto Documented CLI option that always raised NotImplementedError — the auto-selection was never implemented. Removed along with find_best_matching_model.
process_dataset model incompatibility Logged "stop program" and then carried on. Now raises ValueError unless --ignore-model-compatibility.
find_prediction_couple Detected two partners that agree with the anchor but not with each other, logged that it was skipping them, and used both anyway.
pipeline_version() Shelled out to git with no cwd, so it recorded whatever repository the caller happened to be standing in into every centroid JSON (or "Version not found"). Now importlib.metadata; ctd.info["revision"] is gone.
filepaths.py Ran mkdir into the installed package at import time — fails on a read-only install. Now created on demand.

Plus smaller ones: labeling crashed on an empty instance mask and on disable_c1=False with no subregion mask; find_most_probably_sequence mutated the caller's regions list and TypeErrored on allow_skip_at_region without region_rel_cost; the labeling classifier read its patch in the wrong axis order when angle == 0; the seg-key compatibility check compared a list against a list of strings and was always true; the citation reminder ignored its own opt-out (renamed to SPINEPS_NO_CITATION_REMINDER, and it no longer fires on bare import spineps).

Three of these come from one root cause worth knowing about: np_dilate_msk and np_erode_msk modify the array you pass in and return that same object.

Removed

Vendored utils/image.py (697 lines of spinalcordtoolbox) and its only consumer utils/generate_disc_labels.py, a standalone CLI wired to no entry point; architectures_new/unet2D.py (PLNet(do2D=True) now raises — the instance model is 3D only); the spineps/example scripts (figures kept, the README links them); the auto model path; out_unc, parallel_dice, str_id_com_label.

The legacy spineps/architectures stack stays — released checkpoints still load through the try PLNet / except RuntimeError: PLNet_new path, which now says so in a comment.

Also fixed: get_models.check_available_models assigned its module globals to the same dict objects as the download registry and then mutated them, so a local model scan permanently replaced release URLs with local paths.

Not done

  • get_separating_components' subreg_cc_n == 0 fallback now fails with a readable message instead of a bare KeyError, but the algorithm still cannot split a shape with no waist. Reconstructing the intended semantics is guesswork — it needs a domain call.
  • import spineps is still slow. Dropping entry_point from __init__ would not help; api → get_models → seg_model pulls the whole torch stack regardless.

Note for reviewers

spineps/architectures_new/pl_unet.py carries a one-line fix unrelated to the rest: PLNet.__init__ had a suppression comment in a form ruff does not recognise, so the pinned ruff 0.11 pre-commit hook flagged ARG002 on it — on main too, not just here.

MIGRATION.md documents every output change; the README's subregion label table now lists 52/53.

🤖 Generated with Claude Code

https://claude.ai/code/session_013djxckiGFkM46TRjf2RBQW

A full read of the package looking for correctness bugs, memory bumpers, CPU
hotspots and dead code. Ten real defects, a 2-6x speed-up of the two post-model
phases, and ~2.4k lines of dead code removed.

Bugs (output changes -- each was wrong before):

* add_ivd_ep_vert_label used extract_label() without keep_label=True, so the
  superior/inferior endplate split it had just computed was binarised away and
  the semantic mask came back with endplates labelled 1 instead of 52/53.
* clean_cc_artifacts aliased its dilation with its component mask (np_dilate_msk
  mutates and returns its input), zeroing the neighbourhood shell *and* the
  component. The whole only_delete=False path was a no-op that still logged
  "cleaned".
* get_separating_components dilated spart/tpart in place and returned them as
  "the two separated components" -- they overlapped, and get_plane_split derived
  its normal vector from their smeared centers of mass. Its erosion loop also
  collapsed vol/vol_old/vol_erode onto one array.
* semantic_bounding_box_clean grew a region to take in nearby connected
  components and then cropped to the largest component's box anyway.
* detect_and_solve_merged_vertebra did `subreg_cc += 100`, lifting the
  background out of 0 and adding a volume-sized phantom IVD to the stats the
  split-C2 heuristic reads.
* process_dataset logged "stop program" on model incompatibility and continued.
* find_prediction_couple detected two partners that agree with the anchor but
  not with each other, logged that it was skipping them, and used both.
* pipeline_version() shelled out to git with no cwd, recording whatever repo the
  caller stood in into every centroid JSON. Now importlib.metadata; the git
  revision field is dropped.
* filepaths.py ran mkdir into the installed package at import time.
* --model-semantic auto / process_dataset(model_semantic=None) always raised
  NotImplementedError -- the auto-selection was never implemented.

Plus: labeling no longer crashes on an empty instance mask or with
disable_c1=False and no subregion mask; find_most_probably_sequence no longer
mutates the caller's region list and no longer TypeErrors on
allow_skip_at_region without region_rel_cost; the labeling classifier patch is
read in the model orientation when angle is 0; the seg-key compatibility check
compared a list to a list of strings and was always true; the citation reminder
now honours its opt-out (renamed SPINEPS_NO_CITATION_REMINDER).

Performance (no output change -- gated by voxel-identical golden tests):

* The instance phase stores each cutout prediction where it lives instead of in
  a dense (n_vertebrae, 3, *volume) array, and compares candidates on their
  overlapping bounding box only.
* The endplate splitter runs per vertebra on numpy arrays, grows each dilation
  by one voxel per round instead of re-dilating from scratch, and uses scipy's
  binary dilation rather than TPTBox's per-voxel Python loop.
* clean_cc_artifacts builds connected components one label at a time, inside
  each component's bounding box.
* The input volume is read from disk once per image instead of up to three
  times; debug copies are no longer built when they will be discarded.

24 vertebrae, 8.1M voxels: instance 2.22s -> 0.97s (peak RSS +312MB -> +157MB),
post-processing 3.20s -> 0.56s. Real TPTBox fixture at 0.75mm: post-processing
6.62s -> 1.44s.

Removed: vendored utils/image.py and utils/generate_disc_labels.py,
architectures_new/unet2D.py, spineps/example scripts (figures kept), the auto
model path and find_best_matching_model, out_unc, parallel_dice,
str_id_com_label. The legacy spineps/architectures stack stays -- released
checkpoints still load through it.

Also fixes a pre-existing pre-commit failure: PLNet.__init__ carried a
suppression comment in a form ruff does not recognise, so the pinned ruff 0.11
hook flagged ARG002 on it before this branch too.

New tests: unit_tests/test_regression_golden.py pins the instance and post
phases on two fixtures by digest; unit_tests/test_bugfixes.py has one test per
defect above, each verified to fail on the unfixed code. 219 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013djxckiGFkM46TRjf2RBQW
@Hendrik-code
Hendrik-code merged commit 3083074 into main Aug 28, 2026
10 checks passed
@Hendrik-code
Hendrik-code deleted the bugfinder branch August 28, 2026 08:32
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