Skip to content

test: raise coverage 48% -> 78% (+653 tests), fix 13 bugs found on the way - #159

Merged
Hendrik-code merged 25 commits into
mainfrom
coverage-85
Oct 21, 2026
Merged

Hendrik-code merged 25 commits into
mainfrom
coverage-85

Conversation

@Hendrik-code

@Hendrik-code Hendrik-code commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

What this does

Raises unit-test coverage from 47.9% to 78% by adding ~6,500 lines of tests across 17 new files (plus two extended), and curates the coverage omit list so the number is measured on code a test can actually reach.

Both effects are reported separately so neither hides the other:

statements missing coverage
main 20,229 10,538 47.9%
this branch, omit list reverted 20,243 7,338 64%
this branch, as configured 15,769 3,647 77%
as configured, with rtree present (what CI will see) 15,769 3,493 78%

So the tests alone carry 47.9% → 64% — 3,200 statements that were not executed before — and the omit list accounts for the rest by no longer counting ~4,500 statements of vendored forks, dataset-bound pipelines and dead code.

Suite: 1,295 passed / 21 skipped in 207 s, up from 642 passed / 5 skipped in 184 s. The 21 skips are all dependency guards (16 of them need rtree, which this PR declares, so they run in CI).

Measured locally in a full-dependency environment (py3.11, every optional extra installed). This is short of the ~85% I estimated when planning: the remaining misses are concentrated in the torch/deepali registration internals (multilabel_segmentation, spine_rigid_elements_reg, affine_deepali, _deepali/_utils, ~700 statements), which need real optimiser runs rather than a boundary mock, and in dicom_extract's deeper branching. Both are reachable, just not in this PR.

Per-module effect

Measured, not estimated — main vs this branch, same command, same environment:

module before after
spine/spinestats/curvature.py 11% 95%
spine/spinestats/pelvic_parameters.py 14% 100%
spine/spinestats/torso_vat_sat.py 5% 86%
spine/spinestats/body_quadrants.py 10% 96%
spine/spinestats/angles.py 31% 80%
core/dicom/xa_pairing.py 14% 89%
core/dicom/nii2dicom.py 20% 83%
core/dicom/dicom_renamer.py 11% 80%
core/dicom/dicom_extract.py 11% 61%
core/dicom/dicom2nii_utils.py 18% 60%
core/dicom/dicom_header_to_keys.py 7% 43%
logger/log_file.py 43% 95%
logger/log_constants.py 91% 100%
core/bids_files.py 39% 83%
core/poi_fun/ray_casting.py 37% 84%
core/poi_fun/save_load.py 61% 82%
core/poi_fun/poi_abstract.py 81% 92%
core/nii_wrapper.py 70% 86%
core/nii_wrapper_math.py 73% 95%
core/internal/nii_help.py 56% 78%
core/sitk_utils.py 55% 70%
segmentation/rib/_rib_assign.py 12% 63%
segmentation/VibeSeg/vibeseg.py 24% 79%
segmentation/nnUnet_utils/inference_api.py 12% 56%
segmentation/VibeSeg/inference_nnunet.py 9% 51%
registration/_ridged_points/point_registration.py 66% 90%
stitching/stitching_tools.py 22% 43%
spine/snapshot2D/snapshot_modular.py 79% 86%
spine/snapshot2D/snapshot_templates.py 14% 32%
spine/spinestats/measure_ivd_and_vertebra_geometry.py 20% 82%*
spine/spinestats/poi_fun/endplates.py 8% 40%*

* those two need rtree (see bug 7 below); in an environment that still lacks it the
16 guarded tests skip and the two modules read 36%.

How the tests are written

Analytic ground truth wherever the maths allows it, rather than recording current output:

  • a circle of radius r must have curvature exactly 1/r — asserted for several radii, several sampling steps, in the image plane and in a randomly tilted 3D plane (which is what proves the 3D cross-product branch agrees with the 2D one)
  • pelvic incidence must equal pelvic tilt + sacral slope to 1e-9, must be invariant under rotating all four landmarks in the sagittal plane (where PT and SS trade the rotation exactly), and must be scale-invariant — this pins the anatomical claims the docstring makes
  • tilting one vertebra by 10° must give segmental endplate angles of exactly +10 and −10 in the two adjacent segments and zero everywhere else; opposed 15° coronal tilts must give a 30° Cobb angle
  • trilinear interpolation must reproduce the linear field 2x + 3y − z exactly; a ray from the centre of a box spanning indices 10..20 must exit at 20.5
  • volumes are voxel count × voxel volume and areas are per-slice voxel count × in-plane area, with a deliberately anisotropic 2×2×5 mm grid so a dropped zoom cannot pass
  • VBQ is asserted as an exact 0.5 by setting vertebral bodies to 200 and the spinal canal to 400
  • the two convex ray casters are cross-checked against each other, and _plane_from_grid against get_plane_dicom, so neither side can drift alone

Where there is no closed form, the test asserts an invariant or a round-trip: affine decompose/recompose, apply_crop ∘ apply_crop_reverse, global_to_local_arr ∘ local_to_global_arr, POI save/load for every accepted format, NII ↔ SimpleITK, Slicer terminology strings, to_cord_system as an involution, forward ∘ inverse coordinate mapping, and byte-identical snapshot rendering.

DICOM has no sample in the repo, so the test makes one: the sample CT is written out as a 73-slice DICOM series with nii2dicom and read back with extract_dicom_folder, which pins the whole naming contract end to end (rawdata/sub-TESTPAT/ct/sub-TESTPAT_sequ-7_acq-iso_ct.nii.gz, where sequ-7 comes from SeriesNumber and acq-iso from the grid) plus the array round-trip, the age conversion, the zip path and the .extract_cache no-op. Runs in under two seconds.

For the GPU surface the mock sits at the model boundary and nowhere else: only predict_single_npy_array is replaced, so the real code still builds the channel axis, derives the properties dict and converts the result back onto the input grid — and that is what is asserted, not that a mock was called.

Bugs found and fixed

Each has a regression test that was verified to fail without the fix.

  1. segmentation/rib/_rib_assign.assign_ribs_to_vert_segmentation could never return. Two separate lines: (rib_inst != 0) & (rib_inst != error_value) combines two bool-backed NIIs and NII.__and__ raises TypeError for non-integer arrays; and extract_label([Rib_Left, Rib_Right] == 1) has its closing bracket one token too far left, so it evaluated [63, 64] == 1 → False → extract_label(0), tripping the "zero label is the background" assertion. The rib assignment entry point was dead.

  2. np_calc_boundary_mask returned an empty mask for any threshold >= 2. It wrote its sentinel value 2 into the array and then compared that same array against the threshold, erasing the foreground again. NII.boundary_mask(threshold) forwards straight to it, so every realistic HU threshold was affected.

  3. xa_pairing.pair_biplane_xa moved data out of rawdata. The target path came from get_changed_bids(...), whose parent defaults to "derivatives", so applying the pairing relocated the B-side of every biplane pair into derivatives/. The follow-up flip_z pass then no longer saw those files, leaving a pair with one flipped and one unflipped affine.

  4. Searchquery.filter_dixon_water / _fat / _outphase returned nothing whenever the files had JSON sidecars: _keys lists alternative spellings of one channel ("W"/"WATER") but the ImageType filter required all of them while the entity filter beside it used any. No real ImageType carries both spellings.

  5. NII.to_stl(number_path=True) silently did nothing — out_path.with_name(...) was computed and the result discarded. Since to_stls defaults number_path to True for a non-dict path, exporting a multi-label segmentation wrote every label to the same file and only the last mesh survived.

  6. Python 3.9 support was broken. spinestats/curvature.py imported itertools.pairwise at module scope (3.10+), so the whole module raised ImportError on the 3.9 CI leg — ruff never caught it (target-version = "py310") and test_docs_examples.py swallowed it because its missing-module check only fails for names starting with TPTBox. Plus four zip(..., strict=...) call sites (3.10+, TypeError at call time), one of them in measure_ivd_and_vertebra_geometry._segment_length. Added a pairwise backport next to the existing zip_strict in core/compat.py, and two tree-wide guard tests so neither pattern comes back.

  7. rtree was undeclared. trimesh's ray engine needs it for its broad phase and only declares it under its own easy extra, so every mesh ray cast raised ModuleNotFoundError — measure_ivd_and_vertebra_geometry and the endplate code could not run in a clean install (and did not run in 2 of 3 environments here). Added to the core dependencies.

  8. measure_ivd_and_vertebra_geometry(buffer_poi=None) — its own documented default — raised AssertionError, because it asked the POI buffering decorator to write the result back unconditionally and that decorator asserts the path is not None.

  9. stitching_tools._center_frontal cropped the wrong side of any volume below ~258 voxels: the window start middle - 122 goes negative and numpy reads a negative slice start as an offset from the end.

  10. rerun_bids_naming's dry run over-reported. The leftover sweep scans the disk, so under dry_run it reported every file pass 1 had only planned to move, with a different fallback destination: 6 planned moves where the real run performs 4.

  11. _scan_tree dropped filter_folder on recursion, so any filter_folder targeting a level below the subject folder was silently ignored. The docstring example was also off by one about which level is which.

  12. predictor._internal_maybe_mirror_and_predict ignored its network argument for 7 of its 8 test-time-augmentation terms (latent: network is self.network on every current path, but it defeats the purpose of loaded_networks).

  13. Smaller: duplicate entries in the formats table (T1w, T2w, T2star, sti twice); a duplicated neighbour in the boundary-mask flood fill; the folder README and all_output_reference both documented vert_geometry as structure_label=50 where the code uses 0.

  14. Two CI-only constraints surfaced while getting the matrix green, both now handled in this PR: dicom2nifti pulls python-gdcm, which publishes no cp39 wheel and fails to build from source, so the two DICOM dev deps are declared python = ">=3.10" and both DICOM test modules skip on 3.9 on their own guard; and mip_shot builds its frame with denoise_threshold set, which makes create_snapshot do a hard import torch (snapshot_modular.py:1168, for avg_pool3d) — so one of the shipped 2D snapshot templates requires torch even though torch is an optional extra. That is not obvious from the template or from the extras list.

