Bugfinder - #125
Conversation
Four core operations failed or returned wrong results on ordinary inputs: - NII.rescale(scalar) built a generator, so the following len() raised TypeError. Every scalar-spacing call was broken. - NII.threshold() set arr[arr2>=t]=1 then arr[arr2<=t]=0, so the second write erased the == case; thresholding a binary mask at 1 returned an all-zero image. Rewritten as a single comparison, which also drops a redundant full-volume copy. - NII.normalize() divided by max_out instead of scaling into the target range and offset by min_out/max_out, so normalize(0,255) produced values in [0,1] and tripped its own assert (and max_out=0 raised ZeroDivisionError). Now scales properly, handles constant images, and uses np.isclose instead of exact float equality. - np_dilate_msk(mask=..., use_crop=True) cropped `mask` with the global crop but indexed it against the per-label crop, raising IndexError for any multi-label segmentation - i.e. NII.dilate_msk(mask=...) on its default path. - np_dilate_msk_euclid(labels=...) applied the label filter only when use_crop=False; the default path dilated every label. Both dilate helpers also stopped binarising the caller's mask array in place. Additionally corrects three early-return branches that had `inplace` inverted (nii_wrapper.py:811, 1127, 1140, 2700), so an in-place call no longer returns a different object and an out-of-place call no longer returns an alias of self. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…apped
Three float->int guards used isinstance() against numpy scalar types:
isinstance(self.dtype, np.floating) # a np.dtype object, never a scalar
isinstance(arr, np.floating) # an ndarray, never a scalar
Both are always False, so NII.__init__, NII.set_array and NII.save never
downcast float segmentations. A float64 segmentation therefore stayed
float64 for its whole lifetime - 8x the memory of the uint8 it should be,
through every get_seg_array() copy and onto disk - and it defeated the
unsigned-int fast paths in np_utils. Replaced with np.issubdtype(...).
set_dtype("smallest_int"/"smallest_uint") chose the target type from
arr.max() alone. Since the cast uses casting="unsafe", a negative minimum
wrapped silently (-1 -> 255), and "smallest_uint" fell back to the *signed*
np.int32. Dtype selection is now factored into _smallest_int_dtype(), which
considers both bounds, asserts non-negativity for unsigned targets, and
extends to 32/64-bit.
np_map_labels built its lookup table with dtype=arr.dtype, so any mapping
target outside the input dtype's range wrapped without warning - on a uint8
mask, 1 -> 300 produced 44 and 1 -> -5 produced 251. The table dtype is now
derived from the mapping targets as well; in-range mappings keep the input
dtype as before.
NII.save no longer mutates self via set_dtype_ to achieve its cast, and no
longer re-copies the whole volume afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…der check np_bbox_binary clamped the stop index to the array shape and only then added 1, so a bounding box touching the far border produced stop == shape + 1. Slicing tolerates that, but every consumer computing stop - start (np_center_of_bbox_binary, NII.compute_crop, is_segmentation_in_border) saw a size one voxel too large. It also stored px_dist in a uint8 array, so any px_dist > 255 raised OverflowError under NumPy 2. Interior boxes are unaffected. np_unique / np_unique_withoutzero are annotated -> list[int] but returned three different scalar types depending on the input dtype and which of the four code paths was taken: Python int from bincount, np.int64 from the withoutzero fast path, and np.float32/np.int16 from the np.unique fallbacks. The numpy scalars are not JSON-serializable, so serializing label lists failed for non-uint inputs. All paths now go through .tolist(), which yields native scalars without truncating float values. is_segmentation_in_border() guarded on `slices is None`, but compute_crop(raise_error=False) returns full-extent slices for an empty mask and never None, so an empty segmentation was reported as touching the border. Now short-circuits on the existing NII.is_empty property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sag_cor_curve_projection did:
order = v_idx_order
order += [i for i in range(256) if i not in v_idx_order]
`order` aliases the module-level v_idx_order list (vert_constants.py), which
is re-exported as TPTBox.v_idx_order, and `+=` extends a list in place. So
rendering a single snapshot permanently grew the shared global from 105 to
256 entries, affecting anything else that reads it. `order` was never used
afterwards, so both lines are dead - removed, along with the now-unused
import.
POI._vert_orientation_pir was a bare class attribute, i.e. one dict shared by
every POI instance in the process. get_vert_direction_PIR compounded this by
writing the cache onto a temporary extract_subregion() copy, so a lookup
could return vertebra directions computed for a different subject processed
earlier. It is now a per-instance dataclass field (default_factory=dict,
excluded from repr/compare, so a copy still starts empty as documented), and
the cache is written to the object the function was called with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `from TPTBox import *` raised AttributeError: __all__ advertised load_poi
but nothing imported it. Now re-exported from core.poi_fun.save_load.
- nii[0:5] delegated to a key containing Ellipsis, which __getitem__ itself
rejects two branches earlier, and dropped the `return`. It now pads the
trailing dimensions with full slices.
- extract_label("12") / remove_labels("12"): str is a Sequence, so the str
branch was unreachable and the label was iterated character by character
(IndexError). The str check now precedes the Sequence check.
- ssim()/psnr() normalised via in-place `img_1 /= img_1.max()`, which raises
UFuncTypeError on integer images. The neighbouring img_2 already used the
out-of-place form; both now match.
- sitk_utils.transform_centroid called the non-existent NII.get_empty_POI;
the method is make_empty_POI (used correctly at eight other call sites).
Also drops a duplicated assignment in the deformable branch.
- stitching_tools.n4_bias passed dilate_msk_(mm=3), which is not a parameter
of that method -> TypeError on every call. Now n_pixel=3.
- inference_nnunet forwarded stacklevel= into the logger, which passes
**qargs to print() -> TypeError whenever the input affine is the identity.
- poi_global.to_other tested `isinstance(ref, Self)`; Self is a typing
special form and isinstance() against it raises. Now checks POI_Global,
and the method no longer falls off the end returning None.
- poi_global.__init__ gated level_two_info on level_one_info (copy-paste),
so passing only level_two_info silently dropped it.
- calc_centroids took type(stage) after unwrapping the enum to .value, so
level_one_info/level_two_info were always int and the saved POI header
recorded "int" instead of the enum class.
- BIDS auto_add_run_id did info["run"] += 1 on a value that must remain a
decimal string for validate_entities().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
None of these raised; they just computed the wrong thing.
vert_constants:
- Full_Body_Instance_Vibe.get_Full_Body_Instance_mapping() referenced
cls.hip_left/hip_right, which do not exist on that enum (they are
pelvis_left/pelvis_right, 50/51, matching the inverse map) -> the whole
classmethod raised AttributeError.
- The same dict literal had duplicate keys: lung_left twice, lung_right three
times, channel twice. Only the last of each survived, so the shadowed
entries were dead. Removed them, keeping the previously-winning target so
behaviour is unchanged, with a note that one FBI lung label cannot address
several Vibe lobes.
- Abstract_lvl._get_id passed `cls` positionally into a classmethod, shifting
the arguments and raising TypeError on every lookup - swallowed by a bare
`except Exception`. Name resolution therefore never worked:
Any._get_id("L1") fell through to int("L1"). Now resolves to 20.
np_utils:
- np_filter_connected_components compared a list[tuple] to an int, so that
shortcut branch was unreachable. NOTE: fixing this to len(...) changes
which components are preserved when the count equals largest_k_components;
worth a domain review.
- The background_threshold in the label-smoothing helper was applied to the
argmax *indices* rather than the winning confidence, so it removed labels
by index rather than by probability.
Other:
- vertebra_direction dropped the result of
set_array().reorient().rescale_(), then read the array back off the
un-reoriented object, writing the fill-back image in the wrong orientation
and spacing. Mirrors the correct chained form in calc_center_spinal_cord.
- SegmentationMesh discarded `int_arr.astype(np.uint16)`, so the float->int
conversion it announces never happened.
- snapshot_modular: cmap(color - 1 % LABEL_MAX % cmap.N) parses as color - 1,
losing the wrap-around; parenthesised to match the correct site above it.
- angles: `if (vert, 50) not in poi: cord = poi[vert, 50]` was inverted, so
no lordosis/kyphosis label was ever emitted.
- body_quadrants requested Vertebra_Direction_Inferior twice but reads
Vertebra_Direction_Right; the KeyError was swallowed, so every vertebra was
skipped and an all-zero image returned.
- inference_nnunet built its label mapping with the raw string key instead of
the parsed int.
- predictor accumulated the chunk bounding-box max from self.min_s.
- deepali_model gated source_seg on fixed_seg and target_seg on moving_seg.
- point_registration.load_ assigned the dumped (moving, fixed) pair to
(_img_fixed, _img_moving), so reloaded registrations mapped the wrong way.
- save_mkr passed split_by_region=split_by_subregion, so with the documented
defaults neither branch was taken and markers got random colours.
- ray_casting negated z instead of flipping the half-space inequality.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…copies
Import cost: nii_wrapper_math imported peak_signal_noise_ratio and
structural_similarity from skimage.metrics at module level, but they are used
only inside NII.ssim and NII.psnr. That import pulls in scipy.stats, which
accounted for ~35% of `import TPTBox`. Moved both into their methods,
following the existing skimage.exposure precedent in nii_wrapper.py.
import TPTBox 948 ms -> 613 ms (scipy.stats no longer loaded at all)
Memory:
- vertebra_direction allocated `subreg_iso.get_array() * 0` once per vertebra:
get_array() copies the whole volume and `* 0` allocates a second one. Now a
single np.zeros of the same shape and dtype. Two sites.
- set_dtype called get_array() (a full copy) twice on the smallest_int/uint
path; it is now fetched at most once.
Resource leaks:
- _help.py created a figure that was never closed - one leaked figure per call.
- snapshot_modular used a bare plt.close(), which closes the *current* figure
rather than the one just saved, and was skipped entirely if savefig raised.
Both now close their own figure in a finally block.
Encoding: the Logger opened its .log with the platform default encoding, so
print_statistic's "±" raised UnicodeEncodeError under a C/POSIX locale
(Docker, cron, CI). Its logs directory is also created with
parents=True, exist_ok=True, which parallel jobs were racing on. The same
missing encoding= is fixed for the POI, BIDS-sidecar and DICOM JSON readers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TPTBox/registration/ridged_intensity/ was meant to be renamed to _ridged_intensity/ in e919218 ("rename folder to _ to show that this is not the recommended way to import things"), but the old directory stayed tracked. Both copies then received the docstring pass in 81db55a, while only the underscore copy received 1b8e0fb ("fix bug for very elongated segmentations") - so the duplicate still carries the pre-fix `w = max(target.shape[2:])` and the un-scaled delta comparison. Nothing imports it and its __init__.py is empty, so it is removed, along with the stale __pycache__-only leftovers at registration/{deepali,deformable,ridged_points}/. The optional-dependency guards in registration/__init__.py and _deepali/__init__.py caught bare `Exception`, which makes a real NameError or AttributeError inside those modules indistinguishable from "torch is not installed" - the symbol just silently disappears from the public API. That is how the stale duplicate above, and the NameError in deepali_trainer, went unnoticed. Narrowed to ImportError; all five exported names still resolve. Also drops the duplicate MODES definition in nii_wrapper.py, which shadowed the identical import 13 lines above it, and two leftover debug prints - one in POI buffer loading, one emitted once per bisection step per point from ray_casting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Benchmark comparisonSpeed (wall time per call)baseline Showing the 5 most-changed measurements per case; the rest are collapsed. Rows with a baseline below 3 ms are tagged
|
| Measurement | baseline ms (median ±½·range) | head ms (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_fill_holes |
4.60 ±0.04 | 4.32 ±0.03 | -6.2% | 0.000 |
poi_calc_centroids_nocrop |
4.38 ±0.09 | 4.15 ±0.18 | -5.3% | 0.099 |
nii_erode_msk |
6.31 ±0.07 | 5.99 ±0.03 | -5.1% | 0.000 |
nii_load_img |
10.74 ±0.08 | 10.50 ±0.06 | -2.2% | 0.001 |
nii_rescale |
40.21 ±0.28 | 39.35 ±0.08 | -2.1% | 0.001 |
… 31 more measurements
| Measurement | baseline ms (median ±½·range) | head ms (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_filter_connected_components |
4.21 ±0.05 | 4.13 ±0.03 | -1.9% | 0.021 |
nii_save |
11.51 ±0.16 | 11.29 ±0.05 | -1.8% | 0.025 |
nii_rescale_seg |
3.05 ±0.08 | 3.02 ±0.02 | -0.9% | 0.229 |
poi_calc_poi_from_subreg_vert |
12.37 ±0.19 | 12.48 ±0.29 | +0.8% | 0.298 |
nii_resample_from_to |
42.15 ±0.26 | 41.80 ±1.61 | -0.8% | 0.519 |
nii_dilate_msk |
124.86 ±0.14 | 124.59 ±0.22 | -0.2% | 0.119 |
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_load |
0.21 ±0.01 | 0.92 ±0.03 | +341.5% (noise) | 0.000 |
nii_extract_label |
0.73 ±0.02 | 0.31 ±0.03 | -57.9% (noise) | 0.000 |
nii_get_array |
0.04 ±0.01 | 0.03 ±0.00 | -29.2% (noise) | 0.046 |
poi_rescale |
0.19 ±0.02 | 0.17 ±0.01 | -12.0% (noise) | 0.040 |
poi_to_global |
0.17 ±0.01 | 0.15 ±0.01 | -10.7% (noise) | 0.029 |
nii_apply_crop |
0.58 ±0.03 | 0.52 ±0.02 | -10.6% (noise) | 0.006 |
nii_pad_to |
0.56 ±0.03 | 0.50 ±0.04 | -9.8% (noise) | 0.092 |
poi_local_to_global_arr |
0.29 ±0.02 | 0.27 ±0.02 | -8.6% (noise) | 0.043 |
nii_map_labels |
0.95 ±0.04 | 0.88 ±0.02 | -7.9% (noise) | 0.005 |
nii_unique |
0.64 ±0.02 | 0.59 ±0.01 | -7.8% (noise) | 0.005 |
nii_compute_crop |
0.29 ±0.02 | 0.27 ±0.02 | -6.8% (noise) | 0.279 |
nii_set_dtype |
0.39 ±0.04 | 0.36 ±0.01 | -6.4% (noise) | 0.082 |
nii_get_connected_components |
2.26 ±0.11 | 2.13 ±0.09 | -5.9% (noise) | 0.060 |
poi_map_labels |
0.15 ±0.01 | 0.14 ±0.01 | -5.4% (noise) | 0.212 |
nii_load_seg |
2.67 ±0.05 | 2.55 ±0.02 | -4.8% (noise) | 0.001 |
nii_reorient |
0.69 ±0.02 | 0.67 ±0.07 | -3.1% (noise) | 0.659 |
poi_reorient |
0.33 ±0.02 | 0.32 ±0.01 | -2.6% (noise) | 0.110 |
poi_calc_centroids |
2.33 ±0.03 | 2.28 ±0.06 | -2.4% (noise) | 0.728 |
poi_resample_from_to |
0.60 ±0.02 | 0.58 ±0.04 | -2.2% (noise) | 0.878 |
poi_save |
0.36 ±0.04 | 0.37 ±0.04 | +2.1% (noise) | 0.684 |
nii_center_of_masses |
1.69 ±0.03 | 1.66 ±0.01 | -1.7% (noise) | 0.065 |
nii_volumes |
1.80 ±0.04 | 1.79 ±0.02 | -0.2% (noise) | 0.202 |
ct_2d — shape (73, 47, 1)
| Measurement | baseline ms (median ±½·range) | head ms (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_rescale |
5.50 ±0.06 | 5.17 ±0.05 | -6.0% | 0.000 |
nii_resample_from_to |
7.49 ±0.06 | 7.23 ±0.08 | -3.4% | 0.002 |
nii_dilate_msk |
14.74 ±0.17 | 14.66 ±0.09 | -0.5% | 0.170 |
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% | — |
poi_load |
0.24 ±0.03 | 1.06 ±0.03 | +334.8% (noise) | 0.000 |
nii_volumes |
0.29 ±0.02 | 0.26 ±0.02 | -10.8% (noise) | 0.105 |
nii_compute_crop |
0.13 ±0.01 | 0.12 ±0.02 | -8.3% (noise) | 0.609 |
nii_save |
1.36 ±0.02 | 1.25 ±0.01 | -8.1% (noise) | 0.000 |
poi_local_to_global_arr |
0.35 ±0.03 | 0.33 ±0.01 | -7.5% (noise) | 0.069 |
nii_set_dtype |
0.38 ±0.02 | 0.35 ±0.02 | -6.8% (noise) | 0.077 |
poi_save |
0.46 ±0.03 | 0.43 ±0.03 | -6.0% (noise) | 0.504 |
nii_unique |
0.12 ±0.01 | 0.11 ±0.00 | -5.8% (noise) | 0.294 |
nii_filter_connected_components |
0.77 ±0.02 | 0.81 ±0.03 | +4.8% (noise) | 0.038 |
poi_to_global |
0.21 ±0.01 | 0.22 ±0.01 | +4.8% (noise) | 0.184 |
poi_resample_from_to |
0.70 ±0.02 | 0.67 ±0.02 | -3.9% (noise) | 0.116 |
poi_rescale |
0.23 ±0.01 | 0.24 ±0.02 | +3.8% (noise) | 0.162 |
poi_map_labels |
0.19 ±0.01 | 0.18 ±0.01 | -3.4% (noise) | 0.112 |
nii_load_seg |
1.60 ±0.04 | 1.65 ±0.78 | +3.4% (noise) | 0.216 |
poi_reorient |
0.43 ±0.02 | 0.41 ±0.03 | -3.3% (noise) | 0.980 |
nii_map_labels |
0.44 ±0.03 | 0.42 ±0.02 | -3.2% (noise) | 0.353 |
poi_calc_centroids_nocrop |
1.09 ±0.03 | 1.06 ±0.02 | -3.1% (noise) | 0.046 |
nii_get_array |
0.02 ±0.00 | 0.02 ±0.00 | +2.6% (noise) | 0.731 |
nii_apply_crop |
0.61 ±0.01 | 0.60 ±0.02 | -2.4% (noise) | 0.211 |
nii_load_img |
1.45 ±0.03 | 1.43 ±0.09 | -1.7% (noise) | 0.922 |
nii_rescale_seg |
0.72 ±0.03 | 0.71 ±0.02 | -1.7% (noise) | 0.216 |
nii_get_connected_components |
0.59 ±0.04 | 0.60 ±0.03 | +1.7% (noise) | 0.502 |
nii_erode_msk |
0.94 ±0.02 | 0.95 ±0.05 | +1.4% (noise) | 0.463 |
nii_pad_to |
0.50 ±0.04 | 0.50 ±0.02 | +1.3% (noise) | 0.265 |
nii_fill_holes |
0.82 ±0.04 | 0.83 ±0.02 | +1.2% (noise) | 0.917 |
nii_extract_label |
0.40 ±0.02 | 0.40 ±0.02 | -1.0% (noise) | 0.285 |
nii_center_of_masses |
0.20 ±0.01 | 0.20 ±0.01 | -0.9% (noise) | 0.795 |
nii_reorient |
0.76 ±0.03 | 0.77 ±0.03 | +0.8% (noise) | 0.679 |
poi_calc_centroids |
0.87 ±0.02 | 0.87 ±0.03 | +0.2% (noise) | 0.874 |
mri_3d — shape (68, 52, 67)
| Measurement | baseline ms (median ±½·range) | head ms (median ±½·range) | Δ % | p |
|---|---|---|---|---|
poi_calc_centroids_nocrop |
9.20 ±0.08 | 8.53 ±0.08 | -7.3% | 0.000 |
nii_fill_holes |
5.75 ±0.06 | 5.35 ±0.06 | -7.0% | 0.000 |
nii_erode_msk |
8.22 ±0.08 | 7.67 ±0.12 | -6.7% | 0.000 |
nii_filter_connected_components |
4.69 ±0.10 | 4.62 ±0.09 | -1.5% | 0.218 |
nii_dilate_msk |
162.83 ±1.16 | 160.66 ±0.47 | -1.3% | 0.005 |
… 31 more measurements
| Measurement | baseline ms (median ±½·range) | head ms (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_rescale |
38.03 ±0.33 | 37.71 ±0.35 | -0.9% | 0.159 |
nii_save |
9.25 ±0.08 | 9.18 ±0.08 | -0.8% | 0.059 |
nii_resample_from_to |
39.87 ±0.24 | 40.13 ±0.34 | +0.6% | 0.647 |
nii_rescale_seg |
3.35 ±0.06 | 3.36 ±0.10 | +0.3% | 0.676 |
poi_calc_poi_from_subreg_vert |
11.71 ±0.05 | 11.73 ±0.15 | +0.2% | 0.233 |
nii_load_img |
8.84 ±0.17 | 8.84 ±0.03 | -0.1% | 0.331 |
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_load |
0.32 ±0.01 | 2.26 ±0.10 | +609.8% (noise) | 0.000 |
nii_extract_label |
0.82 ±0.01 | 0.43 ±0.03 | -47.4% (noise) | 0.000 |
nii_get_array |
0.05 ±0.01 | 0.04 ±0.01 | -11.5% (noise) | 0.472 |
poi_local_to_global_arr |
0.36 ±0.01 | 0.33 ±0.02 | -7.7% (noise) | 0.010 |
poi_map_labels |
0.24 ±0.01 | 0.23 ±0.00 | -6.9% (noise) | 0.023 |
poi_resample_from_to |
0.77 ±0.04 | 0.72 ±0.04 | -6.1% (noise) | 0.224 |
nii_apply_crop |
0.69 ±0.02 | 0.65 ±0.01 | -5.4% (noise) | 0.012 |
poi_save |
0.52 ±0.03 | 0.55 ±0.03 | +4.7% (noise) | 0.776 |
nii_compute_crop |
0.33 ±0.01 | 0.31 ±0.02 | -4.6% (noise) | 0.087 |
nii_unique |
0.66 ±0.02 | 0.64 ±0.01 | -4.0% (noise) | 0.041 |
poi_to_global |
0.26 ±0.01 | 0.25 ±0.01 | -3.0% (noise) | 0.973 |
poi_rescale |
0.27 ±0.02 | 0.27 ±0.01 | -2.8% (noise) | 0.434 |
nii_map_labels |
1.08 ±0.04 | 1.10 ±0.04 | +2.1% (noise) | 0.773 |
poi_calc_centroids |
2.74 ±0.04 | 2.69 ±0.06 | -1.8% (noise) | 0.126 |
poi_reorient |
0.44 ±0.02 | 0.43 ±0.02 | -1.2% (noise) | 0.652 |
nii_get_connected_components |
2.25 ±0.06 | 2.22 ±0.05 | -1.1% (noise) | 0.835 |
nii_reorient |
0.82 ±0.02 | 0.81 ±0.02 | -1.1% (noise) | 0.188 |
nii_center_of_masses |
1.95 ±0.01 | 1.97 ±0.01 | +1.0% (noise) | 0.134 |
nii_pad_to |
0.68 ±0.02 | 0.68 ±0.06 | -0.6% (noise) | 0.708 |
nii_set_dtype |
0.49 ±0.03 | 0.49 ±0.02 | -0.6% (noise) | 0.244 |
nii_load_seg |
2.88 ±0.15 | 2.88 ±0.03 | -0.3% (noise) | 0.608 |
nii_volumes |
1.94 ±0.02 | 1.94 ±0.04 | +0.2% (noise) | 0.416 |
mri_2d — shape (68, 52, 1)
| Measurement | baseline ms (median ±½·range) | head ms (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_dilate_msk |
19.86 ±0.06 | 19.53 ±0.46 | -1.6% | 0.780 |
nii_rescale |
5.39 ±0.08 | 5.47 ±0.09 | +1.6% | 0.061 |
nii_resample_from_to |
7.48 ±0.07 | 7.39 ±0.07 | -1.2% | 0.048 |
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% | — |
poi_load |
0.31 ±0.01 | 2.22 ±0.06 | +625.6% (noise) | 0.000 |
nii_get_array |
0.02 ±0.00 | 0.01 ±0.00 | -18.6% (noise) | 0.040 |
poi_rescale |
0.29 ±0.02 | 0.25 ±0.01 | -11.8% (noise) | 0.016 |
nii_unique |
0.12 ±0.01 | 0.10 ±0.01 | -11.5% (noise) | 0.009 |
nii_map_labels |
0.42 ±0.02 | 0.47 ±0.04 | +10.3% (noise) | 0.065 |
poi_calc_centroids |
1.18 ±0.02 | 1.06 ±0.02 | -10.2% (noise) | 0.000 |
nii_erode_msk |
1.40 ±0.05 | 1.27 ±0.13 | -9.2% (noise) | 0.286 |
poi_local_to_global_arr |
0.35 ±0.02 | 0.32 ±0.01 | -9.0% (noise) | 0.009 |
nii_fill_holes |
1.15 ±0.05 | 1.05 ±0.04 | -8.9% (noise) | 0.010 |
poi_reorient |
0.46 ±0.02 | 0.43 ±0.02 | -8.2% (noise) | 0.018 |
nii_filter_connected_components |
0.87 ±0.04 | 0.81 ±0.02 | -7.2% (noise) | 0.049 |
poi_calc_centroids_nocrop |
1.45 ±0.04 | 1.35 ±0.02 | -7.2% (noise) | 0.001 |
nii_get_connected_components |
0.61 ±0.03 | 0.58 ±0.01 | -5.4% (noise) | 0.125 |
nii_volumes |
0.52 ±0.02 | 0.49 ±0.02 | -5.4% (noise) | 0.034 |
poi_map_labels |
0.24 ±0.02 | 0.22 ±0.01 | -5.3% (noise) | 0.253 |
nii_extract_label |
0.40 ±0.03 | 0.38 ±0.03 | -5.0% (noise) | 0.315 |
poi_save |
0.51 ±0.01 | 0.49 ±0.02 | -3.6% (noise) | 0.265 |
nii_load_img |
1.39 ±0.03 | 1.44 ±0.04 | +3.5% (noise) | 0.400 |
nii_center_of_masses |
0.46 ±0.03 | 0.44 ±0.02 | -3.5% (noise) | 0.427 |
poi_resample_from_to |
0.74 ±0.02 | 0.72 ±0.03 | -3.2% (noise) | 0.479 |
nii_reorient |
0.78 ±0.01 | 0.76 ±0.02 | -3.0% (noise) | 0.217 |
nii_load_seg |
1.57 ±0.06 | 1.61 ±0.05 | +2.8% (noise) | 0.190 |
nii_rescale_seg |
0.73 ±0.03 | 0.71 ±0.03 | -2.7% (noise) | 0.573 |
nii_save |
1.31 ±0.04 | 1.34 ±0.07 | +2.6% (noise) | 0.293 |
nii_compute_crop |
0.13 ±0.01 | 0.13 ±0.01 | +1.6% (noise) | 0.805 |
nii_apply_crop |
0.61 ±0.02 | 0.62 ±0.04 | +1.6% (noise) | 0.539 |
nii_set_dtype |
0.37 ±0.01 | 0.36 ±0.01 | -1.1% (noise) | 0.324 |
poi_to_global |
0.24 ±0.01 | 0.25 ±0.01 | +0.9% (noise) | 0.915 |
nii_pad_to |
0.49 ±0.02 | 0.49 ±0.01 | +0.1% (noise) | 0.824 |
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 cd07009 vs head cd07009 · python 3.11.15 · 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 |
|---|---|---|---|---|
nii_unique |
3.14 ±0.00 | 2.96 ±0.00 | -5.7% | — |
nii_fill_holes |
6.40 ±0.00 | 6.30 ±0.00 | -1.5% | — |
nii_load_img |
3.56 ±0.00 | 3.53 ±0.00 | -0.9% | — |
nii_set_dtype |
5.23 ±0.00 | 5.26 ±0.00 | +0.6% | — |
nii_erode_msk |
5.98 ±0.00 | 5.94 ±0.00 | -0.6% | — |
… 31 more measurements
| Measurement | baseline MiB (median ±½·range) | head MiB (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_reorient |
5.85 ±0.00 | 5.88 ±0.00 | +0.5% | — |
nii_apply_crop |
5.86 ±0.00 | 5.89 ±0.00 | +0.5% | — |
nii_rescale_seg |
5.93 ±0.00 | 5.96 ±0.00 | +0.5% | — |
poi_resample_from_to |
5.22 ±0.00 | 5.25 ±0.00 | +0.5% | — |
poi_calc_centroids |
6.04 ±0.00 | 6.07 ±0.00 | +0.5% | — |
nii_rescale |
6.11 ±0.00 | 6.14 ±0.00 | +0.5% | — |
nii_extract_label |
5.42 ±0.00 | 5.45 ±0.00 | +0.5% | — |
nii_resample_from_to |
6.24 ±0.00 | 6.27 ±0.00 | +0.5% | — |
nii_map_labels |
5.48 ±0.00 | 5.51 ±0.00 | +0.5% | — |
nii_filter_connected_components |
6.92 ±0.00 | 6.89 ±0.00 | -0.5% | — |
poi_calc_poi_from_subreg_vert |
7.11 ±0.00 | 7.14 ±0.00 | +0.4% | 0.000 |
nii_get_connected_components |
6.61 ±0.00 | 6.63 ±0.00 | +0.4% | — |
nii_pad_to |
5.41 ±0.00 | 5.43 ±0.00 | +0.4% | — |
poi_calc_centroids_nocrop |
5.91 ±0.00 | 5.93 ±0.00 | +0.3% | — |
poi_to_global |
3.08 ±0.00 | 3.09 ±0.00 | +0.3% | — |
nii_center_of_masses |
3.27 ±0.00 | 3.28 ±0.00 | +0.2% | — |
nii_volumes |
3.52 ±0.00 | 3.53 ±0.00 | +0.2% | — |
nii_save |
3.71 ±0.00 | 3.71 ±0.00 | +0.2% | — |
poi_reorient |
3.83 ±0.00 | 3.84 ±0.00 | +0.2% | — |
nii_dilate_msk |
5.92 ±0.00 | 5.93 ±0.00 | +0.1% | — |
nii_load_seg |
6.00 ±0.00 | 5.99 ±0.00 | -0.1% | — |
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_load |
1.67 ±0.00 | 1.61 ±0.00 | -3.5% (noise) | — |
nii_get_array |
1.64 ±0.00 | 1.65 ±0.00 | +0.5% (noise) | — |
poi_rescale |
2.60 ±0.00 | 2.61 ±0.00 | +0.3% (noise) | — |
poi_save |
1.40 ±0.00 | 1.40 ±0.00 | +0.3% (noise) | — |
nii_compute_crop |
2.89 ±0.00 | 2.90 ±0.00 | +0.3% (noise) | — |
poi_local_to_global_arr |
2.95 ±0.00 | 2.96 ±0.00 | +0.3% (noise) | — |
poi_map_labels |
1.77 ±0.00 | 1.77 ±0.00 | +0.2% (noise) | — |
ct_2d — shape (73, 47, 1)
| Measurement | baseline MiB (median ±½·range) | head MiB (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_unique |
3.08 ±0.00 | 2.90 ±0.00 | -6.0% | — |
nii_fill_holes |
6.34 ±0.00 | 6.24 ±0.00 | -1.5% | — |
nii_load_img |
3.50 ±0.00 | 3.46 ±0.00 | -1.0% | — |
nii_erode_msk |
5.91 ±0.00 | 5.88 ±0.00 | -0.7% | — |
poi_resample_from_to |
5.16 ±0.00 | 5.18 ±0.00 | +0.5% | — |
… 30 more measurements
| Measurement | baseline MiB (median ±½·range) | head MiB (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_set_dtype |
5.17 ±0.00 | 5.20 ±0.00 | +0.5% | — |
nii_filter_connected_components |
6.86 ±0.00 | 6.82 ±0.00 | -0.5% | — |
nii_extract_label |
5.36 ±0.00 | 5.38 ±0.00 | +0.5% | — |
nii_map_labels |
5.42 ±0.00 | 5.45 ±0.00 | +0.5% | — |
nii_reorient |
5.79 ±0.00 | 5.81 ±0.00 | +0.5% | — |
nii_apply_crop |
5.79 ±0.00 | 5.82 ±0.00 | +0.5% | — |
nii_rescale_seg |
5.86 ±0.00 | 5.89 ±0.00 | +0.5% | — |
poi_calc_centroids |
5.98 ±0.00 | 6.01 ±0.00 | +0.5% | — |
nii_rescale |
6.05 ±0.00 | 6.08 ±0.00 | +0.5% | — |
nii_resample_from_to |
6.18 ±0.00 | 6.21 ±0.00 | +0.4% | — |
nii_get_connected_components |
6.54 ±0.00 | 6.57 ±0.00 | +0.4% | — |
nii_pad_to |
5.35 ±0.00 | 5.37 ±0.00 | +0.3% | — |
poi_calc_centroids_nocrop |
5.91 ±0.00 | 5.93 ±0.00 | +0.3% | — |
nii_load_seg |
5.94 ±0.00 | 5.93 ±0.00 | -0.2% | — |
poi_to_global |
3.02 ±0.00 | 3.02 ±0.00 | +0.1% | — |
nii_center_of_masses |
3.21 ±0.00 | 3.21 ±0.00 | +0.1% | — |
nii_volumes |
3.46 ±0.00 | 3.46 ±0.00 | +0.1% | — |
nii_save |
3.64 ±0.00 | 3.65 ±0.00 | +0.1% | — |
poi_reorient |
3.77 ±0.00 | 3.77 ±0.00 | +0.1% | — |
nii_dilate_msk |
5.86 ±0.00 | 5.86 ±0.00 | +0.1% | — |
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_load |
1.61 ±0.00 | 1.55 ±0.00 | -3.6% (noise) | — |
poi_save |
1.34 ±0.00 | 1.34 ±0.00 | +0.3% (noise) | — |
nii_get_array |
1.58 ±0.00 | 1.59 ±0.00 | +0.2% (noise) | — |
poi_map_labels |
1.71 ±0.00 | 1.71 ±0.00 | +0.2% (noise) | — |
poi_rescale |
2.54 ±0.00 | 2.54 ±0.00 | +0.2% (noise) | — |
nii_compute_crop |
2.83 ±0.00 | 2.84 ±0.00 | +0.1% (noise) | — |
poi_local_to_global_arr |
2.89 ±0.00 | 2.89 ±0.00 | +0.1% (noise) | — |
mri_3d — shape (68, 52, 67)
| Measurement | baseline MiB (median ±½·range) | head MiB (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_unique |
3.08 ±0.00 | 2.90 ±0.00 | -6.0% | — |
nii_fill_holes |
6.34 ±0.00 | 6.24 ±0.00 | -1.5% | — |
nii_load_img |
3.56 ±0.00 | 3.53 ±0.00 | -1.0% | — |
nii_erode_msk |
5.91 ±0.00 | 5.88 ±0.00 | -0.7% | — |
poi_resample_from_to |
5.16 ±0.00 | 5.18 ±0.00 | +0.5% | — |
… 31 more measurements
| Measurement | baseline MiB (median ±½·range) | head MiB (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_set_dtype |
5.23 ±0.00 | 5.26 ±0.00 | +0.5% | — |
nii_filter_connected_components |
6.86 ±0.00 | 6.82 ±0.00 | -0.5% | — |
nii_extract_label |
5.36 ±0.00 | 5.38 ±0.00 | +0.5% | — |
nii_map_labels |
5.42 ±0.00 | 5.45 ±0.00 | +0.5% | — |
nii_reorient |
5.79 ±0.00 | 5.81 ±0.00 | +0.5% | — |
nii_apply_crop |
5.79 ±0.00 | 5.82 ±0.00 | +0.5% | — |
nii_rescale_seg |
5.86 ±0.00 | 5.89 ±0.00 | +0.5% | — |
poi_calc_centroids |
5.98 ±0.00 | 6.01 ±0.00 | +0.5% | — |
nii_rescale |
6.05 ±0.00 | 6.08 ±0.00 | +0.5% | — |
nii_resample_from_to |
6.18 ±0.00 | 6.20 ±0.00 | +0.4% | — |
nii_get_connected_components |
6.54 ±0.00 | 6.57 ±0.00 | +0.4% | — |
poi_calc_poi_from_subreg_vert |
7.10 ±0.00 | 7.13 ±0.00 | +0.4% | — |
nii_pad_to |
5.35 ±0.00 | 5.37 ±0.00 | +0.3% | — |
poi_calc_centroids_nocrop |
5.85 ±0.00 | 5.87 ±0.00 | +0.3% | — |
nii_load_seg |
5.94 ±0.00 | 5.93 ±0.00 | -0.2% | — |
poi_to_global |
3.02 ±0.00 | 3.02 ±0.00 | +0.1% | — |
nii_center_of_masses |
3.21 ±0.00 | 3.21 ±0.00 | +0.1% | — |
nii_volumes |
3.46 ±0.00 | 3.46 ±0.00 | +0.1% | — |
nii_save |
3.64 ±0.00 | 3.65 ±0.00 | +0.1% | — |
poi_reorient |
3.77 ±0.00 | 3.77 ±0.00 | +0.1% | — |
nii_dilate_msk |
5.86 ±0.00 | 5.86 ±0.00 | +0.1% | — |
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_load |
1.61 ±0.00 | 1.55 ±0.00 | -3.6% (noise) | — |
poi_save |
1.40 ±0.00 | 1.40 ±0.00 | +0.3% (noise) | — |
nii_get_array |
1.58 ±0.00 | 1.59 ±0.00 | +0.2% (noise) | — |
poi_map_labels |
1.71 ±0.00 | 1.71 ±0.00 | +0.2% (noise) | — |
poi_rescale |
2.54 ±0.00 | 2.54 ±0.00 | +0.2% (noise) | — |
nii_compute_crop |
2.83 ±0.00 | 2.84 ±0.00 | +0.1% (noise) | — |
poi_local_to_global_arr |
2.89 ±0.00 | 2.89 ±0.00 | +0.1% (noise) | — |
mri_2d — shape (68, 52, 1)
| Measurement | baseline MiB (median ±½·range) | head MiB (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_unique |
3.08 ±0.00 | 2.90 ±0.00 | -6.0% | — |
nii_fill_holes |
6.34 ±0.00 | 6.24 ±0.00 | -1.5% | — |
nii_load_img |
3.56 ±0.00 | 3.53 ±0.00 | -1.0% | — |
nii_erode_msk |
5.91 ±0.00 | 5.88 ±0.00 | -0.7% | — |
poi_resample_from_to |
5.16 ±0.00 | 5.18 ±0.00 | +0.5% | — |
… 30 more measurements
| Measurement | baseline MiB (median ±½·range) | head MiB (median ±½·range) | Δ % | p |
|---|---|---|---|---|
nii_set_dtype |
5.23 ±0.00 | 5.26 ±0.00 | +0.5% | — |
nii_filter_connected_components |
6.86 ±0.00 | 6.82 ±0.00 | -0.5% | — |
nii_extract_label |
5.36 ±0.00 | 5.38 ±0.00 | +0.5% | — |
nii_map_labels |
5.42 ±0.00 | 5.45 ±0.00 | +0.5% | — |
nii_reorient |
5.79 ±0.00 | 5.81 ±0.00 | +0.5% | — |
nii_apply_crop |
5.79 ±0.00 | 5.82 ±0.00 | +0.5% | — |
nii_rescale_seg |
5.86 ±0.00 | 5.89 ±0.00 | +0.5% | — |
poi_calc_centroids |
5.98 ±0.00 | 6.01 ±0.00 | +0.5% | — |
nii_rescale |
6.05 ±0.00 | 6.08 ±0.00 | +0.5% | — |
nii_resample_from_to |
6.18 ±0.00 | 6.20 ±0.00 | +0.4% | — |
nii_get_connected_components |
6.54 ±0.00 | 6.57 ±0.00 | +0.4% | — |
nii_pad_to |
5.35 ±0.00 | 5.37 ±0.00 | +0.3% | — |
poi_calc_centroids_nocrop |
5.91 ±0.00 | 5.93 ±0.00 | +0.3% | — |
nii_load_seg |
5.94 ±0.00 | 5.93 ±0.00 | -0.2% | — |
poi_to_global |
3.02 ±0.00 | 3.02 ±0.00 | +0.1% | — |
nii_center_of_masses |
3.21 ±0.00 | 3.21 ±0.00 | +0.1% | — |
nii_volumes |
3.46 ±0.00 | 3.46 ±0.00 | +0.1% | — |
nii_save |
3.64 ±0.00 | 3.65 ±0.00 | +0.1% | — |
poi_reorient |
3.77 ±0.00 | 3.77 ±0.00 | +0.1% | — |
nii_dilate_msk |
5.86 ±0.00 | 5.86 ±0.00 | +0.1% | — |
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_load |
1.61 ±0.00 | 1.55 ±0.00 | -3.6% (noise) | — |
poi_save |
1.40 ±0.00 | 1.40 ±0.00 | +0.3% (noise) | — |
nii_get_array |
1.58 ±0.00 | 1.59 ±0.00 | +0.2% (noise) | — |
poi_map_labels |
1.71 ±0.00 | 1.71 ±0.00 | +0.2% (noise) | — |
poi_rescale |
2.54 ±0.00 | 2.54 ±0.00 | +0.2% (noise) | — |
nii_compute_crop |
2.83 ±0.00 | 2.84 ±0.00 | +0.1% (noise) | — |
poi_local_to_global_arr |
2.89 ±0.00 | 2.89 ±0.00 | +0.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.
Bug sweep: 40+ correctness fixes across core, POI, registration and segmentation
A systematic read-through of the codebase looking for defects. Every finding below is a real bug, not a style preference. They are grouped into seven commits by kind, so each can be reviewed (or reverted) on its own.
unit_tests/test_regressions.pyadds 47 tests that fail onmainand pass here. The existing suite (417 tests) stays green.One item needs a domain decision — see⚠️ under "Wrong results" (
np_filter_connected_components).1. Core
NII/np_utilsmethods broken on their default paths(commit
edd6ecf)These are not edge cases — they are the ordinary call.
NII.rescale(1.5)— a scalar spacing was expanded with a generator expression instead of a tuple, and the next line callslen()on it →TypeError. Every scalar-spacing call was broken.tuple(...).NII.threshold()— didarr[arr2 >= t] = 1thenarr[arr2 <= t] = 0. The second write erases the==case, so thresholding a binary mask at 1 returned an all-zero image.(arr >= t).astype(np.uint8). Also drops a redundant full-volume copy.NII.normalize()— divided bymax_outinstead of scaling into[min_out, max_out], sonormalize(0, 255)produced values in[0, 1]and then tripped its ownassert;max_out=0raisedZeroDivisionError.np.iscloseinstead of exact float equality in the asserts.np_dilate_msk(mask=..., use_crop=True)— croppedmaskwith the global bbox but indexed it against the per-label bbox →IndexErroron any multi-label segmentation. That is the default path ofNII.dilate_msk(mask=...).maskwith the per-label crop so the shapes line up.np_dilate_msk_euclid(labels=...)— the label filter was only applied on theuse_crop=Falsebranch; the default path dilated every label.maskarray in place (mask[mask != 0] = 1), silently mutating an argument.mask != 0.Plus four inverted
inplaceearly-returns (nii_wrapper.py:811, 1127, 1140, 2700), all of the formreturn self.copy() if inplace else self. On the no-op shortcut ofreorient,rescale(×2) andextract_label, an in-place call returned a different object and an out-of-place call returned an alias of self — the exact opposite of the contract, so a later mutation silently propagated back to the caller'simage.
2. Dtype selection: silently oversized or wrapped segmentations
(commit
49f4dfc)Three float→int guards used
isinstance()against numpy scalar types:Both are always
False, soNII.__init__,NII.set_arrayandNII.savenever downcast a float segmentation. Afloat64segmentation stayedfloat64for its whole lifetime — 8× the memory of theuint8it should be, through everyget_seg_array()copy and onto disk — and it defeated the unsigned-int fast paths innp_utils. Replaced withnp.issubdtype(...).set_dtype("smallest_int" / "smallest_uint")chose the target fromarr.max()alone. Since the cast usescasting="unsafe", a negative minimum wrapped silently (-1 → 255), and"smallest_uint"fell back to the signednp.int32. Factored into_smallest_int_dtype(), which considers both bounds, asserts non-negativity for unsigned targets, and extends to 32/64-bit.np_map_labelsbuilt its lookup table withdtype=arr.dtype, so any mapping target outside the input dtype's range wrapped without warning — on auint8mask,1 → 300produced44and1 → -5produced251. The table dtype is now derived from the mapping targets too; in-range mappings keep the input dtype as before.NII.savealso no longer mutatesselfviaset_dtype_just to get its cast (a query should not mutate), and no longer re-copies the whole volume afterwards.3. Off-by-one bbox, leaky return types, empty-mask border check
(commit
b11343e)np_bbox_binaryclamped the stop index to the array shape and only then added 1, so a bbox touching the far border producedstop == shape + 1. Slicing tolerates that, but every consumer computingstop - start(np_center_of_bbox_binary,NII.compute_crop,is_segmentation_in_border) saw a size one voxel too large. Interior boxes were unaffected, which is why it survived. Clamp now happens after the+1.px_distin auint8array →OverflowErrorforpx_dist > 255under NumPy 2. Now plainint.np_unique/np_unique_withoutzeroare annotated-> list[int]but returned three different scalar types depending on input dtype and which of four code paths ran: Pythonintfrombincount,np.int64from the fast path,np.float32/np.int16from thenp.uniquefallbacks. The numpy scalars are not JSON-serializable, so serializing a label list failed for non-uint inputs. All paths go through.tolist().NII.is_segmentation_in_border()guarded onslices is None, butcompute_crop(raise_error=False)returns full-extent slices for an empty mask and neverNone— so an empty segmentation was reported as touching the border. Now short-circuits on the existingis_emptyproperty.4. Two caches leaking state across the whole process
(commit
ffc42f4)sag_cor_curve_projectiondid:orderaliases the module-levelv_idx_orderlist fromvert_constants.py— which is re-exported asTPTBox.v_idx_order— and+=extends a list in place. Rendering a single snapshot permanently grew the shared global from 105 to 256 entries for everything else in the process.orderwas never read afterwards, so both lines are dead; removed.POI._vert_orientation_pirwas a bare class attribute, i.e. one dict shared by everyPOIinstance in the process — despite the comment saying it "will not be copied".get_vert_direction_PIRcompounded this by writing the cache onto a throwawayextract_subregion()copy, so a lookup could return vertebra directions computed for a different subject processed earlier. It is now a per-instancefield(default_factory=dict, repr=False, compare=False), and the cache is written to the object the function was actually called with.5. Crashes on public API entry points
(commit
69436cb)from TPTBox import *raisedAttributeError:__all__advertisedload_poibut nothing imported it. Now re-exported fromcore.poi_fun.save_load.nii[0:5]delegated to a key containingEllipsis, which__getitem__itself rejects two branches earlier — and dropped thereturn. Now pads the trailing dimensions with full slices.extract_label("12")/remove_labels("12")—stris aSequence, so thestrbranch was unreachable and the label was iterated character by character →IndexError. Thestrcheck now precedes theSequencecheck.ssim()/psnr()normalised via in-placeimg_1 /= img_1.max()→UFuncTypeErroron integer images. The neighbouringimg_2already used the out-of-place form; both now match.sitk_utils.transform_centroidcalled the non-existentNII.get_empty_POI; the method ismake_empty_POI(spelled correctly at eight other call sites). Also drops a duplicated assignment in the deformable branch.stitching_tools.n4_biaspasseddilate_msk_(mm=3), which is not a parameter of that method →TypeErroron every call.inference_nnunetforwardedstacklevel=into the logger, which passes**qargstoprint()→TypeErrorwhenever the input affine is the identity (exactly the case the warning is about).POI_Global.to_othertestedisinstance(ref, Self);Selfis a typing special form andisinstance()against it raises. Now checksPOI_Global, and the method no longer falls off the end returningNone.POI_Global.__init__gatedlevel_two_infoonlevel_one_info(copy-paste), so passing onlylevel_two_infosilently dropped it.calc_centroidstooktype(stage)after unwrapping the enum to.value, solevel_one_info/level_two_infowere alwaysintand the saved POI header recorded"int"instead of the enum class.auto_add_run_iddidinfo["run"] += 1on a value that must stay a decimal string forvalidate_entities()(which calls.isdecimal()).6. Wrong results, no exception
(commit
b2ee83b) — none of these raised; they just computed the wrong thing.vert_constantsFull_Body_Instance_Vibe.get_Full_Body_Instance_mapping()referencedcls.hip_left/cls.hip_right, which do not exist on that enum — the labels arepelvis_left/pelvis_right(50/51), matching the inverse map. The whole classmethod raisedAttributeError.lung_lefttwice,lung_rightthree times,channeltwice. Only the last of each survived. The shadowed entries are removed and the previously-winning target kept, so behaviour is unchanged — with a note that one FBI lung label cannot address several Vibe lobes. Deciding which lobe should win is a modelling question, left as-is.Abstract_lvl._get_idpassedclspositionally into aclassmethod, shifting every argument by one →TypeErroron every lookup, swallowed by a bareexcept Exception. Name resolution therefore never worked:Any._get_id("L1")fell through toint("L1"). Now resolves to 20. The bare except is narrowed so the next such bug is not silent.np_utilsnp_filter_connected_componentscomparedlabel_volume_pairs(alist[tuple]) tolargest_k_components(anint), so that shortcut branch was unreachable. Fixing it tolen(...)means the shortcut now fires when the component count equalslargest_k_components, which changes which components are preserved in that case. This is the one change with a behavioural consequence I could not settle from the code alone — please sanity-check it against your expectations.background_thresholdin the label-smoothing helper was applied to the argmax indices rather than the winning confidence, so it removed labels by index number rather than by probability. Now thresholdsarr_stack.max(axis=0).Elsewhere
vertebra_directiondropped the result ofset_array().reorient().rescale_()(all out-of-place) and then read the array back off the un-reoriented object, writing the fill-back image in the wrong orientation and spacing. Now mirrors the correct chained form used incalc_center_spinal_cord.SegmentationMeshdiscardedint_arr.astype(np.uint16), so the float→int conversion it prints a message about never happened.snapshot_modular:cmap(color - 1 % LABEL_MAX % cmap.N)parses ascolor - (1 % … % …)=color - 1, losing the wrap-around. Parenthesised to match the correct site above it.angles:if (vert, 50) not in poi: cord = poi[vert, 50]was inverted, so no lordosis/kyphosis label was ever emitted.body_quadrantsrequestedVertebra_Direction_Inferiortwice but readsVertebra_Direction_Right; the resultingKeyErrorwas swallowed, so every vertebra was skipped andmake_quadrantsreturned an all-zero image.inference_nnunetbuilt its label mapping with the raw string key instead of the parsed int, somap_labels_never matched.predictoraccumulated the chunk bounding-box max fromself.min_s.deepali_modelgatedsource_segonfixed_segandtarget_segonmoving_seg— each branch tested the other variable.point_registration.load_assigned the dumped(moving, fixed)pair to(_img_fixed, _img_moving), so every reloaded registration resampled into the wrong space.save_mkrpassedsplit_by_region=split_by_subregion, so with the documented defaults neither branch was taken and markers got random colours.ray_castingnegatedzinstead of flipping the half-space inequality — not the same operation once the plane does not pass through the origin.7. Performance, resource leaks, encoding
(commit
3f5162a)Import time.
nii_wrapper_mathimportedpeak_signal_noise_ratioandstructural_similarityfromskimage.metricsat module level, though they are used only insideNII.ssim/NII.psnr. That import pulls inscipy.stats, which accounted for ~35% ofimport TPTBox. Moved into the methods, following the existingskimage.exposureprecedent innii_wrapper.py:Memory.
vertebra_directionallocatedsubreg_iso.get_array() * 0once per vertebra —get_array()copies the whole volume and* 0allocates a second one. Now a singlenp.zerosof the same shape and dtype. Two sites.set_dtypecalledget_array()(a full copy) twice on thesmallest_int/smallest_uintpath; now fetched at most once.Resource leaks.
_help.make_spine_plotcreated a figure it never closed — one leaked figure per call.snapshot_modular.create_snapshotused a bareplt.close(), which closes the current figure rather than the one just saved, and was skipped entirely ifsavefigraised. Both now close their own figure in afinally.Encoding. The
Loggeropened its.logwith the platform default encoding, soprint_statistic's±raisedUnicodeEncodeErrorunder a C/POSIX locale (Docker, cron, CI). Itslogs/directory is now created withparents=True, exist_ok=True— parallel jobs were racing on it. The same missingencoding="utf-8"is fixed for the POI, BIDS-sidecar and DICOM JSON readers.8. Stale duplicate package, over-broad import guards
(commit
0b0d14c)TPTBox/registration/ridged_intensity/was meant to be renamed to_ridged_intensity/ine919218("rename folder to _ to show that this is not the recommended way to import things"), but the old directory stayed tracked. Both copies then received the docstring pass in81db55a, while only the underscore copy received1b8e0fb("fix bug for very elongated segmentations") — so the duplicate still carries the pre-fixw = max(target.shape[2:])and the un-scaled delta comparison. Nothing imports it and its__init__.pyis empty, so it is removed, along with the stale__pycache__-only leftovers atregistration/{deepali,deformable,ridged_points}/.The optional-dependency guards in
registration/__init__.pyand_deepali/__init__.pycaught bareException, which makes a realNameErrororAttributeErrorinside those modules indistinguishable from "torch is not installed" — the symbol just silently vanishes from the public API. That is exactly how the stale duplicate above, and aNameErrorindeepali_trainer, went unnoticed. Narrowed toImportError; all five exported names still resolve.Also drops the duplicate
MODESdefinition innii_wrapper.py, which shadowed the identical import 13 lines above it, and two leftover debug prints — one in POI buffer loading, one emitted once per bisection step per point fromray_casting.Testing
Each of the 47 new tests targets one bug above and fails on
main. Fixes in code paths that need optional dependencies (deepali, nnU-Net, SimpleITK, ANTs) or GPU are covered by inspection rather than tests.
Review notes
np_filter_connected_componentschange (§6) is the only fix that alters behaviour in a way that could be intentional. Everything else restores what the code plainly says it does..
inplacefixes change what object is returned on the no-op shortcut path. If any caller was relying on the inverted behaviour, it was relying on a bug, but it is worth a grep on your side across downstream code.