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
10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,21 @@ convention = "google"
# Flag errors (`C901`) whenever the complexity level exceeds 5.
max-complexity = 20

[tool.pytest.ini_options]
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::FutureWarning",
]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"def __repr__",
"except",
"warnings.warn",
"import warnings",
"warnings\\.warn",
"warnings\\.filter",
"print\\(f?\"\\[!\\].*",
"def __str__(self):",
"def __hash__(self) -> int:",
Expand Down
4 changes: 0 additions & 4 deletions unit_tests/test_nii.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@

import operator
import random
import sys
import unittest
from pathlib import Path

import nibabel as nib
import numpy as np
Expand Down Expand Up @@ -46,8 +44,6 @@ class TestNII_MathOperators(unittest.TestCase):
def make_nii(shape=(8, 9, 10), seed=0, dtype=float):
rng = np.random.default_rng(seed)
arr = rng.normal(size=shape) if dtype is float else rng.integers(0, 8, size=shape, dtype=dtype)
import nibabel as nib

nii = NII((arr, np.eye(4), nib.nifti1.Nifti1Header()))
return nii

Expand Down
78 changes: 77 additions & 1 deletion unit_tests/test_nii_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,86 @@ def test_rescale_round_trip_shape(self):
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)


class Test_NII_MorphologyStd(unittest.TestCase):
"""Tests for standard (non-Euclidean) NII.erode_msk and NII.dilate_msk."""

@staticmethod
def _make_cube_seg(cube_size=8, shape=(20, 20, 20)):
arr = np.zeros(shape, dtype=np.uint8)
c = shape[0] // 2
h = cube_size // 2
arr[c - h : c + h, c - h : c + h, c - h : c + h] = 1
return _make_nii(arr)

def test_erode_reduces_volume(self):
nii = self._make_cube_seg()
eroded = nii.erode_msk(n_pixel=1)
self.assertLess(int((eroded.get_array() > 0).sum()), int((nii.get_array() > 0).sum()))

def test_dilate_increases_volume(self):
nii = self._make_cube_seg()
dilated = nii.dilate_msk(n_pixel=1)
self.assertGreater(int((dilated.get_array() > 0).sum()), int((nii.get_array() > 0).sum()))

def test_erode_inplace(self):
nii = self._make_cube_seg()
vol_before = int((nii.get_array() > 0).sum())
nii.erode_msk_(n_pixel=1)
self.assertLess(int((nii.get_array() > 0).sum()), vol_before)

def test_dilate_inplace(self):
nii = self._make_cube_seg()
vol_before = int((nii.get_array() > 0).sum())
nii.dilate_msk_(n_pixel=1)
self.assertGreater(int((nii.get_array() > 0).sum()), vol_before)


class Test_NII_FillHoles(unittest.TestCase):
"""Tests for NII.fill_holes and NII.fill_holes_."""

def test_hollow_cube_filled(self):
arr = np.zeros((15, 15, 15), dtype=np.uint8)
arr[2:13, 2:13, 2:13] = 1
arr[5:10, 5:10, 5:10] = 0
nii = _make_nii(arr)
filled = nii.fill_holes()
self.assertEqual(filled.get_array()[7, 7, 7], 1)

def test_no_holes_volume_unchanged(self):
arr = np.zeros((10, 10, 10), dtype=np.uint8)
arr[2:8, 2:8, 2:8] = 1
nii = _make_nii(arr)
vol_before = int((nii.get_array() > 0).sum())
filled = nii.fill_holes()
self.assertGreaterEqual(int((filled.get_array() > 0).sum()), vol_before)

def test_fill_holes_inplace(self):
arr = np.zeros((12, 12, 12), dtype=np.uint8)
arr[1:11, 1:11, 1:11] = 1
arr[4:8, 4:8, 4:8] = 0
nii = _make_nii(arr)
nii.fill_holes_()
self.assertEqual(nii.get_array()[6, 6, 6], 1)


class Test_NII_GetSegArray(unittest.TestCase):
"""Tests for NII.get_seg_array, including the warning path for non-seg NIIs."""

def test_returns_correct_array_when_seg(self):
arr = np.array([[[0, 1], [2, 3]]], dtype=np.int16)
nii = _make_nii(arr, seg=True)
np.testing.assert_array_equal(nii.get_seg_array(), arr)

def test_returns_array_when_not_seg(self):
arr = np.ones((4, 4, 4), dtype=np.float32)
nii = _make_nii(arr, seg=False)
result = nii.get_seg_array()
self.assertEqual(result.shape, arr.shape)