Behaviour pinned rather than changed

These are documented in the tests at the point a reader will meet them, and left alone because changing them would move numeric output or a stitched field of view:

  • endplates._ray_cast_to_mesh is commented "closest hit" but returns origin + d.mean() * direction — for a convex mesh, the midpoint inside it rather than the surface point. Changing it moves every computed endplate point.
  • stitching.get_all_corner_points adds 1 to every voxel index before applying the affine, so the box it reports spans 1..shape+1. Every stitched output FOV depends on it.
  • calculate_pca_normal_np fits the PCA on raw voxel indices and then divides the resulting unit vector by zoom, so anisotropic voxels never change which axis comes out principal, the result is no longer a unit vector, and the division is the index-from-world direction rather than world-from-index.
  • normalize_image's v_range is described as a clip range but only rescales, and a constant image yields NaN.
  • np_erode_msk, np_erode_msk_euclid and np_dilate_msk_euclid modify the caller's array in place as well as returning it.
  • validate_entities registers unknown keys as a side effect of validating (entities_keys[key] = key) and get_values_from_name appends unknown formats to formats_relaxed, so the same filename can validate differently depending on what was parsed earlier in the process. The tests snapshot and restore those containers; the behaviour needs an API decision.
  • save_poi(save_hint=1) (Gruber) raises KeyError for any subregion outside its fixed landmark vocabulary; _is_Point3D raises TypeError instead of returning False for a non-container.

Coverage configuration

