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.
- {voidspace-0.1.3/src/voidspace.egg-info → voidspace-0.1.5}/PKG-INFO +18 -5
- {voidspace-0.1.3 → voidspace-0.1.5}/README.md +17 -4
- {voidspace-0.1.3 → voidspace-0.1.5}/pyproject.toml +1 -1
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/__init__.py +1 -1
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/morphology.py +5 -7
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/segment.py +16 -3
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/workflows.py +22 -18
- {voidspace-0.1.3 → voidspace-0.1.5/src/voidspace.egg-info}/PKG-INFO +18 -5
- {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_segment.py +28 -1
- {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_workflows.py +83 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/LICENSE +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/setup.cfg +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/change.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/cli.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/io.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/metrics.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace/models.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/SOURCES.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/dependency_links.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/entry_points.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/requires.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/src/voidspace.egg-info/top_level.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_aim_io.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_change.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_cli.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_metrics.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.5}/tests/test_models.py +0 -0
- {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
|
+
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
|
|
40
|
-
|
|
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
|
|
59
|
-
|
|
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
|
|
7
|
-
|
|
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
|
|
26
|
-
|
|
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
|
|
|
@@ -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.
|
|
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(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 = "
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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,
|
|
102
|
-
|
|
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,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
domain_mask,
|
|
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,
|
|
181
|
-
|
|
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,
|
|
187
|
-
|
|
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
|
+
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
|
|
40
|
-
|
|
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
|
|
59
|
-
|
|
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"] == "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|