Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
14 changes: 9 additions & 5 deletions spineps/phase_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions unit_tests/test_bugfixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading