voidspace 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {voidspace-0.1.3/src/voidspace.egg-info → voidspace-0.1.5}/PKG-INFO +18 -5
  2. {voidspace-0.1.3 → voidspace-0.1.5}/README.md +17 -4
  3. {voidspace-0.1.3 → voidspace-0.1.5}/pyproject.toml +1 -1
  4. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/__init__.py +1 -1
  5. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/morphology.py +5 -7
  6. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/segment.py +16 -3
  7. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/workflows.py +22 -18
  8. {voidspace-0.1.3 → voidspace-0.1.5/src/voidspace.egg-info}/PKG-INFO +18 -5
  9. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_segment.py +28 -1
  10. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_workflows.py +83 -0
  11. {voidspace-0.1.3 → voidspace-0.1.5}/LICENSE +0 -0
  12. {voidspace-0.1.3 → voidspace-0.1.5}/setup.cfg +0 -0
  13. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/change.py +0 -0
  14. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/cli.py +0 -0
  15. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/io.py +0 -0
  16. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/metrics.py +0 -0
  17. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/models.py +0 -0
  18. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/SOURCES.txt +0 -0
  19. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/dependency_links.txt +0 -0
  20. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/entry_points.txt +0 -0
  21. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/requires.txt +0 -0
  22. {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/top_level.txt +0 -0
  23. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_aim_io.py +0 -0
  24. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_change.py +0 -0
  25. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_cli.py +0 -0
  26. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_metrics.py +0 -0
  27. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_models.py +0 -0
  28. {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_morphology.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidspace
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Spacing-aware voidspace analysis for HR-pQCT segmentations
5
5
  Author: Matthias Walle
6
6
  License-Expression: MIT
@@ -36,8 +36,10 @@ Dynamic: license-file
36
36
  Spacing-aware voidspace analysis for HR-pQCT segmentations.
37
37
 
38
38
  `voidspace` is a standalone logic package. It does not segment bone, register
39
- scans, create common regions, or resample images. It expects inputs that are
40
- already in the image space to analyze.
39
+ scans or create common regions. It expects inputs already in the same physical
40
+ space. File workflows place cropped masks on the reference grid with
41
+ nearest-neighbor resampling using origin, spacing, and direction; this does not
42
+ estimate registration between scans.
41
43
 
42
44
  The command line interface reads common SimpleITK image formats and Scanco AIM
43
45
  masks. AIM masks are read in native scaling, interpreted as foreground where
@@ -55,8 +57,19 @@ nonzero, and written back as native binary AIM masks using the input geometry.
55
57
 
56
58
  When no periosteal mask is supplied, the segmentation-only workflow estimates
57
59
  the analysis domain from the closed segmentation by excluding background
58
- connected to the image border. Supplying an explicit mask remains preferred
59
- when that contour is available.
60
+ connected to lateral (in-plane) image borders, not the two scan ends. Supplying
61
+ an explicit full bone mask remains preferred, especially for breached cortex.
62
+
63
+ Closing and erosion use edge continuation along the scan axis before cropping
64
+ back to the acquired volume. This assumes the endpoint cross-sections continue
65
+ beyond the scan, rather than treating acquisition ends as empty biological space.
66
+ Physical morphology radii and volume thresholds are unchanged. This boundary
67
+ policy is not a validation of exact scanner/IPL equivalence.
68
+
69
+ All voidspace includes large voidspace: the large map is a size-filtered subset.
70
+ For common-region reporting, compute maps on the full bone domain with `run-case`,
71
+ then use `analyze-maps` to clip and measure without recomputing morphology at the
72
+ common-region boundaries. Force-rerun old outputs to apply these corrections.
60
73
 
61
74
  ## Algorithm
62
75
 
@@ -3,8 +3,10 @@
3
3
  Spacing-aware voidspace analysis for HR-pQCT segmentations.
4
4
 
5
5
  `voidspace` is a standalone logic package. It does not segment bone, register
6
- scans, create common regions, or resample images. It expects inputs that are
7
- already in the image space to analyze.
6
+ scans or create common regions. It expects inputs already in the same physical
7
+ space. File workflows place cropped masks on the reference grid with
8
+ nearest-neighbor resampling using origin, spacing, and direction; this does not
9
+ estimate registration between scans.
8
10
 
9
11
  The command line interface reads common SimpleITK image formats and Scanco AIM
10
12
  masks. AIM masks are read in native scaling, interpreted as foreground where
@@ -22,8 +24,19 @@ nonzero, and written back as native binary AIM masks using the input geometry.
22
24
 
23
25
  When no periosteal mask is supplied, the segmentation-only workflow estimates
24
26
  the analysis domain from the closed segmentation by excluding background
25
- connected to the image border. Supplying an explicit mask remains preferred
26
- when that contour is available.
27
+ connected to lateral (in-plane) image borders, not the two scan ends. Supplying
28
+ an explicit full bone mask remains preferred, especially for breached cortex.
29
+
30
+ Closing and erosion use edge continuation along the scan axis before cropping
31
+ back to the acquired volume. This assumes the endpoint cross-sections continue
32
+ beyond the scan, rather than treating acquisition ends as empty biological space.
33
+ Physical morphology radii and volume thresholds are unchanged. This boundary
34
+ policy is not a validation of exact scanner/IPL equivalence.
35
+
36
+ All voidspace includes large voidspace: the large map is a size-filtered subset.
37
+ For common-region reporting, compute maps on the full bone domain with `run-case`,
38
+ then use `analyze-maps` to clip and measure without recomputing morphology at the
39
+ common-region boundaries. Force-rerun old outputs to apply these corrections.
27
40
 
28
41
  ## Algorithm
29
42
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "voidspace"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "Spacing-aware voidspace analysis for HR-pQCT segmentations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -12,7 +12,7 @@ from voidspace.models import (
12
12
  from voidspace.segment import segment_voidspace
13
13
  from voidspace.workflows import analyze_maps, compare, intersect_masks, run_case
14
14
 
15
- __version__ = "0.1.3"
15
+ __version__ = "0.1.4"
16
16
 
17
17
  __all__ = [
18
18
  "VoidspaceChangeMasks",
@@ -66,7 +66,7 @@ def remove_small_components(mask: np.ndarray, min_voxels: int, connectivity: int
66
66
  return keep[labels]
67
67
 
68
68
 
69
- def remove_border_connected_components(mask: np.ndarray, connectivity: int = 3) -> np.ndarray:
69
+ def remove_border_connected_components(mask: np.ndarray, connectivity: int = 3, *, axes=(0, 1, 2)) -> np.ndarray:
70
70
  mask = np.asarray(mask, dtype=bool)
71
71
  if mask.ndim != 3:
72
72
  raise ValueError("mask must be a 3D array")
@@ -75,12 +75,10 @@ def remove_border_connected_components(mask: np.ndarray, connectivity: int = 3)
75
75
  if count == 0:
76
76
  return np.zeros_like(mask, dtype=bool)
77
77
 
78
- border_labels = set(np.unique(labels[0, :, :]))
79
- border_labels.update(np.unique(labels[-1, :, :]))
80
- border_labels.update(np.unique(labels[:, 0, :]))
81
- border_labels.update(np.unique(labels[:, -1, :]))
82
- border_labels.update(np.unique(labels[:, :, 0]))
83
- border_labels.update(np.unique(labels[:, :, -1]))
78
+ border_labels = set()
79
+ for axis in axes:
80
+ border_labels.update(np.unique(np.take(labels, 0, axis=axis)))
81
+ border_labels.update(np.unique(np.take(labels, -1, axis=axis)))
84
82
  border_labels.discard(0)
85
83
  remove = np.zeros(count + 1, dtype=bool)
86
84
  if border_labels:
@@ -25,6 +25,17 @@ def _validate_same_shape(*arrays: np.ndarray) -> None:
25
25
  raise ValueError(f"all masks must have the same shape, got {sorted(shapes)}")
26
26
 
27
27
 
28
+ def _continue_scan_ends(mask, footprint, operation):
29
+ """Continue the endpoint slices, without continuing lateral exterior background."""
30
+ radius = footprint.shape[0] // 2
31
+ if radius == 0:
32
+ return operation(mask, structure=footprint, border_value=0)
33
+ # Closing contains a dilation and an erosion; retain both operations' support.
34
+ padding = 2 * radius
35
+ padded = np.pad(mask, ((padding, padding), (0, 0), (0, 0)), mode="edge")
36
+ return operation(padded, structure=footprint, border_value=0)[padding:-padding]
37
+
38
+
28
39
  def segment_voidspace(
29
40
  segmentation: np.ndarray,
30
41
  spacing_mm: tuple[float, float, float],
@@ -35,7 +46,7 @@ def segment_voidspace(
35
46
  bone = _validate_3d("segmentation", segmentation)
36
47
  if mask is None:
37
48
  domain = np.ones_like(bone, dtype=bool)
38
- domain_source = "segmentation_border_background"
49
+ domain_source = "segmentation_lateral_background"
39
50
  else:
40
51
  domain = _validate_3d("mask", mask)
41
52
  _validate_same_shape(bone, domain)
@@ -47,11 +58,12 @@ def segment_voidspace(
47
58
  connectivity=params.connectivity,
48
59
  )
49
60
  closing_fp = ellipsoid_footprint(params.closing_radius_mm, spacing_mm)
50
- filled_bone = ndi.binary_closing(bone_in_domain, structure=closing_fp) & domain
61
+ filled_bone = _continue_scan_ends(bone_in_domain, closing_fp, ndi.binary_closing) & domain
51
62
  if mask is None:
52
63
  interior_background = remove_border_connected_components(
53
64
  ~filled_bone,
54
65
  connectivity=params.connectivity,
66
+ axes=(1, 2),
55
67
  )
56
68
  domain = filled_bone | interior_background
57
69
  filled_bone = filled_bone & domain
@@ -59,7 +71,7 @@ def segment_voidspace(
59
71
 
60
72
  if params.boundary_erosion_radius_mm > 0:
61
73
  erosion_fp = ellipsoid_footprint(params.boundary_erosion_radius_mm, spacing_mm)
62
- candidate_void = ndi.binary_erosion(candidate_void, structure=erosion_fp, border_value=0)
74
+ candidate_void = _continue_scan_ends(candidate_void, erosion_fp, ndi.binary_erosion)
63
75
  candidate_void = candidate_void & domain
64
76
 
65
77
  all_void = remove_small_components(
@@ -81,5 +93,6 @@ def segment_voidspace(
81
93
  "min_large_void_volume_mm3": params.min_large_void_volume_mm3,
82
94
  "structuring_element": "spacing_aware_ellipsoid",
83
95
  "domain_source": domain_source,
96
+ "scan_end_policy": "edge_continuation",
84
97
  },
85
98
  )
@@ -34,7 +34,10 @@ def _reference_image(reference) -> sitk.Image:
34
34
  def _array_on_reference_grid(array, source_reference, target_reference) -> np.ndarray:
35
35
  source_image = _reference_image(source_reference)
36
36
  target_image = _reference_image(target_reference)
37
- if source_image.GetSize() == target_image.GetSize() and source_image.GetOrigin() == target_image.GetOrigin():
37
+ if source_image.GetSize() == target_image.GetSize() and all(
38
+ np.allclose(getattr(source_image, getter)(), getattr(target_image, getter)(), rtol=0, atol=1e-6)
39
+ for getter in ("GetOrigin", "GetSpacing", "GetDirection")
40
+ ):
38
41
  return np.asarray(array, dtype=bool)
39
42
  image = sitk.GetImageFromArray(np.asarray(array, dtype=np.uint8))
40
43
  image.CopyInformation(source_image)
@@ -66,8 +69,14 @@ def intersect_masks(
66
69
  intersection, spacing, reference = read_mask(paths[0])
67
70
  for path in paths[1:]:
68
71
  mask, mask_spacing, mask_reference = read_mask(path)
69
- if mask_spacing != spacing:
70
- raise ValueError("all masks must have the same spacing")
72
+ # AIM header quantization and NIfTI float storage can differ below 1 nm.
73
+ # Tolerate that rounding, but do not silently mix scan resolutions.
74
+ if not np.allclose(mask_spacing, spacing, rtol=0, atol=1e-6):
75
+ raise ValueError(
76
+ f"mask spacing mismatch (z, y, x; mm): {paths[0]} has {spacing}; "
77
+ f"{path} has {mask_spacing}. Expected the matching native-space "
78
+ "common region at the same scan resolution."
79
+ )
71
80
  mask = _array_on_reference_grid(mask, mask_reference, reference)
72
81
  if mask.shape != intersection.shape:
73
82
  raise ValueError("all masks must overlap the reference image space")
@@ -98,9 +107,8 @@ def run_case(
98
107
  raise FileExistsError(f"output already exists: {existing_outputs[0]}")
99
108
 
100
109
  if mask_path is not None:
101
- domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
102
- if mask_spacing != spacing or domain_mask.shape != segmentation.shape:
103
- raise ValueError("mask must be in the same space as segmentation")
110
+ domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
111
+ domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
104
112
  else:
105
113
  domain_mask = None
106
114
 
@@ -137,12 +145,10 @@ def analyze_maps(
137
145
  if existing_outputs and not force:
138
146
  raise FileExistsError(f"output already exists: {existing_outputs[0]}")
139
147
 
140
- all_void, all_spacing, _all_reference = read_mask(all_void_path)
141
- if all_spacing != spacing or all_void.shape != large_void.shape:
142
- raise ValueError("large and all void masks must be in the same space")
143
- domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
144
- if mask_spacing != spacing or domain_mask.shape != large_void.shape:
145
- raise ValueError("mask must be aligned with the void masks")
148
+ all_void, _all_spacing, all_reference = read_mask(all_void_path)
149
+ all_void = _array_on_reference_grid(all_void, all_reference, reference)
150
+ domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
151
+ domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
146
152
 
147
153
  masked_large_void = large_void & domain_mask
148
154
  masked_all_void = all_void & domain_mask
@@ -177,15 +183,13 @@ def compare(
177
183
  if existing_outputs and not force:
178
184
  raise FileExistsError(f"output already exists: {existing_outputs[0]}")
179
185
 
180
- followup, followup_spacing, _followup_reference = read_mask(followup_void_path)
181
- if followup_spacing != spacing or followup.shape != baseline.shape:
182
- raise ValueError("baseline and followup void masks must already be aligned")
186
+ followup, _followup_spacing, followup_reference = read_mask(followup_void_path)
187
+ followup = _array_on_reference_grid(followup, followup_reference, reference)
183
188
 
184
189
  domain_mask = None
185
190
  if mask_path is not None:
186
- domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
187
- if mask_spacing != spacing or domain_mask.shape != baseline.shape:
188
- raise ValueError("mask must be aligned with the void masks")
191
+ domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
192
+ domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
189
193
 
190
194
  change = classify_voidspace_change(baseline, followup, mask=domain_mask)
191
195
  write_mask_like(change.stable, reference, stable_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidspace
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Spacing-aware voidspace analysis for HR-pQCT segmentations
5
5
  Author: Matthias Walle
6
6
  License-Expression: MIT
@@ -36,8 +36,10 @@ Dynamic: license-file
36
36
  Spacing-aware voidspace analysis for HR-pQCT segmentations.
37
37
 
38
38
  `voidspace` is a standalone logic package. It does not segment bone, register
39
- scans, create common regions, or resample images. It expects inputs that are
40
- already in the image space to analyze.
39
+ scans or create common regions. It expects inputs already in the same physical
40
+ space. File workflows place cropped masks on the reference grid with
41
+ nearest-neighbor resampling using origin, spacing, and direction; this does not
42
+ estimate registration between scans.
41
43
 
42
44
  The command line interface reads common SimpleITK image formats and Scanco AIM
43
45
  masks. AIM masks are read in native scaling, interpreted as foreground where
@@ -55,8 +57,19 @@ nonzero, and written back as native binary AIM masks using the input geometry.
55
57
 
56
58
  When no periosteal mask is supplied, the segmentation-only workflow estimates
57
59
  the analysis domain from the closed segmentation by excluding background
58
- connected to the image border. Supplying an explicit mask remains preferred
59
- when that contour is available.
60
+ connected to lateral (in-plane) image borders, not the two scan ends. Supplying
61
+ an explicit full bone mask remains preferred, especially for breached cortex.
62
+
63
+ Closing and erosion use edge continuation along the scan axis before cropping
64
+ back to the acquired volume. This assumes the endpoint cross-sections continue
65
+ beyond the scan, rather than treating acquisition ends as empty biological space.
66
+ Physical morphology radii and volume thresholds are unchanged. This boundary
67
+ policy is not a validation of exact scanner/IPL equivalence.
68
+
69
+ All voidspace includes large voidspace: the large map is a size-filtered subset.
70
+ For common-region reporting, compute maps on the full bone domain with `run-case`,
71
+ then use `analyze-maps` to clip and measure without recomputing morphology at the
72
+ common-region boundaries. Force-rerun old outputs to apply these corrections.
60
73
 
61
74
  ## Algorithm
62
75
 
@@ -3,6 +3,33 @@ import numpy as np
3
3
  from voidspace import VoidspaceParameters, segment_voidspace
4
4
 
5
5
 
6
+ def _open_tube():
7
+ yy, xx = np.indices((65, 65))
8
+ radius = np.sqrt((xx - 32) ** 2 + (yy - 32) ** 2)
9
+ full = np.broadcast_to(radius < 25, (31, 65, 65)).copy()
10
+ cavity = np.broadcast_to(radius < 15, full.shape)
11
+ params = VoidspaceParameters(closing_radius_mm=1, boundary_erosion_radius_mm=1,
12
+ min_large_void_volume_mm3=10,
13
+ bone_speckle_min_voxels=1, void_speckle_min_voxels=1)
14
+ return full & ~cavity, full, params
15
+
16
+
17
+ def test_open_end_void_survives_without_explicit_domain():
18
+ bone, full, params = _open_tube()
19
+ result = segment_voidspace(bone, (1, 1, 1), parameters=params)
20
+ assert result.large_void[:, 32, 32].all()
21
+ assert not result.all_void[~full].any()
22
+ assert not np.any(result.large_void & ~result.all_void)
23
+
24
+
25
+ def test_end_slices_match_interior_for_uniform_tube():
26
+ bone, full, params = _open_tube()
27
+ result = segment_voidspace(bone, (1, 1, 1), mask=full, parameters=params)
28
+ assert np.array_equal(result.filled_bone[0], result.filled_bone[15])
29
+ assert np.array_equal(result.all_void[0], result.all_void[15])
30
+ assert np.array_equal(result.all_void[-1], result.all_void[15])
31
+
32
+
6
33
  def _solid_cube_with_cavity(size=31, cavity_radius=4):
7
34
  bone = np.zeros((size, size, size), dtype=bool)
8
35
  bone[4:-4, 4:-4, 4:-4] = True
@@ -65,6 +92,6 @@ def test_segment_voidspace_derives_internal_domain_without_periosteal_mask():
65
92
 
66
93
  result = segment_voidspace(bone, (0.061, 0.061, 0.061), parameters=params)
67
94
 
68
- assert result.metadata["domain_source"] == "segmentation_border_background"
95
+ assert result.metadata["domain_source"] == "segmentation_lateral_background"
69
96
  assert result.large_void[cavity].any()
70
97
  assert not result.all_void[~peri].any()
@@ -2,6 +2,23 @@ import numpy as np
2
2
  import SimpleITK as sitk
3
3
 
4
4
  from voidspace import VoidspaceParameters, analyze_maps, compare, intersect_masks, run_case
5
+ import pytest
6
+
7
+
8
+ @pytest.mark.parametrize("size, origin", [(3, (1, 1, 1)), (5, (1, 0, 0))])
9
+ def test_run_case_places_analysis_mask_on_segmentation_grid(tmp_path, size, origin):
10
+ segmentation = np.zeros((5, 5, 5), dtype=np.uint8)
11
+ _write_mask(tmp_path / "seg.nii.gz", segmentation)
12
+ mask_image = sitk.GetImageFromArray(np.ones((size,) * 3, dtype=np.uint8))
13
+ mask_image.SetOrigin(origin)
14
+ sitk.WriteImage(mask_image, str(tmp_path / "domain.nii.gz"))
15
+ result = run_case(segmentation_path=tmp_path / "seg.nii.gz",
16
+ mask_path=tmp_path / "domain.nii.gz", output_dir=tmp_path / "out",
17
+ parameters=VoidspaceParameters(closing_radius_mm=0, boundary_erosion_radius_mm=0,
18
+ min_large_void_volume_mm3=0, bone_speckle_min_voxels=1, void_speckle_min_voxels=1))
19
+ assert result.metrics.total_volume_mm3 == (27 if size == 3 else 100)
20
+ output = sitk.GetArrayFromImage(sitk.ReadImage(str(result.all_mask_path)))
21
+ assert not output[:, :, 0].any()
5
22
 
6
23
 
7
24
  def _write_mask(path, array):
@@ -99,6 +116,47 @@ def test_intersect_masks_writes_boolean_intersection(tmp_path):
99
116
  assert np.array_equal(result, full & common)
100
117
 
101
118
 
119
+ @pytest.mark.parametrize("shift", [0.0, 0.0607])
120
+ @pytest.mark.parametrize("source_spacing, common_spacing", [
121
+ ((0.0607,) * 3, (float(np.float32(0.0607)),) * 3),
122
+ ((0.06069900095462799, 0.06069900095462799, 0.06069599837064743),
123
+ (0.06069965288043022, 0.06069965288043022, 0.06069643050432205)),
124
+ ])
125
+ def test_intersect_masks_accepts_header_rounding_and_preserves_physical_placement(
126
+ tmp_path, shift, source_spacing, common_spacing,
127
+ ):
128
+ full = sitk.GetImageFromArray(np.ones((5, 5, 5), dtype=np.uint8))
129
+ full.SetSpacing(source_spacing)
130
+ sitk.WriteImage(full, str(tmp_path / "full.mha"))
131
+ common = sitk.GetImageFromArray(np.ones((3, 3, 3), dtype=np.uint8))
132
+ common.SetSpacing(common_spacing)
133
+ common.SetOrigin((shift,) * 3)
134
+ sitk.WriteImage(common, str(tmp_path / "common.nii.gz"))
135
+
136
+ output = intersect_masks(
137
+ [tmp_path / "full.mha", tmp_path / "common.nii.gz"],
138
+ output_path=tmp_path / "intersection.nii.gz",
139
+ )
140
+ image = sitk.ReadImage(str(output))
141
+ expected = np.zeros((5, 5, 5), dtype=bool)
142
+ start = 0 if shift == 0 else 1
143
+ expected[start:start + 3, start:start + 3, start:start + 3] = True
144
+ assert np.array_equal(sitk.GetArrayFromImage(image).astype(bool), expected)
145
+ assert np.allclose(image.GetSpacing(), source_spacing, rtol=0, atol=1e-8)
146
+
147
+
148
+ def test_intersect_masks_reports_genuinely_different_resolution(tmp_path):
149
+ for name, spacing in [("full", 0.0607), ("common", 0.082)]:
150
+ image = sitk.GetImageFromArray(np.ones((3, 3, 3), dtype=np.uint8))
151
+ image.SetSpacing((spacing,) * 3)
152
+ sitk.WriteImage(image, str(tmp_path / f"{name}.mha"))
153
+ with pytest.raises(ValueError, match="spacing.*full.*common"):
154
+ intersect_masks(
155
+ [tmp_path / "full.mha", tmp_path / "common.mha"],
156
+ output_path=tmp_path / "intersection.nii.gz",
157
+ )
158
+
159
+
102
160
  def test_intersect_masks_resamples_cropped_mask_to_reference_grid(tmp_path):
103
161
  full = np.ones((5, 5, 5), dtype=bool)
104
162
  common = np.ones((3, 3, 3), dtype=bool)
@@ -119,3 +177,28 @@ def test_intersect_masks_resamples_cropped_mask_to_reference_grid(tmp_path):
119
177
  expected[1:4, 1:4, 1:4] = True
120
178
  assert image.GetSize() == (5, 5, 5)
121
179
  assert np.array_equal(result, expected)
180
+
181
+
182
+ def test_analyze_maps_honors_direction_with_matching_sizes_and_origins(tmp_path):
183
+ full = np.ones((5, 5, 5), dtype=bool)
184
+ _write_mask(tmp_path / "all.nii.gz", full)
185
+ _write_mask(tmp_path / "large.nii.gz", full)
186
+ mask = sitk.GetImageFromArray(full.astype(np.uint8))
187
+ mask.SetDirection((-1, 0, 0, 0, 1, 0, 0, 0, 1))
188
+ sitk.WriteImage(mask, str(tmp_path / "domain.nii.gz"))
189
+ result = analyze_maps(large_void_path=tmp_path / "large.nii.gz", all_void_path=tmp_path / "all.nii.gz",
190
+ mask_path=tmp_path / "domain.nii.gz", output_dir=tmp_path / "out")
191
+ assert result.metrics.total_volume_mm3 == 25
192
+
193
+
194
+ def test_compare_places_cropped_maps_in_same_physical_space(tmp_path):
195
+ baseline = np.zeros((5, 5, 5), dtype=bool)
196
+ baseline[2, 2, 2] = True
197
+ _write_mask(tmp_path / "baseline.nii.gz", baseline)
198
+ cropped = sitk.GetImageFromArray(np.ones((1, 1, 1), dtype=np.uint8))
199
+ cropped.SetOrigin((2, 2, 2))
200
+ sitk.WriteImage(cropped, str(tmp_path / "followup.nii.gz"))
201
+ result = compare(baseline_void_path=tmp_path / "baseline.nii.gz",
202
+ followup_void_path=tmp_path / "followup.nii.gz", output_dir=tmp_path / "out")
203
+ assert result.metrics.stable_volume_mm3 == 1
204
+ assert result.metrics.expanded_volume_mm3 == result.metrics.contracted_volume_mm3 == 0
File without changes
File without changes
File without changes
File without changes