if __name__ == "__main__":
unittest.main()
105 changes: 105 additions & 0 deletions unit_tests/test_nii_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Round-trip I/O tests for NII.load, NII.save, and NII.from_numpy."""

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path

import numpy as np

from TPTBox import NII


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)


class Test_NII_IO(unittest.TestCase):
"""Verify that NII survives a save→load round-trip with consistent shape and metadata."""

def test_save_load_roundtrip_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, 2.0, 3.0))
with tempfile.NamedTemporaryFile(suffix=".nii.gz", delete=False) as f:
path = Path(f.name)
try:
nii.save(path, verbose=False)
loaded = NII.load(path, seg=True)
self.assertEqual(loaded.shape, nii.shape)
finally:
path.unlink(missing_ok=True)

def test_save_load_roundtrip_data(self):
arr = np.arange(27, dtype=np.uint8).reshape(3, 3, 3)
nii = _make_nii(arr, seg=True)
with tempfile.NamedTemporaryFile(suffix=".nii.gz", delete=False) as f:
path = Path(f.name)
try:
nii.save(path, verbose=False)
loaded = NII.load(path, seg=True)
np.testing.assert_array_equal(loaded.get_array(), arr)
finally:
path.unlink(missing_ok=True)

def test_save_load_preserves_zoom(self):
arr = np.zeros((5, 6, 7), dtype=np.uint8)
nii = _make_nii(arr, zoom=(1.5, 2.5, 3.5))
with tempfile.NamedTemporaryFile(suffix=".nii.gz", delete=False) as f:
path = Path(f.name)
try:
nii.save(path, verbose=False)
loaded = NII.load(path, seg=True)
for orig, restored in zip(nii.zoom, loaded.zoom):
self.assertAlmostEqual(orig, restored, places=4)
finally:
path.unlink(missing_ok=True)

def test_save_load_seg_flag(self):
arr = np.zeros((4, 4, 4), dtype=np.uint8)
arr[1:3, 1:3, 1:3] = 1
nii = _make_nii(arr, seg=True)
with tempfile.NamedTemporaryFile(suffix=".nii.gz", delete=False) as f:
path = Path(f.name)
try:
nii.save(path, verbose=False)
loaded = NII.load(path, seg=True)
self.assertTrue(loaded.seg)
finally:
path.unlink(missing_ok=True)


class Test_NII_FromNumpy(unittest.TestCase):
"""Tests for NII.from_numpy factory method."""

def test_shape(self):
arr = np.zeros((8, 9, 10), dtype=np.uint8)
nii = NII.from_numpy(arr, affine=np.eye(4), seg=True)
self.assertEqual(nii.shape, (8, 9, 10))

def test_seg_flag_true(self):
arr = np.zeros((4, 4, 4), dtype=np.uint8)
self.assertTrue(NII.from_numpy(arr, affine=np.eye(4), seg=True).seg)

def test_seg_flag_false(self):
arr = np.zeros((4, 4, 4), dtype=np.uint8)
self.assertFalse(NII.from_numpy(arr, affine=np.eye(4), seg=False).seg)

def test_affine_zoom_extracted(self):
arr = np.zeros((5, 5, 5), dtype=np.uint8)
affine = np.diag([2.0, 3.0, 4.0, 1.0])
nii = NII.from_numpy(arr, affine=affine, seg=True)
self.assertAlmostEqual(nii.zoom[0], 2.0)
self.assertAlmostEqual(nii.zoom[1], 3.0)
self.assertAlmostEqual(nii.zoom[2], 4.0)

def test_data_round_trip(self):
arr = np.arange(24, dtype=np.int16).reshape(2, 3, 4)
nii = NII.from_numpy(arr, affine=np.eye(4), seg=False)
np.testing.assert_array_equal(nii.get_array(), arr)


if __name__ == "__main__":
unittest.main()
19 changes: 5 additions & 14 deletions unit_tests/test_nputils.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
# Call 'python -m unittest' on this folder
# coverage run -m unittest
# coverage report
# coverage html
from __future__ import annotations

import sys
from pathlib import Path
import random
import unittest

file = Path(__file__).resolve()
sys.path.append(str(file.parents[2]))
import random # noqa: E402
import unittest # noqa: E402
import numpy as np

import numpy as np # noqa: E402

from TPTBox.core import np_utils # noqa: E402
from TPTBox.tests.test_utils import get_nii, repeats # noqa: E402
from TPTBox.core import np_utils
from TPTBox.tests.test_utils import get_nii, repeats


def make_test_array_repeating(shape=(4, 4), labels=(0, 1, 2, 3)) -> np.ndarray:
Expand Down
Loading
Loading