From e529c738e71ac091e8f4f4307b1323027713de51 Mon Sep 17 00:00:00 2001 From: iback Date: Tue, 26 May 2026 17:28:13 +0000 Subject: [PATCH 1/4] Add documentation badge and section to README Link to the new ReadTheDocs site (https://tptbox.readthedocs.io) from the badge row and a short Documentation section so users landing on GitHub know the full API reference exists. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8abcb7c..3b50cdd 100755 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![Stable Version](https://img.shields.io/pypi/v/tptbox?label=stable)](https://pypi.python.org/pypi/tptbox/) [![tests](https://github.com/Hendrik-code/TPTBox/actions/workflows/tests.yml/badge.svg)](https://github.com/Hendrik-code/TPTBox/actions/workflows/tests.yml) [![codecov](https://codecov.io/gh/Hendrik-code/TPTBox/graph/badge.svg?token=A7FWUKO9Y4)](https://codecov.io/gh/Hendrik-code/TPTBox) +[![Documentation](https://readthedocs.org/projects/tptbox/badge/?version=latest)](https://tptbox.readthedocs.io/en/latest/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) @@ -22,6 +23,14 @@ It can find, filter, search any BIDS_Family and subjects, and has many functiona - Logging everything consistently - ... +## Documentation + +Full API reference and usage guides are available at **https://tptbox.readthedocs.io**. + +The docs cover all sub-packages — `NII`, `POI`, `BIDS_FILE`, NumPy utilities, +vertebra constants, spine analysis, registration, segmentation, mesh3D, +stitching, and the logger — with hyperlinks back to the GitHub source. + ## Install the package ```bash conda create -n 3.10 python=3.10 @@ -93,7 +102,7 @@ Python function and script for arbitrary image stitching. [See Details](TPTBox/s ![Example of two lumbar vertebrae. The left example is derived from 1 mm isotropic CT, the right from sagittal MRI with a resolution of 3.3 mm in the left–right direction. Top row: Subregion of the vertebra used for analysis. Middle row: Extreme points. Bottom row: Corpus edge and ligamentum flavum points.](TPTBox/images/poi_preview.png) For our Spine segmentation pipline follow the installation of [SPINEPS](https://github.com/Hendrik-code/spineps). -Image Source: Rule-based Key-Point Extraction for MR-Guided Biomechanical Digital Twins of the Spine; +Image Source: Rule-based Key-Point Extraction for MR-Guided Biomechanical Digital Twins of the Spine; @@ -252,4 +261,4 @@ TBD > [!IMPORTANT] > Importantly ---> \ No newline at end of file +--> From a51873d73814e0191b4e70aa340aaa5d738866a3 Mon Sep 17 00:00:00 2001 From: iback Date: Tue, 26 May 2026 17:50:49 +0000 Subject: [PATCH 2/4] Add extended unit tests for NII, np_utils, and POI operations Adds three new test files increasing coverage of previously untested code paths in the NII wrapper, numpy utilities, and POI set operations: - test_nii_extended.py: 52 tests covering properties, dtype/copy ops, label manipulation, spatial transforms, smoothing, Euclidean morphology, reorientation, and rescaling. - test_nputils_extended.py: 36 tests covering np_is_empty, np_count_nonzero, np_unique*, np_bounding_boxes, np_contacts, np_region_graph, np_translate_arr, np_compute_surface, np_normalize_to_range, Euclidean erode/dilate, and label overlap. - test_poi_ops.py: 40 tests covering __len__/__contains__/__getitem__/ __setitem__, keys_region/subregion, items_2D/flatten, join_left/right, intersect, subtract, extract_subregion/region, remove, round, apply_all, calculate_distances_cord, and filter_points_inside_shape. Co-Authored-By: Claude Sonnet 4.6 --- unit_tests/test_nii_extended.py | 393 ++++++++++++++++++++++++++++ unit_tests/test_nputils_extended.py | 275 +++++++++++++++++++ unit_tests/test_poi_ops.py | 356 +++++++++++++++++++++++++ 3 files changed, 1024 insertions(+) create mode 100644 unit_tests/test_nii_extended.py create mode 100644 unit_tests/test_nputils_extended.py create mode 100644 unit_tests/test_poi_ops.py diff --git a/unit_tests/test_nii_extended.py b/unit_tests/test_nii_extended.py new file mode 100644 index 0000000..aafbfe2 --- /dev/null +++ b/unit_tests/test_nii_extended.py @@ -0,0 +1,393 @@ +"""Extended unit tests for NII: properties, label operations, spatial ops, and morphology.""" + +from __future__ import annotations + +import unittest + +import numpy as np + +from TPTBox import NII +from TPTBox.tests.test_utils import get_nii, get_random_ax_code, repeats + + +def _make_nii(arr: np.ndarray, seg: bool = True, zoom=(1.0, 1.0, 1.0)) -> NII: + affine = np.diag([*zoom, 1.0]) + return NII.from_numpy(arr, affine=affine, seg=seg) + + +def _make_seg(shape=(20, 20, 20), num_labels=3) -> NII: + arr = np.zeros(shape, dtype=np.uint8) + for i in range(1, num_labels + 1): + s = slice(i * 3, i * 3 + 4) + arr[s, s, s] = i + return _make_nii(arr) + + +def _make_two_label_seg() -> NII: + arr = np.zeros((20, 20, 20), dtype=np.uint8) + arr[1:6, 1:6, 1:6] = 1 + arr[10:15, 10:15, 10:15] = 2 + return _make_nii(arr) + + +class Test_NII_Properties(unittest.TestCase): + def test_from_numpy_shape(self): + arr = np.zeros((10, 12, 14), dtype=np.uint8) + nii = _make_nii(arr) + self.assertEqual(nii.shape, (10, 12, 14)) + + def test_from_numpy_seg_flag(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + self.assertTrue(_make_nii(arr, seg=True).seg) + self.assertFalse(_make_nii(arr, seg=False).seg) + + def test_from_numpy_round_trip(self): + arr = np.arange(27, dtype=np.int16).reshape(3, 3, 3) + nii = _make_nii(arr, seg=False) + np.testing.assert_array_equal(nii.get_array(), arr) + + def test_zoom_from_affine(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + nii = _make_nii(arr, zoom=(2.0, 3.0, 4.0)) + self.assertAlmostEqual(nii.zoom[0], 2.0) + self.assertAlmostEqual(nii.zoom[1], 3.0) + self.assertAlmostEqual(nii.zoom[2], 4.0) + + def test_voxel_volume(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + nii = _make_nii(arr, zoom=(2.0, 3.0, 4.0)) + self.assertAlmostEqual(nii.voxel_volume(), 24.0) + + def test_is_empty_true(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + self.assertTrue(_make_nii(arr).is_empty) + + def test_is_empty_false(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + arr[2, 2, 2] = 1 + self.assertFalse(_make_nii(arr).is_empty) + + def test_unique_returns_nonzero_labels(self): + nii = _make_seg(num_labels=4) + self.assertEqual(sorted(nii.unique()), [1, 2, 3, 4]) + + def test_unique_excludes_background(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + self.assertEqual(_make_nii(arr).unique(), []) + + def test_unique_after_modification(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[1:3, 1:3, 1:3] = 5 + nii = _make_nii(arr) + self.assertEqual(nii.unique(), [5]) + + def test_volumes_correct_counts(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[0:3, 0:3, 0:3] = 1 # 27 voxels + arr[5:7, 5:7, 5:7] = 2 # 8 voxels + vols = _make_nii(arr).volumes() + self.assertEqual(vols[1], 27) + self.assertEqual(vols[2], 8) + + def test_volumes_sum(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[0:4, 0:4, 0:4] = 1 + arr[5:8, 5:8, 5:8] = 2 + vols = _make_nii(arr).volumes() + self.assertEqual(sum(vols.values()), int((arr > 0).sum())) + + def test_center_of_masses_single_voxel(self): + arr = np.zeros((11, 11, 11), dtype=np.uint8) + arr[5, 6, 7] = 1 + coms = _make_nii(arr).center_of_masses() + self.assertIn(1, coms) + for coord, expected in zip(coms[1], (5.0, 6.0, 7.0)): + self.assertAlmostEqual(coord, expected, places=3) + + +class Test_NII_DTypeAndCopy(unittest.TestCase): + def test_set_dtype_float32(self): + arr = np.array([[[1, 2], [3, 4]]], dtype=np.uint8) + nii = _make_nii(arr).set_dtype(np.float32) + self.assertEqual(nii.get_array().dtype, np.float32) + np.testing.assert_array_almost_equal(nii.get_array(), arr.astype(np.float32)) + + def test_astype_alias(self): + arr = np.ones((4, 4, 4), dtype=np.uint8) + nii = _make_nii(arr, seg=False).astype(np.float64) + self.assertEqual(nii.get_array().dtype, np.float64) + + def test_set_dtype_preserves_values(self): + arr = np.arange(8, dtype=np.int16).reshape(2, 2, 2) + nii = _make_nii(arr, seg=False).set_dtype(np.float32) + np.testing.assert_array_equal(nii.get_array(), arr.astype(np.float32)) + + def test_copy_is_independent(self): + nii = _make_two_label_seg() + nii2 = nii.copy() + nii2.set_array_(nii2.get_array() + 1) + self.assertFalse(np.array_equal(nii.get_array(), nii2.get_array())) + + def test_clone_matches_copy(self): + nii = _make_two_label_seg() + np.testing.assert_array_equal(nii.copy().get_array(), nii.clone().get_array()) + + def test_set_array_replaces_data(self): + nii = _make_two_label_seg() + new_arr = np.ones((20, 20, 20), dtype=np.uint8) * 7 + nii2 = nii.set_array(new_arr) + np.testing.assert_array_equal(nii2.get_array(), new_arr) + + def test_set_array_non_inplace(self): + nii = _make_two_label_seg() + orig_arr = nii.get_array().copy() + new_arr = np.zeros((20, 20, 20), dtype=np.uint8) + nii.set_array(new_arr) + # original nii is unchanged (non-inplace) + np.testing.assert_array_equal(nii.get_array(), orig_arr) + + +class Test_NII_LabelOps(unittest.TestCase): + def test_extract_label_binary(self): + nii = _make_two_label_seg() + ext = nii.extract_label(1) + arr = ext.get_array() + orig = nii.get_array() + # label-2 region is zeroed + self.assertTrue(np.all(arr[orig == 2] == 0)) + # label-1 region is retained + self.assertTrue(np.all(arr[orig == 1] == 1)) + + def test_extract_label_list(self): + # extract_label always binarises: all matched labels → 1, rest → 0. + nii = _make_seg(num_labels=3) + ext = nii.extract_label([1, 3]) + labels = sorted(ext.unique()) + # Only 1 is present (both 1 and 3 become 1); 2 is excluded. + self.assertEqual(labels, [1]) + # Volume of extracted mask equals volume of labels 1 + 3 in original. + vols = nii.volumes() + self.assertEqual(int(ext.volumes().get(1, 0)), vols[1] + vols[3]) + + def test_remove_labels_zeros_out(self): + nii = _make_two_label_seg() + result = nii.remove_labels(1) + self.assertNotIn(1, result.unique()) + self.assertIn(2, result.unique()) + + def test_remove_labels_multiple(self): + nii = _make_seg(num_labels=3) + result = nii.remove_labels([1, 2]) + self.assertNotIn(1, result.unique()) + self.assertNotIn(2, result.unique()) + self.assertIn(3, result.unique()) + + def test_apply_mask_zeroes_outside(self): + arr = np.ones((10, 10, 10), dtype=np.uint8) + nii = _make_nii(arr) + mask_arr = np.zeros((10, 10, 10), dtype=np.uint8) + mask_arr[3:7, 3:7, 3:7] = 1 + mask = _make_nii(mask_arr) + result = nii.apply_mask(mask) + res_arr = result.get_array() + self.assertTrue(np.all(res_arr[:3, :, :] == 0)) + self.assertTrue(np.all(res_arr[3:7, 3:7, 3:7] == 1)) + + def test_map_labels_remaps_ids(self): + nii = _make_two_label_seg() + mapped = nii.map_labels({1: 10, 2: 20}) + self.assertIn(10, mapped.unique()) + self.assertIn(20, mapped.unique()) + self.assertNotIn(1, mapped.unique()) + self.assertNotIn(2, mapped.unique()) + + def test_map_labels_preserves_volume(self): + nii = _make_two_label_seg() + vols_before = nii.volumes() + mapped = nii.map_labels({1: 10, 2: 20}) + vols_after = mapped.volumes() + self.assertEqual(vols_before[1], vols_after[10]) + self.assertEqual(vols_before[2], vols_after[20]) + + def test_map_labels_inplace(self): + nii = _make_two_label_seg() + nii.map_labels_({1: 99}) + self.assertIn(99, nii.unique()) + self.assertNotIn(1, nii.unique()) + + +class Test_NII_Spatial(unittest.TestCase): + def test_flip_reverses_axis(self): + arr = np.zeros((10, 5, 5), dtype=np.uint8) + arr[0, :, :] = 1 # marker at start of axis 0 + nii = _make_nii(arr) + flipped = nii.flip(0) + arr_f = flipped.get_array() + self.assertTrue(np.all(arr_f[-1, :, :] == 1)) + self.assertTrue(np.all(arr_f[0, :, :] == 0)) + + def test_flip_double_is_identity(self): + arr = np.random.randint(0, 4, (10, 8, 6), dtype=np.uint8) + nii = _make_nii(arr) + np.testing.assert_array_equal(nii.flip(0).flip(0).get_array(), arr) + + def test_pad_to_increases_shape(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + arr[2, 2, 2] = 1 + nii = _make_nii(arr) + padded = nii.pad_to((10, 10, 10)) + self.assertEqual(padded.shape, (10, 10, 10)) + self.assertIn(1, padded.unique()) + + def test_compute_crop_reduces_shape(self): + arr = np.zeros((20, 20, 20), dtype=np.uint8) + arr[5:10, 5:10, 5:10] = 1 + nii = _make_nii(arr) + crop = nii.compute_crop() + cropped = nii.apply_crop(crop) + self.assertLessEqual(max(cropped.shape), 20) + self.assertIn(1, cropped.unique()) + + def test_get_histogram_total_count(self): + arr = np.array([[[1, 1, 2, 2, 3]]], dtype=np.uint8) + nii = _make_nii(arr) + counts, bins = nii.get_histogram() + self.assertEqual(int(counts.sum()), 5) + + def test_get_intersecting_volume_same_fov(self): + # get_intersecting_volume binarises b's entire field-of-view, then + # counts how many voxels in self's grid are within b's extent. + # Two images with the same shape and affine fully overlap. + arr = np.zeros((10, 10, 10), dtype=np.uint8) + nii1 = _make_nii(arr) + nii2 = _make_nii(arr) + self.assertEqual(nii1.get_intersecting_volume(nii2), 1000) + + def test_get_intersecting_volume_partial(self): + # Smaller image whose field-of-view is a strict subset → count < nii1's total voxels. + arr_large = np.zeros((10, 10, 10), dtype=np.uint8) + arr_small = np.zeros((5, 5, 5), dtype=np.uint8) + nii_large = _make_nii(arr_large) + # small image placed at origin covers 5x5x5 = 125 voxels in nii_large's 10x10x10 grid + nii_small = _make_nii(arr_small) + overlap = nii_large.get_intersecting_volume(nii_small) + self.assertEqual(overlap, 125) + + def test_boundary_mask_nonzero(self): + # boundary_mask uses threshold to distinguish foreground (>threshold) from background. + # threshold must be between 0 and the actual foreground value. + arr = np.zeros((15, 15, 15), dtype=np.uint8) + arr[3:12, 3:12, 3:12] = 1 + nii = _make_nii(arr, seg=True) + bm = nii.boundary_mask(threshold=0.5) + bm_arr = bm.get_array() + # Foreground voxels should appear in the result. + self.assertGreater(int((bm_arr != 0).sum()), 0) + + def test_compute_surface_mask_smaller_than_original(self): + arr = np.zeros((15, 15, 15), dtype=np.uint8) + arr[2:13, 2:13, 2:13] = 1 + nii = _make_nii(arr) + surface = nii.compute_surface_mask() + orig_vol = int((nii.get_array() > 0).sum()) + surf_vol = int((surface.get_array() > 0).sum()) + self.assertLess(surf_vol, orig_vol) + + +class Test_NII_Smoothing(unittest.TestCase): + def test_smooth_gaussian_same_shape(self): + arr = np.random.rand(10, 10, 10).astype(np.float32) + nii = _make_nii(arr, seg=False) + smoothed = nii.smooth_gaussian(sigma=1.0) + self.assertEqual(smoothed.shape, nii.shape) + + def test_smooth_gaussian_reduces_variance(self): + arr = np.random.rand(20, 20, 20).astype(np.float32) + nii = _make_nii(arr, seg=False) + smoothed = nii.smooth_gaussian(sigma=2.0) + self.assertLess(smoothed.get_array().std(), arr.std()) + + def test_smooth_gaussian_inplace(self): + arr = np.random.rand(10, 10, 10).astype(np.float32) + nii = _make_nii(arr, seg=False) + nii.smooth_gaussian_(sigma=1.0) + self.assertEqual(nii.shape, (10, 10, 10)) + + +class Test_NII_Morphology_Euclid(unittest.TestCase): + @staticmethod + def _sphere_seg(radius=6, shape=(25, 25, 25)): + cx, cy, cz = shape[0] // 2, shape[1] // 2, shape[2] // 2 + arr = np.zeros(shape, dtype=np.uint8) + xs, ys, zs = np.ogrid[: shape[0], : shape[1], : shape[2]] + arr[(xs - cx) ** 2 + (ys - cy) ** 2 + (zs - cz) ** 2 <= radius**2] = 1 + return _make_nii(arr) + + def test_erode_msk_euclid_reduces_volume(self): + nii = self._sphere_seg() + eroded = nii.erode_msk_euclid(n_pixel=2) + orig_vol = int((nii.get_array() > 0).sum()) + ero_vol = int((eroded.get_array() > 0).sum()) + self.assertLess(ero_vol, orig_vol) + + def test_dilate_msk_euclid_increases_volume(self): + nii = self._sphere_seg() + dilated = nii.dilate_msk_euclid(n_pixel=2) + orig_vol = int((nii.get_array() > 0).sum()) + dil_vol = int((dilated.get_array() > 0).sum()) + self.assertGreater(dil_vol, orig_vol) + + def test_erode_then_dilate_is_subset(self): + nii = self._sphere_seg() + processed = nii.erode_msk_euclid(n_pixel=2).dilate_msk_euclid(n_pixel=2) + orig_arr = nii.get_array() + proc_arr = processed.get_array() + # no new voxels should appear outside the original mask + self.assertTrue(np.all(proc_arr[orig_arr == 0] == 0)) + + def test_erode_msk_euclid_inplace(self): + nii = self._sphere_seg() + orig_vol = int((nii.get_array() > 0).sum()) + nii.erode_msk_euclid(n_pixel=2, inplace=True) + ero_vol = int((nii.get_array() > 0).sum()) + self.assertLess(ero_vol, orig_vol) + + def test_dilate_msk_euclid_inplace(self): + nii = self._sphere_seg() + orig_vol = int((nii.get_array() > 0).sum()) + nii.dilate_msk_euclid(n_pixel=2, inplace=True) + dil_vol = int((nii.get_array() > 0).sum()) + self.assertGreater(dil_vol, orig_vol) + + def test_erode_msk_euclid_zero_pixel_unchanged(self): + nii = self._sphere_seg() + orig_arr = nii.get_array().copy() + eroded = nii.erode_msk_euclid(n_pixel=0) + np.testing.assert_array_equal(eroded.get_array(), orig_arr) + + +class Test_NII_Reorient_Rescale(unittest.TestCase): + """Verify that reorient/rescale leave the affine consistent after round-trips.""" + + def test_reorient_round_trip(self): + for _ in range(repeats): + with self.subTest(): + msk, _, _, _ = get_nii() + ax1 = get_random_ax_code() + ax2 = get_random_ax_code() + msk2 = msk.reorient(ax1).reorient(ax2).reorient(msk.orientation) + self.assertEqual(msk2.orientation, msk.orientation) + self.assertEqual(msk2.shape, msk.shape) + + def test_rescale_round_trip_shape(self): + arr = np.zeros((10, 12, 14), dtype=np.uint8) + arr[3:7, 3:9, 3:11] = 1 + nii = _make_nii(arr, zoom=(1.0, 1.0, 1.0)) + nii2 = nii.rescale((2.0, 2.0, 2.0)).rescale((1.0, 1.0, 1.0)) + # shape should approximately return to original after double rescale + for orig_s, new_s in zip(nii.shape, nii2.shape): + self.assertAlmostEqual(orig_s, new_s, delta=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/unit_tests/test_nputils_extended.py b/unit_tests/test_nputils_extended.py new file mode 100644 index 0000000..b8e0814 --- /dev/null +++ b/unit_tests/test_nputils_extended.py @@ -0,0 +1,275 @@ +"""Extended unit tests for np_utils: contacts, bounding boxes, surface, translate, normalize, euclid ops.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +file = Path(__file__).resolve() +sys.path.append(str(file.parents[2])) + +import numpy as np # noqa: E402 + +from TPTBox.core import np_utils # noqa: E402 + + +def _make_two_block_array() -> np.ndarray: + """3-D array with two separate blocks of label 1 and one block of label 2.""" + arr = np.zeros((15, 15, 15), dtype=np.uint8) + arr[1:5, 1:5, 1:5] = 1 # block A, label 1 + arr[9:13, 9:13, 9:13] = 1 # block B, label 1 + arr[1:5, 9:13, 1:5] = 2 # block C, label 2 + return arr + + +class Test_np_is_empty(unittest.TestCase): + def test_empty_array(self): + self.assertTrue(np_utils.np_is_empty(np.zeros((5, 5, 5), dtype=np.uint8))) + + def test_nonempty_array(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + arr[2, 2, 2] = 1 + self.assertFalse(np_utils.np_is_empty(arr)) + + def test_all_ones(self): + self.assertFalse(np_utils.np_is_empty(np.ones((3, 3), dtype=np.uint8))) + + +class Test_np_count_nonzero(unittest.TestCase): + def test_zeros(self): + self.assertEqual(np_utils.np_count_nonzero(np.zeros((4, 4), dtype=np.uint8)), 0) + + def test_some_nonzero(self): + arr = np.zeros((5, 5), dtype=np.uint8) + arr[0, 0] = 7 + arr[1, 2] = 3 + self.assertEqual(np_utils.np_count_nonzero(arr), 2) + + def test_all_nonzero(self): + arr = np.ones((3, 3, 3), dtype=np.uint16) + self.assertEqual(np_utils.np_count_nonzero(arr), 27) + + +class Test_np_unique(unittest.TestCase): + def test_basic(self): + arr = np.array([[[0, 1, 2], [2, 3, 0]]], dtype=np.uint8) + result = np_utils.np_unique(arr) + self.assertEqual(sorted(result), [0, 1, 2, 3]) + + def test_all_same(self): + arr = np.full((4, 4), 5, dtype=np.uint8) + self.assertEqual(np_utils.np_unique(arr), [5]) + + def test_unique_withoutzero(self): + arr = np.array([0, 1, 2, 0, 3, 1], dtype=np.uint8) + result = np_utils.np_unique_withoutzero(arr) + self.assertEqual(sorted(result), [1, 2, 3]) + + def test_unique_withoutzero_all_zeros(self): + arr = np.zeros((3, 3), dtype=np.uint8) + self.assertEqual(np_utils.np_unique_withoutzero(arr), []) + + +class Test_np_bounding_boxes(unittest.TestCase): + def test_single_label_size(self): + # Bounding boxes are in the coordinate frame of the internally cropped array, + # so we only verify that the box spans the correct number of voxels per axis. + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[2:6, 3:7, 1:4] = 1 # size (4, 4, 3) + boxes = np_utils.np_bounding_boxes(arr) + self.assertIn(1, boxes) + s0, s1, s2 = boxes[1] + self.assertEqual(s0.stop - s0.start, 4) + self.assertEqual(s1.stop - s1.start, 4) + self.assertEqual(s2.stop - s2.start, 3) + + def test_multiple_labels(self): + arr = _make_two_block_array() + boxes = np_utils.np_bounding_boxes(arr) + self.assertIn(1, boxes) + self.assertIn(2, boxes) + self.assertNotIn(0, boxes) + + def test_empty_array(self): + arr = np.zeros((5, 5, 5), dtype=np.uint8) + boxes = np_utils.np_bounding_boxes(arr) + self.assertEqual(boxes, {}) + + +class Test_np_contacts(unittest.TestCase): + def test_adjacent_labels_contact(self): + arr = np.array([[[1, 1, 2, 2]]], dtype=np.uint8) + contacts = np_utils.np_contacts(arr, connectivity=1) + self.assertIn((1, 2), contacts) + self.assertGreater(contacts[(1, 2)], 0) + + def test_non_adjacent_no_contact(self): + arr = np.array([[[1, 0, 0, 2]]], dtype=np.uint8) + contacts = np_utils.np_contacts(arr, connectivity=1) + self.assertNotIn((1, 2), contacts) + + def test_contact_key_ordering(self): + # np_contacts returns only one direction: (lower, higher) or (higher, lower) + # — we just verify the pair exists in some order and has a positive count. + arr = np.array([[[1, 2]]], dtype=np.uint8) + contacts = np_utils.np_contacts(arr, connectivity=1) + exists = (1, 2) in contacts or (2, 1) in contacts + self.assertTrue(exists) + count = contacts.get((1, 2), contacts.get((2, 1), 0)) + self.assertGreater(count, 0) + + +class Test_np_region_graph(unittest.TestCase): + def test_adjacent_connected(self): + arr = np.array([[[1, 1, 2, 2]]], dtype=np.uint8) + graph = np_utils.np_region_graph(arr, connectivity=1) + self.assertIn((1, 2), graph) + + def test_empty_graph(self): + arr = np.zeros((4, 4, 4), dtype=np.uint8) + arr[0:2, 0:2, 0:2] = 1 + graph = np_utils.np_region_graph(arr, connectivity=1) + # only one label → no edges between labels + for a, b in graph: + self.assertNotEqual(a, b) + + +class Test_np_translate_arr(unittest.TestCase): + def test_shift_2d(self): + arr = np.zeros((10, 10), dtype=np.uint8) + arr[3, 3] = 1 + shifted = np_utils.np_translate_arr(arr, (2, 2)) + self.assertEqual(shifted[5, 5], 1) + self.assertEqual(shifted[3, 3], 0) + + def test_shift_3d(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[2, 2, 2] = 5 + shifted = np_utils.np_translate_arr(arr, (1, 1, 1)) + self.assertEqual(shifted[3, 3, 3], 5) + self.assertEqual(shifted[2, 2, 2], 0) + + def test_negative_shift(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[5, 5, 5] = 9 + shifted = np_utils.np_translate_arr(arr, (-2, 0, 0)) + self.assertEqual(shifted[3, 5, 5], 9) + + def test_shape_preserved(self): + arr = np.ones((8, 9, 10), dtype=np.uint8) + shifted = np_utils.np_translate_arr(arr, (1, 2, 3)) + self.assertEqual(shifted.shape, arr.shape) + + +class Test_np_compute_surface(unittest.TestCase): + def test_surface_subset_of_mask(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[2:8, 2:8, 2:8] = 1 + surface = np_utils.np_compute_surface(arr) + # surface voxels only where mask is + self.assertTrue(np.all(surface[arr == 0] == 0)) + + def test_surface_nonzero(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[2:8, 2:8, 2:8] = 1 + surface = np_utils.np_compute_surface(arr) + self.assertGreater(np_utils.np_count_nonzero(surface), 0) + + def test_dilated_surface_larger(self): + arr = np.zeros((12, 12, 12), dtype=np.uint8) + arr[3:9, 3:9, 3:9] = 1 + surf_normal = np_utils.np_compute_surface(arr, dilated_surface=False) + surf_dilated = np_utils.np_compute_surface(arr, dilated_surface=True) + # dilated surface must cover at least as many voxels + self.assertGreaterEqual(np_utils.np_count_nonzero(surf_dilated), np_utils.np_count_nonzero(surf_normal)) + + +class Test_np_normalize_to_range(unittest.TestCase): + def test_min_shifted_to_zero(self): + # After normalization, the minimum value should be ≥ min_value (it is shifted exactly to min_value). + arr = np.array([-100.0, 0.0, 500.0, 1000.0, 2000.0], dtype=np.float32) + out = np_utils.np_normalize_to_range(arr, min_value=0, max_value=1500) + self.assertAlmostEqual(float(out.min()), 0.0, places=3) + + def test_identity_in_range(self): + # Array already in [0, max_value] should not be scaled down. + arr = np.array([0.0, 500.0, 1000.0, 1500.0], dtype=np.float32) + out = np_utils.np_normalize_to_range(arr, min_value=0, max_value=1500) + np.testing.assert_allclose(out, arr, atol=1e-3) + + def test_scales_down_when_max_exceeded(self): + # Old max 2000 > max_value 1000 → values should be scaled; check min stays 0. + arr = np.array([0.0, 500.0, 1000.0, 2000.0], dtype=np.float32) + out = np_utils.np_normalize_to_range(arr, min_value=0, max_value=1000) + self.assertAlmostEqual(float(out.min()), 0.0, places=3) + self.assertLessEqual(float(out.max()), 1000.0 + 1e-3) + + def test_shape_preserved(self): + arr = np.random.rand(5, 6, 7).astype(np.float32) * 2000 - 500 + out = np_utils.np_normalize_to_range(arr) + self.assertEqual(out.shape, arr.shape) + + +class Test_np_erode_dilate_euclid(unittest.TestCase): + def test_erode_shrinks(self): + arr = np.zeros((20, 20, 20), dtype=np.uint8) + arr[4:16, 4:16, 4:16] = 1 + volume_before = np_utils.np_volume(arr) + eroded = np_utils.np_erode_msk_euclid(arr, n_pixel=2) + volume_after = np_utils.np_volume(eroded) + self.assertLess(volume_after.get(1, 0), volume_before[1]) + + def test_dilate_grows(self): + arr = np.zeros((20, 20, 20), dtype=np.uint8) + arr[7:13, 7:13, 7:13] = 1 + volume_before = np_utils.np_volume(arr) + dilated = np_utils.np_dilate_msk_euclid(arr, n_pixel=2) + volume_after = np_utils.np_volume(dilated) + self.assertGreater(volume_after.get(1, 0), volume_before[1]) + + def test_erode_then_dilate_subset(self): + arr = np.zeros((20, 20, 20), dtype=np.uint8) + arr[3:17, 3:17, 3:17] = 1 + eroded = np_utils.np_erode_msk_euclid(arr, n_pixel=2) + dilated = np_utils.np_dilate_msk_euclid(eroded, n_pixel=2) + # after erode+dilate, result should be subset of original + self.assertTrue(np.all(dilated[arr == 0] == 0)) + + def test_multilabel_euclid(self): + arr = np.zeros((20, 20, 20), dtype=np.uint8) + arr[2:9, 2:9, 2:9] = 1 + arr[11:18, 11:18, 11:18] = 2 + vol_before = np_utils.np_volume(arr) + dilated = np_utils.np_dilate_msk_euclid(arr, n_pixel=1) + vol_after = np_utils.np_volume(dilated) + self.assertGreater(vol_after.get(1, 0), vol_before[1]) + self.assertGreater(vol_after.get(2, 0), vol_before[2]) + + def test_zero_pixels_noop(self): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[3:7, 3:7, 3:7] = 1 + result = np_utils.np_erode_msk_euclid(arr, n_pixel=0) + np.testing.assert_array_equal(result, arr) + + +class Test_np_calc_overlapping_labels(unittest.TestCase): + def test_overlap_detected(self): + a = np.zeros((10, 10, 10), dtype=np.uint8) + b = np.zeros((10, 10, 10), dtype=np.uint8) + a[2:8, 2:8, 2:8] = 1 + b[4:9, 4:9, 4:9] = 2 + result = np_utils.np_calc_overlapping_labels(a, b) + self.assertIn((1, 2), result) + + def test_no_overlap(self): + a = np.zeros((10, 10, 10), dtype=np.uint8) + b = np.zeros((10, 10, 10), dtype=np.uint8) + a[0:3, 0:3, 0:3] = 1 + b[7:10, 7:10, 7:10] = 2 + result = np_utils.np_calc_overlapping_labels(a, b) + self.assertNotIn((1, 2), result) + + +if __name__ == "__main__": + unittest.main() diff --git a/unit_tests/test_poi_ops.py b/unit_tests/test_poi_ops.py new file mode 100644 index 0000000..6db0e2f --- /dev/null +++ b/unit_tests/test_poi_ops.py @@ -0,0 +1,356 @@ +"""Extended unit tests for POI: set operations, accessors, coordinate transforms.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +file = Path(__file__).resolve() +sys.path.append(str(file.parents[2])) + +import random # noqa: E402 + +import numpy as np # noqa: E402 + +from TPTBox.core.poi import POI # noqa: E402 +from TPTBox.tests.test_utils import get_poi, repeats # noqa: E402 + + +def _simple_poi(*points: tuple[int, int, float, float, float]) -> POI: + """Build a POI with identity affine and integer coordinates. + + Points are given as (region, subregion, x, y, z) tuples. + """ + d: dict[int, dict[int, tuple[float, float, float]]] = {} + for region, subregion, x, y, z in points: + d.setdefault(region, {})[subregion] = (x, y, z) + return POI(d, orientation=("R", "A", "S"), zoom=(1, 1, 1), shape=(100, 100, 100), origin=(0, 0, 0), rotation=np.eye(3)) + + +class Test_POI_Accessors(unittest.TestCase): + def test_len(self): + p = _simple_poi((1, 50, 1, 2, 3), (2, 50, 4, 5, 6), (3, 60, 7, 8, 9)) + self.assertEqual(len(p), 3) + + def test_len_empty(self): + p = POI({}, orientation=("R", "A", "S"), zoom=(1, 1, 1), shape=(10, 10, 10), origin=(0, 0, 0), rotation=np.eye(3)) + self.assertEqual(len(p), 0) + + def test_contains_present(self): + p = _simple_poi((1, 50, 1, 2, 3)) + self.assertIn((1, 50), p) + + def test_contains_absent(self): + p = _simple_poi((1, 50, 1, 2, 3)) + self.assertNotIn((2, 50), p) + self.assertNotIn((1, 99), p) + + def test_getitem(self): + p = _simple_poi((5, 10, 1.5, 2.5, 3.5)) + coord = p[5, 10] + self.assertAlmostEqual(coord[0], 1.5) + self.assertAlmostEqual(coord[1], 2.5) + self.assertAlmostEqual(coord[2], 3.5) + + def test_setitem_overwrites(self): + p = _simple_poi((1, 1, 0, 0, 0)) + p[1, 1] = (9, 8, 7) + coord = p[1, 1] + self.assertAlmostEqual(coord[0], 9) + self.assertAlmostEqual(coord[1], 8) + self.assertAlmostEqual(coord[2], 7) + + def test_setitem_new_entry(self): + p = _simple_poi((1, 1, 0, 0, 0)) + p[2, 5] = (3, 3, 3) + self.assertIn((2, 5), p) + self.assertEqual(len(p), 2) + + def test_keys_region(self): + p = _simple_poi((1, 50, 0, 0, 0), (2, 50, 1, 1, 1), (1, 60, 2, 2, 2)) + regions = p.keys_region() + self.assertIn(1, regions) + self.assertIn(2, regions) + self.assertNotIn(3, regions) + + def test_keys_subregion(self): + p = _simple_poi((1, 50, 0, 0, 0), (2, 60, 1, 1, 1), (3, 50, 2, 2, 2)) + subregs = p.keys_subregion() + self.assertIn(50, subregs) + self.assertIn(60, subregs) + self.assertNotIn(70, subregs) + + def test_items_2d_structure(self): + p = _simple_poi((1, 50, 0, 0, 0), (1, 60, 1, 1, 1), (2, 50, 2, 2, 2)) + items = dict(p.items_2D()) + self.assertIn(1, items) + self.assertIn(2, items) + self.assertIn(50, items[1]) + self.assertIn(60, items[1]) + + def test_items_flatten(self): + # items_flatten yields (packed_int, coordinate) pairs where + # packed_int = subregion * LABEL_MAX + region (legacy encoding). + p = _simple_poi((1, 50, 7, 8, 9), (2, 60, 1, 2, 3)) + flat = list(p.items_flatten()) + self.assertEqual(len(flat), 2) + # Coordinates should still be recoverable via the packed key. + coords_by_packed = dict(flat) + # Verify one known coord is reachable via the packed index. + for packed, coord in coords_by_packed.items(): + retrieved = p[packed] + np.testing.assert_allclose(retrieved, coord, atol=1e-6) + + +class Test_POI_SetOps(unittest.TestCase): + def test_join_left_no_overwrite(self): + p1 = _simple_poi((1, 50, 1, 1, 1)) + p2 = _simple_poi((1, 50, 9, 9, 9), (2, 50, 2, 2, 2)) + result = p1.join_left(p2) + # (1,50) should remain from p1, not overwritten by p2 + coord = result[1, 50] + self.assertAlmostEqual(coord[0], 1) + # (2,50) should be added from p2 + self.assertIn((2, 50), result) + self.assertEqual(len(result), 2) + + def test_join_right_overwrites(self): + p1 = _simple_poi((1, 50, 1, 1, 1)) + p2 = _simple_poi((1, 50, 9, 9, 9), (2, 50, 2, 2, 2)) + result = p1.join_right(p2) + # (1,50) should be overwritten by p2 + coord = result[1, 50] + self.assertAlmostEqual(coord[0], 9) + self.assertIn((2, 50), result) + + def test_add_operator(self): + p1 = _simple_poi((1, 50, 0, 0, 0)) + p2 = _simple_poi((2, 50, 1, 1, 1)) + result = p1 + p2 + self.assertIn((1, 50), result) + self.assertIn((2, 50), result) + + def test_lshift_operator(self): + p1 = _simple_poi((1, 50, 0, 0, 0)) + p2 = _simple_poi((2, 60, 5, 5, 5)) + result = p1 << p2 + self.assertIn((2, 60), result) + + def test_intersect_keeps_common(self): + p1 = _simple_poi((1, 50, 0, 0, 0), (2, 50, 1, 1, 1), (3, 50, 2, 2, 2)) + p2 = _simple_poi((2, 50, 9, 9, 9), (3, 50, 8, 8, 8), (4, 50, 7, 7, 7)) + result = p1.intersect(p2) + self.assertIn((2, 50), result) + self.assertIn((3, 50), result) + self.assertNotIn((1, 50), result) + self.assertNotIn((4, 50), result) + + def test_intersect_empty(self): + p1 = _simple_poi((1, 50, 0, 0, 0)) + p2 = _simple_poi((2, 60, 1, 1, 1)) + result = p1.intersect(p2) + self.assertEqual(len(result), 0) + + def test_subtract_removes_matching(self): + p1 = _simple_poi((1, 50, 0, 0, 0), (2, 50, 1, 1, 1), (3, 50, 2, 2, 2)) + p2 = _simple_poi((2, 50, 9, 9, 9)) + result = p1.subtract(p2) + self.assertIn((1, 50), result) + self.assertNotIn((2, 50), result) + self.assertIn((3, 50), result) + + def test_join_left_inplace(self): + p1 = _simple_poi((1, 50, 1, 1, 1)) + p2 = _simple_poi((2, 50, 2, 2, 2)) + p1.join_left_(p2) + self.assertIn((2, 50), p1) + + def test_intersect_inplace(self): + p1 = _simple_poi((1, 50, 0, 0, 0), (2, 50, 1, 1, 1)) + p2 = _simple_poi((2, 50, 9, 9, 9)) + p1.intersect_(p2) + self.assertNotIn((1, 50), p1) + self.assertIn((2, 50), p1) + + +class Test_POI_Extract(unittest.TestCase): + def test_extract_subregion(self): + p = _simple_poi((1, 50, 0, 0, 0), (1, 60, 1, 1, 1), (2, 50, 2, 2, 2)) + p2 = p.extract_subregion(50) + self.assertIn((1, 50), p2) + self.assertIn((2, 50), p2) + self.assertNotIn((1, 60), p2) + + def test_extract_subregion_multiple(self): + p = _simple_poi((1, 50, 0, 0, 0), (1, 60, 1, 1, 1), (2, 70, 2, 2, 2)) + p2 = p.extract_subregion(50, 60) + self.assertIn((1, 50), p2) + self.assertIn((1, 60), p2) + self.assertNotIn((2, 70), p2) + + def test_extract_region(self): + p = _simple_poi((1, 50, 0, 0, 0), (2, 50, 1, 1, 1), (3, 50, 2, 2, 2)) + p2 = p.extract_region(2) + self.assertNotIn((1, 50), p2) + self.assertIn((2, 50), p2) + self.assertNotIn((3, 50), p2) + + def test_extract_subregion_inplace(self): + p = _simple_poi((1, 50, 0, 0, 0), (1, 60, 1, 1, 1)) + p.extract_subregion_(50) + self.assertIn((1, 50), p) + self.assertNotIn((1, 60), p) + + def test_extract_region_inplace(self): + p = _simple_poi((1, 50, 0, 0, 0), (2, 50, 1, 1, 1)) + p.extract_region_(1) + self.assertIn((1, 50), p) + self.assertNotIn((2, 50), p) + + +class Test_POI_Remove(unittest.TestCase): + def test_remove_existing(self): + p = _simple_poi((1, 50, 0, 0, 0), (2, 60, 1, 1, 1)) + p2 = p.remove((1, 50)) + self.assertNotIn((1, 50), p2) + self.assertIn((2, 60), p2) + # original unchanged + self.assertIn((1, 50), p) + + def test_remove_inplace(self): + p = _simple_poi((1, 50, 0, 0, 0), (2, 60, 1, 1, 1)) + p.remove_((2, 60)) + self.assertNotIn((2, 60), p) + self.assertIn((1, 50), p) + + def test_remove_centroid(self): + p = _simple_poi((1, 50, 0, 0, 0), (1, 60, 1, 1, 1)) + p2 = p.remove_centroid((1, 50)) + self.assertNotIn((1, 50), p2) + self.assertIn((1, 60), p2) + + +class Test_POI_Round(unittest.TestCase): + def test_round_coordinates(self): + p = _simple_poi((1, 50, 1.23456, 2.34567, 3.45678)) + p2 = p.round(2) + coord = p2[1, 50] + self.assertAlmostEqual(coord[0], 1.23, places=5) + self.assertAlmostEqual(coord[1], 2.35, places=5) + self.assertAlmostEqual(coord[2], 3.46, places=5) + + def test_round_inplace(self): + p = _simple_poi((1, 50, 1.555, 2.444, 3.111)) + p.round_(1) + coord = p[1, 50] + self.assertAlmostEqual(coord[0], 1.6, places=5) + self.assertAlmostEqual(coord[1], 2.4, places=5) + self.assertAlmostEqual(coord[2], 3.1, places=5) + + def test_round_does_not_mutate_original(self): + p = _simple_poi((1, 50, 1.23456, 2.34567, 3.45678)) + _ = p.round(2) + coord = p[1, 50] + self.assertAlmostEqual(coord[0], 1.23456, places=4) + + +class Test_POI_ApplyAll(unittest.TestCase): + def test_apply_all_translate(self): + p = _simple_poi((1, 50, 1, 2, 3), (2, 50, 4, 5, 6)) + p2 = p.apply_all(lambda x, y, z: (x + 10, y + 10, z + 10)) + self.assertAlmostEqual(p2[1, 50][0], 11) + self.assertAlmostEqual(p2[2, 50][1], 15) + + def test_apply_all_inplace(self): + p = _simple_poi((1, 50, 1, 2, 3)) + p.apply_all(lambda x, y, z: (x * 2, y * 2, z * 2), inplace=True) + self.assertAlmostEqual(p[1, 50][0], 2) + self.assertAlmostEqual(p[1, 50][1], 4) + self.assertAlmostEqual(p[1, 50][2], 6) + + def test_apply_all_not_inplace_preserves_original(self): + p = _simple_poi((1, 50, 5, 5, 5)) + _ = p.apply_all(lambda x, y, z: (x + 100, y, z)) + self.assertAlmostEqual(p[1, 50][0], 5) + + +class Test_POI_DistanceCord(unittest.TestCase): + def test_distance_to_self(self): + p = _simple_poi((1, 50, 3, 4, 0)) + dists = p.calculate_distances_cord((3, 4, 0)) + self.assertAlmostEqual(dists[(1, 50)], 0.0, places=5) + + def test_distance_known_value(self): + p = _simple_poi((1, 50, 0, 0, 0)) + # distance to (3, 4, 0) should be 5 + dists = p.calculate_distances_cord((3, 4, 0)) + self.assertAlmostEqual(dists[(1, 50)], 5.0, places=5) + + def test_distance_multiple_points(self): + p = _simple_poi((1, 50, 0, 0, 0), (2, 50, 10, 0, 0)) + dists = p.calculate_distances_cord((5, 0, 0)) + self.assertAlmostEqual(dists[(1, 50)], 5.0, places=5) + self.assertAlmostEqual(dists[(2, 50)], 5.0, places=5) + + +class Test_POI_FilterInsideShape(unittest.TestCase): + def test_all_inside(self): + p = _simple_poi((1, 50, 10, 10, 10), (2, 50, 50, 50, 50)) + p2 = p.filter_points_inside_shape() + self.assertEqual(len(p2), 2) + + def test_outside_filtered(self): + p = _simple_poi((1, 50, 10, 10, 10), (2, 50, 200, 200, 200)) + p2 = p.filter_points_inside_shape() + self.assertIn((1, 50), p2) + self.assertNotIn((2, 50), p2) + + def test_inplace(self): + p = _simple_poi((1, 50, 5, 5, 5), (2, 50, 500, 500, 500)) + p.filter_points_inside_shape(inplace=True) + self.assertIn((1, 50), p) + self.assertNotIn((2, 50), p) + + +class Test_POI_Random(unittest.TestCase): + def test_len_matches_inserted(self): + for _ in range(repeats): + n = random.randint(1, 20) + p = get_poi(num_vert=n, num_subreg=1) + self.assertEqual(len(p), n) + + def test_keys_region_count(self): + for _ in range(repeats): + n = random.randint(2, 15) + p = get_poi(num_vert=n, num_subreg=1) + regions = p.keys_region() + self.assertEqual(len(regions), n) + + def test_round_trip_join(self): + for _ in range(repeats): + p1 = get_poi(num_vert=5, num_subreg=1, max_subreg=50) + p2 = get_poi(num_vert=5, num_subreg=1, min_subreg=51, max_subreg=100) + p1.origin = p2.origin + p1.rotation = p2.rotation + p1.zoom = p2.zoom + p1.shape = p2.shape + combined = p1.join_left(p2) + # intersect with p1 should recover p1 only + recovered = combined.intersect(p1) + for k, _ in p1.items_flatten(): + self.assertIn(k, recovered) + + def test_subtract_disjoint(self): + p1 = get_poi(num_vert=5, num_subreg=1, max_subreg=50) + p2 = get_poi(num_vert=5, num_subreg=1, min_subreg=51, max_subreg=100) + p2.origin = p1.origin + p2.rotation = p1.rotation + p2.zoom = p1.zoom + p2.shape = p1.shape + result = p1.subtract(p2) + self.assertEqual(len(result), len(p1)) + + +if __name__ == "__main__": + unittest.main() From 59860ffb14cc7a174f69a5a0ae6a20f37c7aa6cc Mon Sep 17 00:00:00 2001 From: iback Date: Tue, 26 May 2026 18:04:35 +0000 Subject: [PATCH 3/4] Add module overview table to README and sub-README pages to docs README.md: insert a Modules table between the Documentation and Install sections linking each sub-package to its own README file. mkdocs.yml: add a Modules nav section (10 pages) and configure pymdownx.snippets base_path so wrappers can include source files from the repo root. docs/modules/: create thin wrapper pages for core, poi_fun, logger, mesh3d, registration, segmentation, spine, snapshot2d, spinestats, and stitching. Nine of them pull content via --8<-- snippets directly from the existing sub-package READMEs; the stitching wrapper is inlined to fix the relative image path to a GitHub raw URL. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +++++++ docs/modules/core.md | 1 + docs/modules/logger.md | 1 + docs/modules/mesh3d.md | 1 + docs/modules/poi_fun.md | 1 + docs/modules/registration.md | 1 + docs/modules/segmentation.md | 1 + docs/modules/snapshot2d.md | 1 + docs/modules/spine.md | 27 +++++++++++ docs/modules/spinestats.md | 1 + docs/modules/stitching.md | 90 ++++++++++++++++++++++++++++++++++++ mkdocs.yml | 14 +++++- 12 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 docs/modules/core.md create mode 100644 docs/modules/logger.md create mode 100644 docs/modules/mesh3d.md create mode 100644 docs/modules/poi_fun.md create mode 100644 docs/modules/registration.md create mode 100644 docs/modules/segmentation.md create mode 100644 docs/modules/snapshot2d.md create mode 100644 docs/modules/spine.md create mode 100644 docs/modules/spinestats.md create mode 100644 docs/modules/stitching.md diff --git a/README.md b/README.md index 3b50cdd..8115a43 100755 --- a/README.md +++ b/README.md @@ -31,6 +31,23 @@ The docs cover all sub-packages — `NII`, `POI`, `BIDS_FILE`, NumPy utilities, vertebra constants, spine analysis, registration, segmentation, mesh3D, stitching, and the logger — with hyperlinks back to the GitHub source. +## Modules + +Each sub-package has its own README with API tables and examples: + +| Module | Description | +|---|---| +| [`core`](TPTBox/core/README.md) | `NII` (NIfTI I/O and transforms), `POI` (anatomical landmarks), BIDS dataset navigation, NumPy utilities, vertebra constants | +| [`core/poi_fun`](TPTBox/core/poi_fun/README.md) | Internal POI computation strategies (surface points, corpus centers, disc points) | +| [`spine`](TPTBox/spine/README.md) | Spine-specific tools: 2D snapshot generation and statistical measurements | +| [`spine/snapshot2D`](TPTBox/spine/snapshot2D/README.md) | Modular 2D image generation — axial/sagittal/coronal slices, MIPs, segmentation overlays | +| [`spine/spinestats`](TPTBox/spine/spinestats/README.md) | Clinical spine measurements: distances, angles, disc heights, IVD landmarks | +| [`registration`](TPTBox/registration/README.md) | Rigid and deformable image registration via ANTs and DeepALI | +| [`segmentation`](TPTBox/segmentation/README.md) | Integration with SPINEPS, VibeSeg/TotalVibeSeg, and nnU-Net pipelines | +| [`mesh3D`](TPTBox/mesh3D/README.md) | 3D surface mesh generation and rendering from segmentation volumes | +| [`stitching`](TPTBox/stitching/README.md) | Multi-station NIfTI stitching for whole-body or long-spine acquisitions | +| [`logger`](TPTBox/logger/README.md) | Structured, consistent logging for medical image processing pipelines | + ## Install the package ```bash conda create -n 3.10 python=3.10 diff --git a/docs/modules/core.md b/docs/modules/core.md new file mode 100644 index 0000000..505156c --- /dev/null +++ b/docs/modules/core.md @@ -0,0 +1 @@ +--8<-- "TPTBox/core/README.md" diff --git a/docs/modules/logger.md b/docs/modules/logger.md new file mode 100644 index 0000000..61e4d9e --- /dev/null +++ b/docs/modules/logger.md @@ -0,0 +1 @@ +--8<-- "TPTBox/logger/README.md" diff --git a/docs/modules/mesh3d.md b/docs/modules/mesh3d.md new file mode 100644 index 0000000..823a861 --- /dev/null +++ b/docs/modules/mesh3d.md @@ -0,0 +1 @@ +--8<-- "TPTBox/mesh3D/README.md" diff --git a/docs/modules/poi_fun.md b/docs/modules/poi_fun.md new file mode 100644 index 0000000..8323e1a --- /dev/null +++ b/docs/modules/poi_fun.md @@ -0,0 +1 @@ +--8<-- "TPTBox/core/poi_fun/README.md" diff --git a/docs/modules/registration.md b/docs/modules/registration.md new file mode 100644 index 0000000..35d9276 --- /dev/null +++ b/docs/modules/registration.md @@ -0,0 +1 @@ +--8<-- "TPTBox/registration/README.md" diff --git a/docs/modules/segmentation.md b/docs/modules/segmentation.md new file mode 100644 index 0000000..6d7bcb2 --- /dev/null +++ b/docs/modules/segmentation.md @@ -0,0 +1 @@ +--8<-- "TPTBox/segmentation/README.md" diff --git a/docs/modules/snapshot2d.md b/docs/modules/snapshot2d.md new file mode 100644 index 0000000..6b24390 --- /dev/null +++ b/docs/modules/snapshot2d.md @@ -0,0 +1 @@ +--8<-- "TPTBox/spine/snapshot2D/README.md" diff --git a/docs/modules/spine.md b/docs/modules/spine.md new file mode 100644 index 0000000..5a03170 --- /dev/null +++ b/docs/modules/spine.md @@ -0,0 +1,27 @@ +# Spine (`TPTBox.spine`) + +Spine-specific utilities built on top of the core `NII` and `POI` abstractions. +Contains two sub-modules: 2D snapshot generation and statistical spine measurements. + +## Sub-modules + +| Sub-module | Description | +|---|---| +| [`snapshot2D/`](snapshot2d.md) | Modular 2D image snapshot generation (slices, MIPs, overlays) | +| [`spinestats/`](spinestats.md) | Clinical measurements: distances, Cobb angles, IVD POIs, endplates | + +## Quick Example + +```python +from TPTBox import NII, calc_centroids +from TPTBox.spine.snapshot2D.snapshot_modular import Snapshot_Frame, create_snapshot + +ct = NII.load("ct.nii.gz", seg=False) +seg = NII.load("seg.nii.gz", seg=True) + +# Generate a 2D sagittal snapshot with a segmentation overlay +create_snapshot( + [Snapshot_Frame(image=ct, segmentation=seg, mode="CT")], + to="snapshot.png", +) +``` diff --git a/docs/modules/spinestats.md b/docs/modules/spinestats.md new file mode 100644 index 0000000..7be2b76 --- /dev/null +++ b/docs/modules/spinestats.md @@ -0,0 +1 @@ +--8<-- "TPTBox/spine/spinestats/README.md" diff --git a/docs/modules/stitching.md b/docs/modules/stitching.md new file mode 100644 index 0000000..beed24b --- /dev/null +++ b/docs/modules/stitching.md @@ -0,0 +1,90 @@ +# Stitching (`TPTBox.stitching`) + +Merges multiple NIfTI images that are already aligned in global space into a single volume. +Useful for whole-body or long-spine multi-station acquisitions. +You can verify alignment by opening the images in ITKSnap with "open additional image." + +## API + +| Function | Description | +|---|---| +| `stitching(nii_list, out, ...)` | Stitch a list of `NII` objects; returns `(result_nii, ramp_nii)` | +| `stitching_raw(paths, out, ...)` | Stitch from file paths directly | +| `GNC_stitch_T2w(nii_list, ...)` | GNC-based stitching optimised for T2w spine MRI | + +![Example of a stitching](https://raw.githubusercontent.com/Hendrik-code/TPTBox/main/TPTBox/stitching/stitching.jpg "Example of a stitching") + + +### Standalone +This script can be run directly from the console. Copy 'stiching.py' and install the necessary package. + +``` +stitching.py +[-h] print the help message +[-i IMAGES [IMAGES ...]] a list of input image paths +[-o OUTPUT] The output image path +[-v] verbose - if set, there will be more printouts. +[-min_value MIN_VALUE] New pixels not present will get this value. Recommended 0 for MRI and for CT -1024 or the known min-value. +[-seg] This flag is required if you merge segmentation Niftis. +Switches: +[-no_bias] If set: Do not use n4_bias_field_correction. It speeds up the process, but n4_bias_field_correction helps in roughly aligning the histogram. +[-bias_crop] crop empty spaces by the bias field mask. +[-crop] crop empty space away +[-sr] Store the ramp and stitching of the images in a 4d nii.gz +Optional: +[-hists] Use histogram matching to put the images in the roughly same histogram. The previous image is used when hist_n is not set. +[-hist_n HISTOGRAM_NAME] path to an image that should be used for histogram matching +[-ramp_e RAMP_EDGE_MIN_VALUE] The ramp is only considering values above this minimum value +[-ms MIN_SPACING] Set the minimum Spacing (in mm) +[-dtype DTYPE] Force a dtype +``` + +Example: + +Given the image a.nii.gz,b.nii.gz,c.nii.gz and the segmentations a_msk.nii.gz,b_msk.nii.gz,c_msk.nii.gz. The images can be merged with: + +```bash +stitching.py -i a.nii.gz b.nii.gz c.nii.gz -o out.nii.gz +stitching.py -i a_msk.nii.gz b_msk.nii.gz c_msk.nii.gz -o out_msk.nii.gz -seg +``` + +### Install as a package + +Install on Python 3.10 or higher +```bash +pip install TPTBox +``` + +```python +from TPTBox import NII +from TPTBox.stitching import stitching +out_nii,_ = stitching([NII.load("a.nii.gz",seg=False), NII.load("b.nii.gz",seg=False), NII.load("c.nii.gz",seg=False)], out="out.nii.gz") + +``` + +or + + +```python +from TPTBox.stitching import stitching_raw +stitching_raw(["a.nii.gz", "b.nii.gz", "c.nii.gz"], "out.nii.gz", is_segmentation=False) +``` + + +### Cite +``` +Graf, R., Platzek, PS., Riedel, E.O. et al. Generating synthetic high-resolution spinal STIR and T1w images from T2w FSE and low-resolution axial Dixon. Eur Radiol (2024). https://doi.org/10.1007/s00330-024-11047-1 + +``` + +``` +@article{graf2024generating, + title={Generating synthetic high-resolution spinal STIR and T1w images from T2w FSE and low-resolution axial Dixon}, + author={Graf, Robert and Platzek, Paul-S{\"o}ren and Riedel, Evamaria Olga and Kim, Su Hwan and Lenhart, Nicolas and Ramsch{\"u}tz, Constanze and Paprottka, Karolin Johanna and Kertels, Olivia Ruriko and M{\"o}ller, Hendrik Kristian and Atad, Matan and others}, + journal={European Radiology}, + pages={1--11}, + year={2024}, + publisher={Springer} +} + +``` diff --git a/mkdocs.yml b/mkdocs.yml index ce541f1..cca7d67 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,7 +53,8 @@ markdown_extensions: - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - - pymdownx.snippets + - pymdownx.snippets: + base_path: ["."] - attr_list - md_in_html - toc: @@ -62,6 +63,17 @@ markdown_extensions: nav: - Home: index.md - Getting Started: getting-started.md + - Modules: + - Core: modules/core.md + - POI Strategies: modules/poi_fun.md + - Spine: modules/spine.md + - "Spine – 2D Snapshots": modules/snapshot2d.md + - "Spine – Statistics": modules/spinestats.md + - Registration: modules/registration.md + - Segmentation: modules/segmentation.md + - Mesh 3D: modules/mesh3d.md + - Stitching: modules/stitching.md + - Logger: modules/logger.md - API Reference: - Core: - NII (NIfTI wrapper): api/nii.md From d0aa88d98507fd7f17507d9c2325d58ae06c30ca Mon Sep 17 00:00:00 2001 From: Hendrik Date: Tue, 26 May 2026 14:17:46 -0400 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/modules/stitching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/stitching.md b/docs/modules/stitching.md index beed24b..4483643 100644 --- a/docs/modules/stitching.md +++ b/docs/modules/stitching.md @@ -16,7 +16,7 @@ You can verify alignment by opening the images in ITKSnap with "open additional ### Standalone -This script can be run directly from the console. Copy 'stiching.py' and install the necessary package. +This script can be run directly from the console. Copy 'stitching.py' and install the necessary package. ``` stitching.py