[tool.coverage.report].omit previously held one entry, and it matched nothing — TPTBox/test/speedtest/*, where the real directory is TPTBox/tests/speedtests/. It now lists, with a justification per group, only code a unit test cannot legitimately reach: the vendored nnU-Net inference internals (TPTBox's own inference_api.py around them is not omitted and is tested), the nnU-Net training drivers, two nipy-dependent scripts, the dead vendored deepali engine, the NAKO dataset pipelines, and one in-place repair script. Anything merely awkward to test got a test instead.

The dead deepali engine (registration/_deformable/_deepali/* plus deformable_reg_old.py, grid_search.py, _grid_search_vert.py) is a candidate for deletion rather than coverage: it imports an undeclared yaml, and nothing in the public API reaches it — those modules are referenced only by each other.

CI and dependencies

  • tests.yml gains build-full-coverage: one ubuntu / py3.11 job that installs CPU-only torch plus nnunetv2, acvl-utils, batchgenerators, antspyx and hf-deepali, then uploads to Codecov with flags: full (the existing matrix now uploads with flags: light). It prints the coverage number both with and without the omit list. spineps is deliberately not installed — it depends on TPTBox itself, so installing it would pin a released TPTBox over the checkout; the tests that touch it fake the module instead.
  • tests_mr.yml gains the same heavy-dependency set as a gate without upload, so a PR that breaks a mocked torch/deepali test fails before merge.
  • pydicom and dicom2nifti move into the dev group so the DICOM tests run in every job rather than only where the optional extra happens to be installed. rtree becomes a core dependency (see bug 7).

Notes for review

  • All new tests follow the existing house style: unittest.TestCase, self.subTest for tables, tempfile.TemporaryDirectory(), importlib.util.find_spec + @unittest.skipUnless for optional backends, no conftest.py.
  • Module-level mutable state that leaks between tests is snapshotted and restored: bids_constants.entities / entities_keys / formats_relaxed, and logger.indentation_level is asserted back to zero in tearDown.
  • Nothing new touches the network. auto_download is asserted not to reach it when the weights folder already exists.
  • Python 3.9 compatibility of every new file verified by compiling the tree with a real 3.9 interpreter; the light-dep surface also verified under numpy 1.26.

🤖 Generated with Claude Code

Hendrik-code and others added 22 commits October 19, 2026 13:39
…ge path

`pyproject.toml` declares `python = "^3.9 || ..."` and CI runs a 3.9 leg, but
two 3.10-only stdlib features had crept in:

- `spine/spinestats/curvature.py` imported `itertools.pairwise` at module
  scope, so the whole module raised ImportError on 3.9. Ruff did not catch it
  (`target-version = "py310"`), and `test_docs_examples.py` swallowed it
  because its `_missing_module_name` check only fails for names starting with
  "TPTBox". Added a `pairwise` backport next to the existing `zip_strict` in
  `core/compat.py` and switched curvature over.
- `zip(..., strict=...)` (3.10+, TypeError at call time on 3.9) in four
  places, one of them `measure_ivd_and_vertebra_geometry._segment_length`.
  Replaced with `zip_strict` where strictness was intended, plain `zip`
  otherwise.

Tests: `pairwise` behaviour plus two tree-wide guards that fail if either
pattern comes back. Both compat helpers verified against a real 3.9
interpreter.

Also fixes the coverage `omit` entry `TPTBox/test/speedtest/*`, which matched
nothing - the directory is `TPTBox/tests/speedtests/`.

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

Two defects found while writing tests for these modules. Both regression tests
were verified to fail against the unfixed code.

`stitching_tools._center_frontal` (active bug): the dorsal window is built as
`middle - 122 .. middle + 134`, and `middle` is only ~0.47*size, so the start
is negative for any volume below ~258 voxels (size=100 gave slice(-75, 181)).
numpy reads a negative start as an offset from the end, so `NAKO_stitch_T2w`
silently cropped the wrong side of any sub-258-voxel stitched volume. Now
clamped to [0, size]; the tuned window for full-size volumes is unchanged
(size=600 -> slice(163, 419), still 256 wide) and pinned by a test.

`predictor._internal_maybe_mirror_and_predict` (latent): the method takes a
`network` argument, uses it for the main forward pass, and then used
`self.network` for the seven mirrored passes. On every current code path
`network is self.network`, so no output was ever wrong - but the argument was
silently ignored, which defeats the purpose of `loaded_networks` (one network
per fold). Had that field ever held distinct per-fold objects, 7 of 8 TTA
terms would have come from the wrong fold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
unit_tests/test_bids_file.py covers this module through get_BIDS_test(), but all
four of its tests are gated on /media/robert/Expansion/dataset-Testset existing,
so they never run for anyone - which is why the BIDS parser the whole package
depends on sat at 39%. Two of them genuinely need JSON sidecars on disk, so
rather than un-gating them this adds a file that builds a real two-subject tree
(NIfTIs, sidecars, a POI json, a PNG) in a temp dir and drives scan -> query ->
family -> derived-path end to end, plus table-driven tests for the two pure
parsing primitives.

Three bugs surfaced and are fixed here, each with a regression test that was
verified to fail beforehand:

- Searchquery.filter_dixon_water/_fat/_outphase returned *nothing* whenever the
  files had JSON sidecars: `_keys` lists alternative spellings of one channel
  ("W"/"WATER"), but the ImageType filter required `all` of them while the
  entity filter next to it used `any`. No real ImageType carries both spellings.
- _scan_tree did not forward `filter_folder` to its recursive call, so any
  filter_folder targeting a level below the subject folder was silently ignored.
  The docstring example used `lvl != 2` for subject folders, which is also off
  by one - subject folders are level 1 under <dataset>/<parent>. Both fixed.
- `formats` carried copy-paste duplicates (T1w, T2w, T2star and sti twice).

Note for reviewers: validate_entities registers unknown keys as a side effect
(`entities_keys[key] = key`) and get_values_from_name appends unknown formats to
`formats_relaxed`, so the same filename can validate differently depending on
what was parsed earlier in the process. The tests snapshot and restore those
containers in place; the behaviour itself needs an API decision and is left
alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
String_Logger accumulates into .log_content, so most of this needs neither files
nor stdout capture: prefixing rules, the colored/plain buffer pair, the
indentation context manager, statistics accumulation and the sub-logger
flush/close propagation into a real file-backed Logger.

indentation_level is a module global shared by every logger instance, so each
test asserts it back to zero in tearDown.

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

curvature.py 11%->95%, pelvic_parameters.py 14%->100%, angles.py 31%->62%
(the remainder of angles.py is its three plotting entry points).

The sample data holds a single vertebra, so the spines are synthesised: bodies
stacked along the superior axis, each carrying its direction POIs and the two
endplate median points that MoveTo.TOP/BOTTOM resolve to, with per-vertebra
rotations about any axis. That makes every expected value analytic rather than
a recorded output:

- a circle of radius r has curvature exactly 1/r, asserted for several radii
  and sampling steps, in the image plane and in a randomly tilted 3D plane
  (which is what proves the 3D cross-product branch agrees with the 2D one)
- a 30 degree sagittal tilt of one vertebra gives a 30 degree P-direction
  angle; opposed 15 degree coronal tilts give a 30 degree Cobb angle with
  MoveTo.CENTER and 15 with the default endplate moves, because those average
  each normal with its neighbour
- tilting one vertebra by 10 degrees gives segmental endplate angles of
  exactly +10 and -10 in the two adjacent segments, and zero everywhere else
- pelvic incidence equals pelvic tilt plus sacral slope to 1e-9, is invariant
  under rotating all four landmarks in the sagittal plane (where PT and SS
  trade the rotation exactly), and is invariant under scaling - these pin the
  anatomical claims the docstring makes
- a pure arc of radius 200 gives a mean curvature profile of 1/200

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…->86%)

torso_vat_sat.py 4%->86%, body_quadrants.py 10%->96%.

Inputs are synthetic labelled volumes with known voxel counts and a deliberately
anisotropic zoom (2 x 2 x 5 mm), so every reported figure is analytic:

- volumes are voxel count x 20 mm^3, areas are per-slice voxel count x 4 mm^2
- VBQ is asserted as an exact 0.5 by setting the vertebral bodies to 200 and the
  spinal canal to 400, and the robust CSF estimator is shown to hold at the 400
  peak when part of the canal is darkened while the plain mean drops
- fat fraction is asserted against fat/(fat+water) for a water/fat pair
  constructed to a target FF, including the all-IMAT and all-lean sides of the
  0.20 threshold
- peak_centered_mean is pinned on every documented edge case, including
  peak_frac_height=0 reducing exactly to the plain mean
- tissue placed outside the ROI slab must not be counted, and the incomplete
  torso guards (missing clavicula / pelvis) return nan with a reason

make_quadrants needs real vertebra subregions to build its local axes, so it runs
on the shipped sample CT and asserts the 27 labels partition the body mask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
measure_ivd_and_vertebra_geometry.py 20%->82%, poi_fun/endplates.py 8%->40%.

The private helpers are pure geometry, so they are asserted against shapes with
closed-form answers: a 10-voxel cube meshes to a watertight surface spanning
exactly 10 mm whose |volume| is within 3% of 10^3 and whose projection along an
axis is twice the 10x10 face; a ray through it measures 10 mm; PCA returns an
orthonormal triple containing the box's long axis; Moeller-Trumbore hits the
centroid at exactly the plane distance and misses on the parallel, outside and
behind-origin cases; ray voxel sampling advances exactly one voxel per step and
never leaves the volume.

Three things found while writing this:

- `rtree` was undeclared. trimesh's ray engine needs it for its broad phase and
  only declares it under its own `easy` extra, so every mesh ray cast raised
  ModuleNotFoundError - i.e. measure_ivd_and_vertebra_geometry and the endplate
  code could not run in a clean install (and did not run in 2 of 3 local envs
  here). Added to the core dependencies; the tests that need it are guarded so
  the suite still passes in an environment that has not reinstalled yet.
- measure_ivd_and_vertebra_geometry's own documented default buffer_poi=None
  raised AssertionError: it asked the POI buffering decorator to write the
  result back unconditionally, and that decorator asserts the buffer path is
  not None. Now it only requests a write-back when there is a path to write to.
  The class-level call in the new tests uses the default, so this stays covered.
- The folder README and all_output_reference both documented vert_geometry as
  `structure_label=50`; the module docstring and _run_all use 0 (labels above 0
  and below 100). Docs corrected.

Reported, not changed: endplates._ray_cast_to_mesh is commented "closest hit"
but returns origin + d.mean() * direction, i.e. the mean over every hit along
the ray, which for a convex mesh is the midpoint *inside* it rather than the
surface point. Changing it would move every computed endplate point, so the
current behaviour is pinned by a test that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…61%)

dicom_extract.py 11%->61%, dicom2nii_utils.py 18%->60%, nii2dicom.py 20%->83%,
dicom_header_to_keys.py 7%->37%. Runs in under two seconds.

No DICOM sample ships with the repo, so the integration test makes one: the
sample CT is written out as a 73-slice DICOM series with nii2dicom and read back
with extract_dicom_folder. That pins the full naming contract end to end - the
output lands at rawdata/sub-TESTPAT/ct/sub-TESTPAT_sequ-7_acq-iso_ct.nii.gz,
where sequ-7 comes from SeriesNumber and acq-iso from the grid - plus the
array/zoom round-trip, the grid sidecar, the fact that the patient birth date is
converted to an age and never written to disk, the zipped-input path, and that a
second extraction is a no-op thanks to the .extract_cache marker.

The pure helpers are tested directly: letter-suffix collision avoidance
(a -> b -> ... -> z -> aa), BIDS path assembly including subject chunking and
dot sanitisation, ImageType token filtering, echo splitting, DICOM magic-byte
detection, json save/load with numpy scalars, and the age arithmetic on and
around a birthday.

pydicom and dicom2nifti move into the dev group so these tests run in every CI
job rather than only where the optional "dicom" extra happens to be installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…>80%/89%)

Both modules rewrite an already-extracted dataset and both offer dry_run, so the
plan is the assertion and the destructive path is then checked against it: the
tests build a small dataset of NIfTI + sidecar pairs in a temp dir, assert the
dry run moves nothing, apply it, and compare. Subject-map rebuilding is asserted
to be injective, idempotent across re-runs, and to preserve historic rows; the
sanitised entity values are round-tripped back through get_values_from_name so
they are provably parseable; _plane_from_grid is cross-checked against
get_plane_dicom on the same grids.

Two bugs found, both with a regression test that fails without the fix:

- xa_pairing.pair_biplane_xa moved data out of rawdata. It built the target
  path with bf.get_changed_bids(info={"sequ": ...}), and that method's `parent`
  defaults to "derivatives" - so applying the pairing relocated the B-side of
  every biplane pair into derivatives/ with an otherwise correct filename. The
  follow-up flip_z pass, which re-scans under `parent`, then no longer saw those
  files, so a pair ended up with one flipped and one unflipped affine. Now
  passes parent=bf.parent, and the test asserts every file stays in rawdata and
  that both planes get flipped.
- rerun_bids_naming's dry run over-reported. The leftover sweep (pass 2) scans
  the disk, and under dry_run the files pass 1 had only *planned* to move were
  still there, so each was reported a second time with a different fallback
  destination: 6 planned moves where the real run performs 4. The sweep now
  skips sources pass 1 already claimed, and the test asserts the dry run plans
  exactly what the real run does.

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

Fills in the methods the existing test_nii*.py files never reach, with inputs
small enough that the expected result can be written down: two seeds diffusing
from opposite ends of an 11-voxel corridor must split it by distance; a slab
truncated at a reference label must lose exactly the voxels above it; the centre
voxel of a cube must not appear among its surface points; a one-voxel hole must
be filled by majority voting; histogram matching must move a N(100,10) volume
onto a N(500,10) reference without touching the source; global_to_local_arr must
invert the affine and agree with the scalar version point by point.

Also covers the math mixin's untested half - mean/median/std against numpy,
clamp, nan_to_num, the three normalize variants, dice (1.0 for identical masks,
0.0 for disjoint ones), betti numbers, and the reflected, shift and in-place
operators - plus the Has_Grid accessors (dims, direction, orientation_ornt,
spacing, corners, obb quads, make_empty_nii, to_gird) and the first direct
construction of a bare Grid.

Bug found and fixed, with a regression test that fails without it: NII.to_stl
computed `out_path.with_name(f"{stem}_{label}.stl")` for `number_path=True` but
never assigned the result, so the numbering silently did nothing. Since
`to_stls` defaults `number_path` to True for a non-dict out_path, exporting a
multi-label segmentation wrote every label to the same file and only the last
label's mesh survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ray_casting.py 37%->84%, pixel_based_point_finder.py 44%->58% (measured
together with the existing tests that reach these modules indirectly).

Analytic expectations throughout: trilinear interpolation reproduces the linear
field 2x + 3y - z exactly at sub-voxel points and returns 0 outside the interior;
a ray from the centre of a box spanning indices 10..20 exits at 20.5; the two
convex ray casters agree with each other to sub-voxel tolerance along four
directions; on a box with a slab removed the stepping non-convex caster stops at
the near edge of the gap while the bisecting convex one reports the far wall
(which is the documented difference between them); a plane through three points
splits a volume into two strict sides that, with the plane slice itself, account
for every voxel; PCA on an elongated blob returns its long axis and orthogonal
components.

Recorded for the PR description, not changed: calculate_pca_normal_np fits the
PCA on raw voxel indices and then divides the resulting unit vector by "zoom".
So anisotropic voxels never change which axis comes out principal, the result is
no longer a unit vector, and the division is the index-from-world direction
rather than world-from-index. The test pins today's behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
save_load.py 60%->82%, poi_abstract.py 81%->92%, poi_global.py 75%->81%,
save_mkr.py 85%->86%, poi.py 74%->76% (measured together with the existing
unit_tests/test_poi*.py files).

test_poi_save.py already round-trips the current format; this covers the other
readers the loader still accepts and the parsers underneath them: the
colon-separated landmark TXT (group headers, point names, coordinate parsing,
and that blank/colonless lines are skipped), Slicer .mrk.json in both
directions, and all three save_hint formats. The header parser is pinned against
every example in its own docstring, including that "7.0" collapses to int.

For the descriptor: name resolution ("T12", "Vertebra_Corpus") to ids, the four
accepted key forms of unpack_poi_id (packed int, slice, int pair, enum pair),
get/pop/delitem/items_2d, nested-vs-flat input normalisation, the set operators
(+, >>, -, &), hand-computed point distances, and spline fitting through four
corpus points. apply_crop_reverse is asserted to be the exact inverse of
apply_crop, local_to_global_arr/global_to_local_arr to round-trip, and
POI_Global.to_cord_system to be an involution.

Two documented limitations are pinned rather than changed: save_hint=1 (the
Gruber format) raises KeyError for any subregion outside its fixed landmark
vocabulary, and _is_Point3D raises TypeError instead of returning False for a
non-container input.

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

stitching.py 69%->73%, stitching_tools.py 22%->43%, sitk_utils.py 55%->70%,
nii_help.py 56%->59%, slicer_nrrd.py 87%->88%, optional_deps.py 0%->97%.

The stitching geometry is asserted as invariants rather than recorded numbers:
for a diagonal, a double-flipped and a 13/-7/21-degree rotated anisotropic
affine, the decomposition must give a non-negative spacing equal to the column
norms, an orthonormal rotation, and rotation @ diag(spacing) == the original
3x3; feeding it back through get_ras_affine with the translation expressed in
the rotated frame must reproduce the affine exactly. The union grid must cover
both inputs' corners, take the finest input spacing, and honour min_spacing.
_auto_output_dtype is pinned on the Philips case its docstring is about (int16
storage plus a large scl_slope must fall back to float32), which needed a
file-backed fixture because the slope is read off the array proxy.

sitk_utils is covered by round-trips (NII -> sitk -> NII preserves array, affine
and orientation, for a reoriented input too) and by cross-checking the computed
metadata against what SimpleITK itself reports. Note the two metadata functions
do not share an argument order - the getter returns (origin, spacing, direction),
the builder takes (spacing, direction, origin) - which the test now documents.

optional_deps gets its first direct test: the stubs must raise on call or on
instantiation rather than at creation, and must name the TPTBox extra when one
provides the backend and the bare packages when none does (the spineps case).

Two developer-facing rough edges pinned as-is: stitching.get_all_corner_points
adds 1 to every voxel index before applying the affine, so the box it reports
spans 1..shape+1 (every stitched FOV depends on this, so it is reported rather
than changed), and stitching_tools._crop_borders logs a message for an unknown
chunk key and then raises KeyError on the very next line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
snapshot_modular.py 79%->86%, snapshot_templates.py 14%->32%.

The pure helpers are asserted directly: normalize_image is linear and maps to
[0, 1]; make_isotropic2d stretches the anisotropic axis and, in mask mode, must
not invent labels (nearest neighbour, which is the real regression risk when
rendering segmentations); div0 substitutes its fill for both array and scalar
divisions by zero; get_contrasting_stroke_color picks gray on black and black on
white; to_cdt maps an empty POI to None.

create_snapshot renders the shipped sample CT to a temp PNG under the Agg
backend, and the assertions are on the file: it exists, it is not blank (nonzero
pixel variance), crop changes the output size, several output paths are written
at once, None frames are skipped, and the same input renders byte-identically
twice - which is what would catch unseeded jitter in the overlay code. Four of
the snapshot_templates entry points are then smoke-rendered.

Two documented-behaviour mismatches are pinned rather than changed:
normalize_image's `v_range` is described as a clip range but only rescales (so
out-of-range input lands outside [0, 1]), and a constant image yields NaN
because min == max.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d >= 2

Covers the four functions that had no test at all (np_isin including invert,
np_calc_boundary_mask, np_voxel_connectivity_graph,
np_filter_connected_components_by_bbox_chain) plus the uncovered branches of
np_calc_convex_hull and np_erode_msk_euclid. np_isin is checked against np.isin
as the reference over random label arrays, and a 10-voxel cube eroded by 2 is
asserted to leave exactly a 6-voxel cube.

Bug found and fixed, with a regression test that fails without it:
np_calc_boundary_mask built its foreground mask with two in-place assignments,
`boundary[boundary > threshold] = 2` followed by
`boundary[boundary <= threshold] = 0`. The second line compares the sentinel 2
written by the first, so for any threshold >= 2 the foreground was erased again
and the function returned an empty mask. NII.boundary_mask(threshold) forwards
straight to it, so every realistic HU threshold was affected; only thresholds
below 2 happened to work. The mask is now computed before the sentinel is
written. Also dropped a duplicated (0, 1, 0) entry from the flood-fill
neighbourhood list (no behaviour change - visited voxels are marked before being
pushed).

Documented, not changed: np_erode_msk, np_erode_msk_euclid and
np_dilate_msk_euclid all modify the caller's array in place as well as returning
it, with or without use_crop. That is consistent across the family and the NII
wrappers rely on it, so a test now pins it where a reader will find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sts (12%->63%)

_rib_assign.py 12%->63%. This is the pure-numpy half of the rib sub-package - no
torch, no spineps - so the tests run everywhere.

Writing them showed the public entry point raises before returning, on two
separate lines:

- `matched = (rib_inst != 0) & (rib_inst != error_value)` combined two
  bool-backed NIIs, and NII.__and__ raises TypeError for a non-integer array.
  The comparison now happens on the arrays.
- `sem_seg.extract_label([Location.Rib_Left.value, Location.Rib_Right.value] == 1)`
  has its closing bracket one token too far left, so it evaluated
  `[63, 64] == 1` -> False -> extract_label(0), which trips the "zero label is
  the background" assertion. Bracket moved.

With both fixed the function runs to completion, merges Rib_Left / Rib_Right
into the semantic segmentation over exactly the rib mask's footprint, and leaves
the vertebra labels intact - all asserted. What is deliberately *not* asserted is
that the matching heuristic pairs every rib with a vertebra: on synthetic
geometry it does not, and those components come back marked with `error_value`,
which is the documented behaviour for unmatched ribs. Whether real data matches
better is untested here.

The helpers get exact expectations: _thoracic_like_labels keeps 7..20 and
appends 28, and _touching_surface is checked against its own default axis
weights (left-right contact counts 100x a superior-inferior contact), for
symmetry, and for scaling with the shared face area.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
point_registration.py 66%->88%. Rigid point registration needs only SimpleITK,
so unlike the rest of TPTBox/registration these run in every environment.

Expectations are known transforms and inverse properties rather than recorded
numbers: six landmarks related by an exact translation, or by an exact 12 degree
rotation about their centroid, must be recovered with a sub-0.001 mm residual
and map onto the fixed set to within 0.05 mm; transform_cord composed with
transform_cord_inverse must be the identity over 20 random coordinates;
transform_poi_inverse must undo transform_poi; save/load and get_dump/load_ must
reproduce the same affine, residuals and coordinate mapping.

The save/load round-trip is also run with *different* grids on the fixed and
moving side, which is the only arrangement in which a moving/fixed swap inside
load_() would be observable - a symmetric fixture cannot catch it.

One corrupted landmark is used to show that both `exclusion` and
`leave_worst_percent_out` restore the clean fit, and error_natural is pinned to
what it actually measures: the mean change in pairwise landmark distances, i.e.
non-rigidity. It stays at zero for any rigid motion however large, and only
grows once the point set is genuinely deformed (here, scaled).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
inference_nnunet.py 9%->51%, inference_api.py 12%->56%, vibeseg.py 24%->79%,
auto_download.py 27%->55%, _deface.py 13%->48%, spineps.py 13%->25%. No weights,
no GPU, no network.

The mock sits at the model boundary and nowhere else. For run_inference only
`predict_single_npy_array` is replaced, so the real code still builds the channel
axis, derives the properties dict, and converts the returned array back into an
NII - and the assertions are on exactly that: the result carries the input's
shape, affine and orientation, the planted labels survive, the predictor is
handed a 4-D array with one channel per input image, and the spacing it receives
is the input's own reversed (or the PIR one when reorient_PIR is set). For
run_inference_on_file, load_inf_model and run_inference are patched where they
are defined (the function imports them inside its own body), and the test then
checks the written file, the label mapping, the preserved grid, and that a second
call short-circuits unless override=True.

The pure parts need no mock: estimate_peak_ram_mb is checked against the formula
in its own docstring and for linear scaling; compute_cpu_chunks_for_ram is
checked to return a chunk count that actually fits the budget; _split_ranges is
checked to have read windows that overlap and write windows that tile the axis
exactly once; sliding_nd_slices reconstructs its input under the identity;
extract_vertebra_bodies_from_VibeSeg labels a six-body column exactly L5..T12;
_extend_mask grows only along the requested anatomical direction; deface_img
blanks exactly the masked voxels.

spineps.py is driven through patch.dict(sys.modules) - it imports spineps only
inside function bodies - so its dispatch is testable without torch, and
_run_spineps_all is checked with subprocess.run patched. auto_download is
asserted *not* to reach the network when the weights folder already exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
plot_compute_lordosis_and_kyphosis and plot_cobb_angle are asserted to return
exactly the same numbers as the bare computations and to render a PNG, under the
Agg backend.

Getting there needed the fixture to be right in two ways that are worth writing
down: the POI has to sit on the image's own grid (the renderers resample between
the two) and its spine has to run along the image's inferior axis, because the
snapshot code interpolates along the first coordinate axis. So the synthetic
spine is built in RAS, where the builder stacks along S, and then reoriented onto
the sample CT's PIR grid.

plot_cobb_and_lordosis_and_kyphosis stays uncovered: its resampling is singular
for a synthetic spine on the shipped single-vertebra sample, and a fixture
contrived to get past that would not be testing anything real.

Also guards the to_stl tests on numpy-stl being importable, matching how
test_mesh3D.py guards its optional backends - two of the three local environments
have a partial install and were erroring instead of skipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`[tool.coverage.report].omit` held exactly one entry and it matched nothing -
`TPTBox/test/speedtest/*`, where the real directory is `TPTBox/tests/speedtests/`
(fixed earlier in this branch). It now lists, with a justification per group,
only code a unit test cannot legitimately reach:

- the vendored nnU-Net inference internals (predictor, plans_handler,
  export_prediction, default_preprocessor, data_iterators,
  get_network_from_plans, sliding_window_prediction) and the nvidia-smi memory
  prober. TPTBox's own entry point around them, nnUnet_utils/inference_api.py,
  is deliberately NOT omitted and is tested with a mock at the model boundary.
- the nnU-Net training drivers, which need a populated nnUNet_raw tree and a GPU
- registration/script_ax2sag.py and _ridged_intensity/register.py, which import
  `nipy` - not a declared dependency and not installable on current Python
- the dead vendored deepali engine (_deformable/_deepali/* plus
  deformable_reg_old.py, grid_search.py, _grid_search_vert.py): it imports an
  undeclared `yaml` and nothing in the public API reaches it, those modules are
  referenced only by each other. A candidate for deletion rather than coverage.
- the NAKO dataset pipelines (interactive input() prompts, hard-coded /DATA/NAS
  roots, process pools over a private dataset, Excel reporting)
- core/dicom/fix_brocken.py, an in-place repair script

Anything merely awkward to test got a test instead, and both numbers are
reported so the two effects stay separable.

CI: tests.yml gains `build-full-coverage`, one ubuntu/py3.11 job that installs
CPU-only torch plus nnunetv2, acvl-utils, batchgenerators, antspyx and
hf-deepali, so the modules behind those imports stop being hard zeros on
Codecov. It prints the coverage number with and without the omit list and
uploads with `flags: full`; the existing matrix now uploads with `flags: light`
and stays the fast compatibility gate. tests_mr.yml gains the same dependency
set as a gate without upload, so a PR that breaks a mocked torch/deepali test
fails before merge rather than after. `spineps` is deliberately not installed
anywhere: it depends on TPTBox itself, so installing it would pin a released
TPTBox over the checkout.

CLAUDE.md: the Test Layout section claimed the generated test files are >20K LOC
(the largest is 970 lines). Replaced with what is actually true about them, the
house style for new tests, the Python 3.9 constraints, and the omit-list policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The filter_folder docstring note added earlier in this branch broke the blank
line after the Args section. Reflowed; no behaviour change.

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

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Benchmark comparison

Speed (wall time per call)

baseline fbb3d0f vs head fbb3d0f · python 3.11.17 · 5 repeats + 1 warmup

Showing the 5 most-changed measurements per case; the rest are collapsed. Rows with a baseline below 3 ms are tagged (noise) — runner jitter on those swamps any real change.

ct_3d — shape (73, 47, 73)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_dilate_msk 124.21 ±3.88 126.86 ±0.46 +2.1% 0.713
poi_calc_centroids_nocrop 4.08 ±0.09 4.14 ±0.05 +1.5% 0.546
nii_rescale_seg 3.05 ±0.05 3.01 ±0.05 -1.2% 0.265
nii_load_img 10.55 ±0.05 10.68 ±0.10 +1.2% 0.057
nii_fill_holes 4.39 ±0.10 4.36 ±0.01 -0.8% 0.245
… 31 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_rescale 39.53 ±0.26 39.26 ±0.13 -0.7% 0.543
nii_erode_msk 6.00 ±0.05 5.96 ±0.04 -0.6% 0.365
nii_save 11.42 ±0.10 11.44 ±0.12 +0.2% 0.528
nii_resample_from_to 41.41 ±0.32 41.32 ±0.10 -0.2% 0.163
nii_filter_connected_components 4.12 ±0.04 4.13 ±0.04 +0.2% 0.934
poi_calc_poi_from_subreg_vert 12.37 ±0.07 12.38 ±0.09 +0.1% 0.943
metric_voxels 250463.00 ±0.00 250463.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
metric_foreground_pct 14.51 ±0.00 14.51 ±0.00 +0.0% —
poi_map_labels 0.13 ±0.01 0.15 ±0.01 +13.8% (noise) 0.154
poi_save 0.33 ±0.03 0.38 ±0.01 +13.7% (noise) 0.226
nii_apply_crop 0.52 ±0.03 0.56 ±0.04 +8.3% (noise) 0.053
nii_load_seg 2.53 ±0.08 2.72 ±0.10 +7.8% (noise) 0.019
poi_to_global 0.16 ±0.02 0.15 ±0.01 -5.4% (noise) 0.971
nii_set_dtype 0.38 ±0.02 0.40 ±0.02 +4.8% (noise) 0.185
nii_reorient 0.65 ±0.02 0.68 ±0.02 +4.7% (noise) 0.059
nii_compute_crop 0.27 ±0.01 0.28 ±0.01 +4.6% (noise) 0.232
nii_get_array 0.03 ±0.00 0.03 ±0.00 +4.2% (noise) 0.297
poi_local_to_global_arr 0.27 ±0.01 0.26 ±0.01 -3.3% (noise) 0.994
poi_reorient 0.33 ±0.02 0.34 ±0.03 +2.9% (noise) 0.393
nii_unique 0.60 ±0.02 0.61 ±0.02 +2.6% (noise) 0.633
poi_calc_centroids 2.30 ±0.04 2.35 ±0.05 +2.3% (noise) 0.040
nii_pad_to 0.51 ±0.01 0.52 ±0.03 +1.9% (noise) 0.286
poi_load 0.89 ±0.02 0.91 ±0.02 +1.9% (noise) 0.043
nii_volumes 1.78 ±0.02 1.80 ±0.02 +1.3% (noise) 0.190
nii_get_connected_components 2.10 ±0.03 2.08 ±0.02 -1.0% (noise) 0.683
poi_rescale 0.16 ±0.01 0.16 ±0.01 -0.7% (noise) 0.838
nii_extract_label 0.32 ±0.05 0.32 ±0.02 +0.5% (noise) 0.829
nii_map_labels 0.90 ±0.03 0.90 ±0.02 +0.4% (noise) 0.936
poi_resample_from_to 0.55 ±0.02 0.55 ±0.03 -0.2% (noise) 0.749
nii_center_of_masses 1.67 ±0.01 1.67 ±0.02 -0.0% (noise) 0.604

ct_2d — shape (73, 47, 1)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_resample_from_to 7.37 ±0.08 7.47 ±0.09 +1.4% 0.312
nii_rescale 5.26 ±0.08 5.21 ±0.11 -0.9% 0.947
nii_dilate_msk 14.61 ±0.11 14.65 ±0.09 +0.3% 0.869
metric_voxels 3431.00 ±0.00 3431.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
… 30 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
metric_foreground_pct 37.60 ±0.00 37.60 ±0.00 +0.0% —
nii_volumes 0.28 ±0.02 0.26 ±0.01 -6.9% (noise) 0.192
nii_extract_label 0.39 ±0.01 0.41 ±0.01 +5.8% (noise) 0.160
poi_save 0.47 ±0.02 0.45 ±0.02 -4.7% (noise) 0.101
nii_center_of_masses 0.20 ±0.01 0.19 ±0.01 -4.3% (noise) 0.645
nii_load_img 1.38 ±0.03 1.44 ±0.05 +4.0% (noise) 0.139
nii_apply_crop 0.63 ±0.01 0.65 ±0.03 +3.9% (noise) 0.109
nii_unique 0.11 ±0.01 0.12 ±0.02 +3.8% (noise) 0.314
nii_map_labels 0.44 ±0.02 0.46 ±0.03 +3.8% (noise) 0.203
poi_calc_centroids 0.93 ±0.02 0.90 ±0.01 -3.6% (noise) 0.027
nii_get_array 0.01 ±0.00 0.01 ±0.01 +3.2% (noise) 0.627
poi_rescale 0.23 ±0.01 0.22 ±0.01 -3.1% (noise) 0.093
poi_load 0.98 ±0.03 1.01 ±0.04 +2.9% (noise) 0.390
poi_local_to_global_arr 0.31 ±0.01 0.31 ±0.01 -2.7% (noise) 0.098
nii_fill_holes 0.81 ±0.02 0.83 ±0.04 +2.6% (noise) 0.221
nii_set_dtype 0.38 ±0.01 0.37 ±0.03 -2.6% (noise) 0.946
poi_to_global 0.21 ±0.01 0.20 ±0.00 -2.5% (noise) 0.296
nii_filter_connected_components 0.82 ±0.03 0.80 ±0.03 -2.4% (noise) 0.337
nii_compute_crop 0.13 ±0.01 0.13 ±0.00 +2.3% (noise) 0.842
poi_map_labels 0.18 ±0.01 0.17 ±0.00 -2.1% (noise) 0.189
nii_erode_msk 0.94 ±0.03 0.96 ±0.02 +1.9% (noise) 0.288
nii_load_seg 1.59 ±0.04 1.62 ±0.03 +1.8% (noise) 0.106
nii_rescale_seg 0.73 ±0.03 0.72 ±0.02 -1.6% (noise) 0.828
nii_get_connected_components 0.59 ±0.03 0.60 ±0.02 +1.5% (noise) 0.203
nii_save 1.29 ±0.04 1.27 ±0.02 -1.3% (noise) 0.240
nii_reorient 0.81 ±0.04 0.80 ±0.03 -1.3% (noise) 0.386
poi_calc_centroids_nocrop 1.09 ±0.02 1.10 ±0.02 +1.3% (noise) 0.151
poi_reorient 0.42 ±0.02 0.41 ±0.01 -0.9% (noise) 0.971
poi_resample_from_to 0.68 ±0.02 0.69 ±0.04 +0.7% (noise) 0.258
nii_pad_to 0.53 ±0.01 0.53 ±0.02 -0.3% (noise) 0.862

mri_3d — shape (68, 52, 67)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_rescale_seg 3.08 ±0.07 3.26 ±0.06 +6.0% 0.011
nii_dilate_msk 160.34 ±0.70 164.30 ±0.72 +2.5% 0.000
nii_filter_connected_components 4.65 ±0.04 4.54 ±0.04 -2.4% 0.005
nii_load_img 8.69 ±0.08 8.53 ±0.04 -1.8% 0.011
nii_rescale 37.24 ±0.18 37.71 ±0.21 +1.3% 0.009
… 31 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_resample_from_to 39.27 ±0.15 39.70 ±0.27 +1.1% 0.039
nii_erode_msk 7.66 ±0.09 7.75 ±0.28 +1.1% 0.326
nii_fill_holes 5.31 ±0.04 5.27 ±0.05 -0.7% 0.523
poi_calc_poi_from_subreg_vert 11.76 ±0.06 11.74 ±0.08 -0.2% 0.998
poi_calc_centroids_nocrop 8.49 ±0.08 8.50 ±0.02 +0.1% 0.464
nii_save 9.15 ±0.02 9.16 ±0.03 +0.1% 0.913
metric_voxels 236912.00 ±0.00 236912.00 ±0.00 +0.0% —
metric_labels 8.00 ±0.00 8.00 ±0.00 +0.0% —
metric_foreground_pct 18.21 ±0.00 18.21 ±0.00 +0.0% —
poi_to_global 0.24 ±0.01 0.25 ±0.01 +5.7% (noise) 0.084
poi_save 0.51 ±0.02 0.54 ±0.02 +5.6% (noise) 0.208
nii_get_array 0.05 ±0.00 0.05 ±0.00 +5.0% (noise) 0.751
poi_resample_from_to 0.71 ±0.01 0.75 ±0.04 +4.9% (noise) 0.065
poi_local_to_global_arr 0.33 ±0.01 0.31 ±0.01 -4.2% (noise) 0.230
nii_load_seg 2.79 ±0.05 2.70 ±0.05 -3.2% (noise) 0.015
nii_apply_crop 0.68 ±0.01 0.66 ±0.04 -2.7% (noise) 0.621
nii_extract_label 0.44 ±0.02 0.43 ±0.02 -2.5% (noise) 0.771
poi_reorient 0.44 ±0.02 0.45 ±0.01 +2.2% (noise) 0.353
nii_set_dtype 0.49 ±0.00 0.50 ±0.01 +1.9% (noise) 0.048
poi_load 2.26 ±0.04 2.22 ±0.02 -1.7% (noise) 0.160
poi_map_labels 0.22 ±0.01 0.22 ±0.01 -1.3% (noise) 0.702
nii_unique 0.64 ±0.01 0.64 ±0.01 +1.1% (noise) 0.530
poi_calc_centroids 2.71 ±0.02 2.72 ±0.04 +0.7% (noise) 0.259
poi_rescale 0.26 ±0.01 0.26 ±0.01 -0.7% (noise) 0.951
nii_center_of_masses 1.96 ±0.02 1.97 ±0.01 +0.6% (noise) 0.909
nii_get_connected_components 2.14 ±0.04 2.15 ±0.02 +0.5% (noise) 0.738
nii_compute_crop 0.32 ±0.01 0.32 ±0.02 -0.3% (noise) 0.200
nii_reorient 0.85 ±0.04 0.86 ±0.03 +0.2% (noise) 0.568
nii_volumes 1.92 ±0.02 1.92 ±0.03 +0.2% (noise) 0.187
nii_pad_to 0.69 ±0.02 0.69 ±0.03 -0.1% (noise) 0.698
nii_map_labels 0.98 ±0.03 0.98 ±0.03 +0.0% (noise) 0.957

mri_2d — shape (68, 52, 1)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_dilate_msk 19.42 ±0.04 20.81 ±3.12 +7.2% 0.125
nii_resample_from_to 7.26 ±0.04 7.42 ±0.06 +2.2% 0.000
nii_rescale 5.30 ±0.04 5.29 ±0.05 -0.3% 0.858
metric_voxels 3536.00 ±0.00 3536.00 ±0.00 +0.0% —
metric_labels 7.00 ±0.00 7.00 ±0.00 +0.0% —
… 30 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
metric_foreground_pct 50.17 ±0.00 50.17 ±0.00 +0.0% —
nii_get_array 0.01 ±0.00 0.02 ±0.00 +22.3% (noise) 0.180
nii_rescale_seg 0.76 ±0.01 0.71 ±0.02 -7.2% (noise) 0.004
poi_local_to_global_arr 0.30 ±0.01 0.32 ±0.01 +5.6% (noise) 0.008
nii_get_connected_components 0.58 ±0.02 0.61 ±0.06 +5.3% (noise) 0.105
nii_filter_connected_components 0.81 ±0.03 0.85 ±0.02 +4.4% (noise) 0.024
nii_extract_label 0.39 ±0.02 0.41 ±0.03 +4.0% (noise) 0.418
nii_compute_crop 0.13 ±0.01 0.13 ±0.00 +3.5% (noise) 0.070
nii_reorient 0.79 ±0.02 0.81 ±0.01 +3.3% (noise) 0.006
nii_set_dtype 0.39 ±0.03 0.38 ±0.02 -2.9% (noise) 0.821
poi_resample_from_to 0.73 ±0.03 0.75 ±0.03 +2.2% (noise) 0.901
poi_map_labels 0.22 ±0.02 0.21 ±0.01 -2.0% (noise) 0.755
poi_calc_centroids_nocrop 1.39 ±0.02 1.42 ±0.05 +2.0% (noise) 0.207
nii_center_of_masses 0.41 ±0.01 0.40 ±0.00 -1.9% (noise) 0.415
nii_pad_to 0.52 ±0.01 0.53 ±0.04 +1.7% (noise) 0.808
poi_save 0.52 ±0.01 0.51 ±0.02 -1.5% (noise) 0.363
poi_load 1.99 ±0.12 2.02 ±0.05 +1.4% (noise) 0.937
nii_save 1.27 ±0.02 1.25 ±0.01 -1.4% (noise) 0.325
poi_reorient 0.46 ±0.02 0.47 ±0.01 +1.3% (noise) 0.274
nii_apply_crop 0.63 ±0.02 0.63 ±0.03 -1.3% (noise) 0.449
nii_unique 0.11 ±0.01 0.11 ±0.00 +1.3% (noise) 0.528
poi_rescale 0.25 ±0.01 0.25 ±0.01 +1.0% (noise) 0.101
nii_volumes 0.44 ±0.02 0.44 ±0.01 +0.9% (noise) 0.522
nii_load_img 1.42 ±0.02 1.40 ±0.03 -0.8% (noise) 0.793
nii_map_labels 0.47 ±0.02 0.46 ±0.01 -0.8% (noise) 0.963
nii_fill_holes 1.09 ±0.03 1.09 ±0.02 +0.6% (noise) 0.275
poi_to_global 0.24 ±0.01 0.24 ±0.01 +0.4% (noise) 0.751
nii_erode_msk 1.27 ±0.01 1.27 ±0.03 +0.3% (noise) 0.481
nii_load_seg 1.59 ±0.05 1.59 ±0.05 -0.2% (noise) 0.182
poi_calc_centroids 1.11 ±0.02 1.11 ±0.04 -0.1% (noise) 0.276

Gate: a measurement fails when the baseline is ≥ 1 ms, the median grows by ≥ 50%, and Welch's t-test gives p < 0.05. metric_* rows are context only.

Memory (peak RSS growth per call)

baseline fbb3d0f vs head fbb3d0f · python 3.11.17 · 5 repeats + 1 warmup · sampler proc-status, isolation fork · measurement floor ≈ 0.90 MiB

Showing the 5 most-changed measurements per case; the rest are collapsed. Rows with a baseline below 3 MiB are tagged (noise) — runner jitter on those swamps any real change.

ct_3d — shape (73, 47, 73)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
poi_resample_from_to 5.32 ±0.00 5.18 ±0.00 -2.7% —
nii_reorient 5.95 ±0.00 5.80 ±0.00 -2.4% —
nii_rescale 6.21 ±0.00 6.07 ±0.00 -2.3% —
nii_resample_from_to 6.34 ±0.00 6.19 ±0.00 -2.3% —
poi_local_to_global_arr 3.02 ±0.00 2.96 ±0.00 -2.1% —
… 31 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_unique 3.02 ±0.00 2.96 ±0.00 -2.1% —
nii_set_dtype 5.29 ±0.00 5.18 ±0.00 -2.0% —
poi_to_global 3.14 ±0.00 3.08 ±0.00 -2.0% —
nii_extract_label 5.41 ±0.00 5.30 ±0.00 -1.9% —
nii_pad_to 5.47 ±0.00 5.36 ±0.00 -1.9% —
nii_map_labels 5.54 ±0.00 5.43 ±0.00 -1.9% —
nii_center_of_masses 3.34 ±0.00 3.27 ±0.00 -1.9% —
nii_apply_crop 5.91 ±0.00 5.80 ±0.00 -1.8% —
poi_calc_centroids_nocrop 5.97 ±0.00 5.86 ±0.00 -1.8% —
nii_dilate_msk 5.97 ±0.00 5.87 ±0.00 -1.8% —
nii_rescale_seg 5.98 ±0.00 5.88 ±0.00 -1.8% —
nii_volumes 3.59 ±0.00 3.52 ±0.00 -1.7% —
poi_calc_centroids 6.10 ±0.00 5.99 ±0.00 -1.7% —
nii_save 3.77 ±0.00 3.71 ±0.00 -1.7% —
nii_load_seg 6.05 ±0.00 5.95 ±0.00 -1.6% —
nii_fill_holes 6.32 ±0.00 6.22 ±0.00 -1.6% —
poi_reorient 3.89 ±0.00 3.83 ±0.00 -1.6% —
nii_get_connected_components 6.66 ±0.00 6.55 ±0.00 -1.6% —
nii_erode_msk 5.98 ±0.00 5.88 ±0.00 -1.6% —
nii_filter_connected_components 6.91 ±0.00 6.80 ±0.00 -1.5% —
nii_load_img 3.62 ±0.00 3.57 ±0.00 -1.5% —
poi_calc_poi_from_subreg_vert 7.16 ±0.00 7.05 ±0.00 -1.4% 0.000
metric_voxels 250463.00 ±0.00 250463.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
metric_foreground_pct 14.51 ±0.00 14.51 ±0.00 +0.0% —
poi_save 1.46 ±0.00 1.40 ±0.00 -4.3% (noise) —
poi_load 1.68 ±0.00 1.61 ±0.00 -3.7% (noise) —
nii_get_array 1.71 ±0.00 1.65 ±0.00 -3.7% (noise) —
poi_map_labels 1.84 ±0.00 1.77 ±0.00 -3.4% (noise) —
poi_rescale 2.67 ±0.00 2.61 ±0.00 -2.3% (noise) —
nii_compute_crop 2.96 ±0.00 2.90 ±0.00 -2.1% (noise) —

ct_2d — shape (73, 47, 1)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
poi_resample_from_to 5.26 ±0.00 5.11 ±0.00 -2.7% —
nii_reorient 5.89 ±0.00 5.74 ±0.00 -2.5% —
nii_rescale 6.15 ±0.00 6.00 ±0.00 -2.4% —
nii_resample_from_to 6.27 ±0.00 6.13 ±0.00 -2.3% —
poi_to_global 3.08 ±0.00 3.02 ±0.00 -2.0% —
… 30 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_set_dtype 5.22 ±0.00 5.12 ±0.00 -2.0% —
nii_extract_label 5.35 ±0.00 5.24 ±0.00 -2.0% —
nii_pad_to 5.41 ±0.00 5.30 ±0.00 -2.0% —
nii_map_labels 5.47 ±0.00 5.37 ±0.00 -1.9% —
nii_center_of_masses 3.27 ±0.00 3.21 ±0.00 -1.9% —
nii_apply_crop 5.84 ±0.00 5.74 ±0.00 -1.8% —
nii_dilate_msk 5.91 ±0.00 5.80 ±0.00 -1.8% —
nii_rescale_seg 5.92 ±0.00 5.82 ±0.00 -1.8% —
nii_volumes 3.52 ±0.00 3.46 ±0.00 -1.8% —
poi_calc_centroids_nocrop 5.97 ±0.00 5.86 ±0.00 -1.8% —
poi_calc_centroids 6.04 ±0.00 5.93 ±0.00 -1.7% —
nii_save 3.71 ±0.00 3.64 ±0.00 -1.7% —
nii_load_seg 5.98 ±0.00 5.89 ±0.00 -1.6% —
poi_reorient 3.83 ±0.00 3.77 ±0.00 -1.6% —
nii_fill_holes 6.26 ±0.00 6.16 ±0.00 -1.6% —
nii_get_connected_components 6.60 ±0.00 6.49 ±0.00 -1.6% —
nii_erode_msk 5.91 ±0.00 5.82 ±0.00 -1.6% —
nii_filter_connected_components 6.85 ±0.00 6.74 ±0.00 -1.5% —
nii_load_img 3.56 ±0.00 3.50 ±0.00 -1.5% —
metric_voxels 3431.00 ±0.00 3431.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
metric_foreground_pct 37.60 ±0.00 37.60 ±0.00 +0.0% —
poi_save 1.40 ±0.00 1.34 ±0.00 -4.5% (noise) —
poi_load 1.61 ±0.00 1.55 ±0.00 -3.9% (noise) —
nii_get_array 1.65 ±0.00 1.59 ±0.00 -3.8% (noise) —
poi_map_labels 1.77 ±0.00 1.71 ±0.00 -3.5% (noise) —
poi_rescale 2.61 ±0.00 2.54 ±0.00 -2.4% (noise) —
nii_compute_crop 2.90 ±0.00 2.84 ±0.00 -2.2% (noise) —
poi_local_to_global_arr 2.96 ±0.00 2.89 ±0.00 -2.1% (noise) —
nii_unique 2.96 ±0.00 2.90 ±0.00 -2.1% (noise) —

mri_3d — shape (68, 52, 67)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
poi_resample_from_to 5.26 ±0.00 5.11 ±0.00 -2.7% —
nii_reorient 5.89 ±0.00 5.74 ±0.00 -2.5% —
nii_rescale 6.15 ±0.00 6.00 ±0.00 -2.4% —
nii_resample_from_to 6.27 ±0.00 6.13 ±0.00 -2.3% —
poi_to_global 3.09 ±0.00 3.02 ±0.00 -2.0% —
… 31 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_set_dtype 5.29 ±0.00 5.18 ±0.00 -2.0% —
nii_extract_label 5.35 ±0.00 5.24 ±0.00 -2.0% —
nii_pad_to 5.41 ±0.00 5.30 ±0.00 -2.0% —
nii_map_labels 5.47 ±0.00 5.37 ±0.00 -1.9% —
nii_center_of_masses 3.27 ±0.00 3.21 ±0.00 -1.9% —
nii_apply_crop 5.84 ±0.00 5.74 ±0.00 -1.8% —
poi_calc_centroids_nocrop 5.91 ±0.00 5.80 ±0.00 -1.8% —
nii_dilate_msk 5.91 ±0.00 5.80 ±0.00 -1.8% —
nii_rescale_seg 5.92 ±0.00 5.82 ±0.00 -1.8% —
nii_volumes 3.52 ±0.00 3.46 ±0.00 -1.8% —
poi_calc_centroids 6.04 ±0.00 5.93 ±0.00 -1.7% —
nii_save 3.71 ±0.00 3.64 ±0.00 -1.7% —
nii_load_seg 5.98 ±0.00 5.89 ±0.00 -1.6% —
poi_reorient 3.83 ±0.00 3.77 ±0.00 -1.6% —
nii_fill_holes 6.26 ±0.00 6.16 ±0.00 -1.6% —
nii_get_connected_components 6.60 ±0.00 6.49 ±0.00 -1.6% —
nii_erode_msk 5.91 ±0.00 5.82 ±0.00 -1.6% —
nii_filter_connected_components 6.85 ±0.00 6.74 ±0.00 -1.5% —
nii_load_img 3.62 ±0.00 3.57 ±0.00 -1.5% —
poi_calc_poi_from_subreg_vert 7.15 ±0.00 7.05 ±0.00 -1.5% —
metric_voxels 236912.00 ±0.00 236912.00 ±0.00 +0.0% —
metric_labels 8.00 ±0.00 8.00 ±0.00 +0.0% —
metric_foreground_pct 18.21 ±0.00 18.21 ±0.00 +0.0% —
poi_save 1.46 ±0.00 1.40 ±0.00 -4.3% (noise) —
poi_load 1.61 ±0.00 1.55 ±0.00 -3.9% (noise) —
nii_get_array 1.65 ±0.00 1.59 ±0.00 -3.8% (noise) —
poi_map_labels 1.77 ±0.00 1.71 ±0.00 -3.5% (noise) —
poi_rescale 2.61 ±0.00 2.54 ±0.00 -2.4% (noise) —
nii_compute_crop 2.90 ±0.00 2.84 ±0.00 -2.2% (noise) —
poi_local_to_global_arr 2.96 ±0.00 2.89 ±0.00 -2.1% (noise) —
nii_unique 2.96 ±0.00 2.90 ±0.00 -2.1% (noise) —

mri_2d — shape (68, 52, 1)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
poi_resample_from_to 5.26 ±0.00 5.11 ±0.00 -2.7% —
nii_reorient 5.89 ±0.00 5.74 ±0.00 -2.5% —
nii_rescale 6.15 ±0.00 6.00 ±0.00 -2.4% —
nii_resample_from_to 6.27 ±0.00 6.13 ±0.00 -2.3% —
poi_to_global 3.08 ±0.00 3.02 ±0.00 -2.0% —
… 30 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_set_dtype 5.29 ±0.00 5.18 ±0.00 -2.0% —
nii_extract_label 5.35 ±0.00 5.24 ±0.00 -2.0% —
nii_pad_to 5.41 ±0.00 5.30 ±0.00 -2.0% —
nii_map_labels 5.47 ±0.00 5.37 ±0.00 -1.9% —
nii_center_of_masses 3.27 ±0.00 3.21 ±0.00 -1.9% —
nii_apply_crop 5.84 ±0.00 5.74 ±0.00 -1.8% —
nii_dilate_msk 5.91 ±0.00 5.80 ±0.00 -1.8% —
nii_rescale_seg 5.92 ±0.00 5.82 ±0.00 -1.8% —
nii_volumes 3.52 ±0.00 3.46 ±0.00 -1.8% —
poi_calc_centroids_nocrop 5.97 ±0.00 5.86 ±0.00 -1.8% —
poi_calc_centroids 6.04 ±0.00 5.93 ±0.00 -1.7% —
nii_save 3.71 ±0.00 3.64 ±0.00 -1.7% —
nii_load_seg 5.98 ±0.00 5.89 ±0.00 -1.6% —
poi_reorient 3.83 ±0.00 3.77 ±0.00 -1.6% —
nii_fill_holes 6.26 ±0.00 6.16 ±0.00 -1.6% —
nii_get_connected_components 6.60 ±0.00 6.49 ±0.00 -1.6% —
nii_erode_msk 5.91 ±0.00 5.82 ±0.00 -1.6% —
nii_filter_connected_components 6.85 ±0.00 6.74 ±0.00 -1.5% —
nii_load_img 3.62 ±0.00 3.57 ±0.00 -1.5% —
metric_voxels 3536.00 ±0.00 3536.00 ±0.00 +0.0% —
metric_labels 7.00 ±0.00 7.00 ±0.00 +0.0% —
metric_foreground_pct 50.17 ±0.00 50.17 ±0.00 +0.0% —
poi_save 1.46 ±0.00 1.40 ±0.00 -4.3% (noise) —
poi_load 1.61 ±0.00 1.55 ±0.00 -3.9% (noise) —
nii_get_array 1.65 ±0.00 1.59 ±0.00 -3.8% (noise) —
poi_map_labels 1.77 ±0.00 1.71 ±0.00 -3.5% (noise) —
poi_rescale 2.61 ±0.00 2.54 ±0.00 -2.4% (noise) —
nii_compute_crop 2.90 ±0.00 2.84 ±0.00 -2.2% (noise) —
poi_local_to_global_arr 2.96 ±0.00 2.89 ±0.00 -2.1% (noise) —
nii_unique 2.96 ±0.00 2.90 ±0.00 -2.1% (noise) —

Gate: a measurement fails when the baseline is ≥ 1 MiB, the median grows by ≥ 50%, and Welch's t-test gives p < 0.05. metric_* rows are context only.

Hendrik-code and others added 3 commits October 19, 2026 15:31
The 3.9 matrix leg failed in `poetry install`: dicom2nifti pulls python-gdcm,
which publishes no cp39 wheel, so pip tried to build 3.2.6 from source and the
CMake step failed.

Both DICOM test modules already guard on pydicom + dicom2nifti being importable,
so on 3.9 they skip and the rest of the suite is unaffected. 3.10 upwards - and
in particular the full-coverage job on 3.11 - still install them and run the
tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 3.10 matrix leg reported 1 failed / 1240 passed: `mip_shot` builds its frame
with `denoise_threshold` set, and `create_snapshot` does a hard `import torch`
for that path (snapshot_modular.py:1168, for avg_pool3d). torch is an optional
extra, so the light matrix cannot run it.

Guarded with the reason spelled out. Worth noting independently: one of the
shipped 2D snapshot templates therefore requires torch, which is not obvious
from either the template or the optional-extra list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 3.9 leg never reached the tests - it failed in `poetry install`:

  CMake Error: The source directory ".../python_gdcm-3.2.6" does not
  appear to contain CMakeLists.txt

Two upstream metadata problems, neither of them ours:

* python-gdcm stopped shipping cp39 wheels after 3.2.2 but still
  advertises `requires-python = ">=3.7"`, so the resolver picks 3.2.6 on
  3.9 and falls back to an sdist that cannot build.
* dicom2nifti 2.6.0 imports `pydicom.pixels` (pydicom >= 3.0) while
  declaring only `pydicom >= 2.2`, and pydicom 3 requires python >= 3.10.

Cap both for `python < 3.10` instead of trying to keep them off 3.9 with
a marker. The `python = ">=3.10"` markers added in 5e284e3 cannot work:
poetry 2.2 merges a group declaration with the unmarked optional one in
`[tool.poetry.dependencies]` into a marker that still mentions
`extra == "dicom"` and installs the package on 3.9 regardless (poetry
2.4 simplifies it correctly, so the breakage depends on which poetry
`pip install poetry` happens to fetch). nnunetv2 2.4.2, pinned for 3.9,
hard-depends on dicom2nifti anyway, so the 3.9 region has to resolve
whatever the markers say. Capping also makes `pip install
"TPTBox[dicom]"` work on 3.9, which it previously did not.

With the dev markers gone the two DICOM test modules now run on every
leg instead of skipping on 3.9.

Verified on a provisioned CPython 3.9.25 (the version CI uses):
`poetry install --with dev` succeeds wheel-only with pydicom 2.4.4 /
dicom2nifti 2.5.1 / python-gdcm 3.2.2, and `pytest unit_tests/` gives
1240 passed, 76 skipped. Re-locked under poetry 2.2.1 and 2.4.1 with the
same result; the `>=3.10` region still resolves to pydicom 3.0.2 /
dicom2nifti 2.6.2 / python-gdcm 3.2.6, so the green legs are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Hendrik-code
Hendrik-code merged commit ce029af into main Oct 21, 2026
7 checks passed
@Hendrik-code
Hendrik-code deleted the coverage-85 branch October 21, 2026 16:08
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.

2 participants