From 3c705a6997e22ae812e31817204242c89e52a8da Mon Sep 17 00:00:00 2001 From: iback Date: Thu, 24 Sep 2026 10:25:51 +0000 Subject: [PATCH] chore: require TPTBox ^1.0.0 TPTBox 1.0.0 carries boolean arrays natively instead of upcasting them to uint8 when building the Nifti1Image, so `NII.extract_label(...).get_seg_array()` now returns a bool array. `detect_and_solve_merged_vertebra` accumulated into that array in place (`arr += arr2 + 1`) and raised `UFuncOutputCastingError` on the split-C2 branch. Rewriting the encoding also fixes what it was measuring. `arr += arr2 + 1` lifted the background to 1 and labelled *both* vertebrae 2, so `contacts[(1, 2)]` was the outer surface of the pair rather than their mutual contact: on the unit fixture 456 voxels against a 19-voxel threshold, where the real contact is 0. The branch therefore fired on virtually any pair of top instances that passed the size-ratio test. The two instances are now painted 1 and 2 with the background left at 0, and the lookup uses `.get((1, 2), 0)` because cc3d only reports label pairs that actually touch. Verified: the full unit suite (223 tests) passes against TPTBox v0.8.1, v1.0.0 and the current TPTBox main, so the fix is not version-dependent. Every TPTBox symbol SPINEPS imports still resolves at v1.0.0; the API removed there (the deprecated NII compute_crop_slice / apply_crop_slice aliases) is unused here. The nnU-Net dependency comment is updated: TPTBox 1.0 moved torch/nnunetv2/acvl-utils/batchgenerators into an optional `seg` extra, so SPINEPS' own pins are now the ones that supply them. Co-Authored-By: Claude Opus 5 --- pyproject.toml | 8 +++++--- spineps/phase_post.py | 14 +++++++++----- unit_tests/test_bugfixes.py | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f146b1b..18767b7 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,15 +26,17 @@ pytorch-lightning = "^2.0.8" torchmetrics = "^1.1.2" tqdm = "^4.66.1" einops= "^0.6.1" -TPTBox = "^0.8.1" +TPTBox = "^1.0.0" # TPTBox needs ants.utils.nibabel_nifti_to_ants, which only exists from antspyx 0.6 on, and antspyx ships no # Windows/macOS cp39 wheel from 0.6.0 on -- so Python 3.9 is Linux-only (see the CI matrix exclude). antspyx = "0.6.3" rich = "^13.6.0" monai="^1.3.0" TypeSaveArgParse="^1.0.1" -# nnunetv2/acvl-utils/blosc2/python-gdcm are transitive dependencies of TPTBox's nnU-Net inference path -# (TPTBox.segmentation.nnUnet_utils.inference_api); pinned directly here to control the resolved versions. +# nnunetv2/acvl-utils/blosc2/python-gdcm back TPTBox's nnU-Net inference path +# (TPTBox.segmentation.nnUnet_utils.inference_api). TPTBox 1.0 moved them into its optional `seg` +# extra, so they are required here rather than pulled in transitively; pinned directly to control +# the resolved versions. torch comes in via pytorch-lightning, batchgenerators via nnunetv2. python-gdcm = [ { version = "==3.0.25", python = ">=3.9,<3.10" }, ] diff --git a/spineps/phase_post.py b/spineps/phase_post.py index 634d3f7..60c92ba 100644 --- a/spineps/phase_post.py +++ b/spineps/phase_post.py @@ -750,12 +750,16 @@ def detect_and_solve_merged_vertebra(seg_nii: NII, vert_nii: NII) -> tuple[NII, # both vertebra if first_stats[2] < MERGED_VERTEBRA_SIZE_RATIO * second_stats[2]: # first is significantly smaller than second and they are close in height - # how many pixels are touching - vert_firsttwo_arr = vert_nii.extract_label(first_key).get_seg_array() - vert_firsttwo_arr2 = vert_nii.extract_label(second_key).get_seg_array() - vert_firsttwo_arr += vert_firsttwo_arr2 + 1 + # Paint the two instances as 1 and 2 with the background left at 0, so the contact count + # is the one between the two vertebrae. Adding the two binary masks plus one instead + # labelled the background 1 and *both* vertebrae 2, so `(1, 2)` measured the outer + # surface of the pair and the branch fired on virtually any pair of top instances. + # `extract_label` also returns a boolean mask, which cannot be accumulated into in place. + vert_firsttwo_arr = vert_nii.extract_label(first_key).get_seg_array().astype(np.uint8) + vert_firsttwo_arr[vert_nii.extract_label(second_key).get_seg_array() != 0] = 2 contacts = np_contacts(vert_firsttwo_arr, connectivity=3) - if contacts[(1, 2)] > isotropic_area_to_voxels(MERGED_VERTEBRA_MIN_CONTACT_MM2, vert_nii.zoom): + # cc3d only reports label pairs that actually touch. + if contacts.get((1, 2), 0) > isotropic_area_to_voxels(MERGED_VERTEBRA_MIN_CONTACT_MM2, vert_nii.zoom): logger.print("Found first two instance weird, will merge", Log_Type.STRANGE) vert_nii.map_labels_({first_key: second_key}, verbose=False) diff --git a/unit_tests/test_bugfixes.py b/unit_tests/test_bugfixes.py index 9f3deeb..3cab7b1 100644 --- a/unit_tests/test_bugfixes.py +++ b/unit_tests/test_bugfixes.py @@ -64,12 +64,35 @@ def _fixture() -> tuple[NII, NII]: seg[3:9, 37:39, 3:9] = 100 # an IVD below them, so the disc branch has something to find return _nii(seg), _nii(vert) + @staticmethod + def _detached_fixture() -> tuple[NII, NII]: + """Same shape as ``_fixture``, but with a gap between the two top instances. + + They never touch, so the split-C2 heuristic must leave them alone. The old encoding + (background 1, both vertebrae 2) measured the outer surface of the pair instead of their + mutual contact, so this case was merged too. + """ + shape = (12, 40, 12) + seg = np.zeros(shape, dtype=np.uint8) + vert = np.zeros(shape, dtype=np.uint8) + seg[3:9, 20:23, 3:9] = 49 # small instance 1 + vert[3:9, 20:23, 3:9] = 1 + seg[3:9, 27:37, 3:9] = 49 # large instance 2, four voxels below it + vert[3:9, 27:37, 3:9] = 2 + seg[3:9, 37:39, 3:9] = 100 # an IVD below them + return _nii(seg), _nii(vert) + def test_top_two_instances_are_merged(self): seg_nii, vert_nii = self._fixture() detect_and_solve_merged_vertebra(seg_nii, vert_nii) self.assertNotIn(1, vert_nii.unique(), "the small top instance should have been merged into its neighbour") self.assertIn(2, vert_nii.unique()) + def test_detached_top_instance_is_kept(self): + seg_nii, vert_nii = self._detached_fixture() + detect_and_solve_merged_vertebra(seg_nii, vert_nii) + self.assertEqual([1, 2], list(vert_nii.unique()), "instances that do not touch must not be merged") + class Test_Semantic_Bounding_Box_Clean(unittest.TestCase): """The region kept must be the union of the incorporated components' boxes, not just the largest."""