voidspace 0.1.3__tar.gz → 0.1.4__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.4}/PKG-INFO +18 -5
- {voidspace-0.1.3 → voidspace-0.1.4}/README.md +17 -4
- {voidspace-0.1.3 → voidspace-0.1.4}/pyproject.toml +1 -1
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/__init__.py +1 -1
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/morphology.py +5 -7
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/segment.py +16 -3
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/workflows.py +14 -16
- {voidspace-0.1.3 → voidspace-0.1.4/src/voidspace.egg-info}/PKG-INFO +18 -5
- {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_segment.py +28 -1
- {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_workflows.py +42 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/LICENSE +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/setup.cfg +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/change.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/cli.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/io.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/metrics.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/models.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/SOURCES.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/dependency_links.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/entry_points.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/requires.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/top_level.txt +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_aim_io.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_change.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_cli.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_metrics.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_models.py +0 -0
- {voidspace-0.1.3 → voidspace-0.1.4}/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.4
|
|
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)
|
|
@@ -98,9 +101,8 @@ def run_case(
|
|
|
98
101
|
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
99
102
|
|
|
100
103
|
if mask_path is not None:
|
|
101
|
-
domain_mask,
|
|
102
|
-
|
|
103
|
-
raise ValueError("mask must be in the same space as segmentation")
|
|
104
|
+
domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
|
|
105
|
+
domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
|
|
104
106
|
else:
|
|
105
107
|
domain_mask = None
|
|
106
108
|
|
|
@@ -137,12 +139,10 @@ def analyze_maps(
|
|
|
137
139
|
if existing_outputs and not force:
|
|
138
140
|
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
139
141
|
|
|
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")
|
|
142
|
+
all_void, _all_spacing, all_reference = read_mask(all_void_path)
|
|
143
|
+
all_void = _array_on_reference_grid(all_void, all_reference, reference)
|
|
144
|
+
domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
|
|
145
|
+
domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
|
|
146
146
|
|
|
147
147
|
masked_large_void = large_void & domain_mask
|
|
148
148
|
masked_all_void = all_void & domain_mask
|
|
@@ -177,15 +177,13 @@ def compare(
|
|
|
177
177
|
if existing_outputs and not force:
|
|
178
178
|
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
179
179
|
|
|
180
|
-
followup,
|
|
181
|
-
|
|
182
|
-
raise ValueError("baseline and followup void masks must already be aligned")
|
|
180
|
+
followup, _followup_spacing, followup_reference = read_mask(followup_void_path)
|
|
181
|
+
followup = _array_on_reference_grid(followup, followup_reference, reference)
|
|
183
182
|
|
|
184
183
|
domain_mask = None
|
|
185
184
|
if mask_path is not None:
|
|
186
|
-
domain_mask,
|
|
187
|
-
|
|
188
|
-
raise ValueError("mask must be aligned with the void masks")
|
|
185
|
+
domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
|
|
186
|
+
domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
|
|
189
187
|
|
|
190
188
|
change = classify_voidspace_change(baseline, followup, mask=domain_mask)
|
|
191
189
|
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.4
|
|
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):
|
|
@@ -119,3 +136,28 @@ def test_intersect_masks_resamples_cropped_mask_to_reference_grid(tmp_path):
|
|
|
119
136
|
expected[1:4, 1:4, 1:4] = True
|
|
120
137
|
assert image.GetSize() == (5, 5, 5)
|
|
121
138
|
assert np.array_equal(result, expected)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_analyze_maps_honors_direction_with_matching_sizes_and_origins(tmp_path):
|
|
142
|
+
full = np.ones((5, 5, 5), dtype=bool)
|
|
143
|
+
_write_mask(tmp_path / "all.nii.gz", full)
|
|
144
|
+
_write_mask(tmp_path / "large.nii.gz", full)
|
|
145
|
+
mask = sitk.GetImageFromArray(full.astype(np.uint8))
|
|
146
|
+
mask.SetDirection((-1, 0, 0, 0, 1, 0, 0, 0, 1))
|
|
147
|
+
sitk.WriteImage(mask, str(tmp_path / "domain.nii.gz"))
|
|
148
|
+
result = analyze_maps(large_void_path=tmp_path / "large.nii.gz", all_void_path=tmp_path / "all.nii.gz",
|
|
149
|
+
mask_path=tmp_path / "domain.nii.gz", output_dir=tmp_path / "out")
|
|
150
|
+
assert result.metrics.total_volume_mm3 == 25
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_compare_places_cropped_maps_in_same_physical_space(tmp_path):
|
|
154
|
+
baseline = np.zeros((5, 5, 5), dtype=bool)
|
|
155
|
+
baseline[2, 2, 2] = True
|
|
156
|
+
_write_mask(tmp_path / "baseline.nii.gz", baseline)
|
|
157
|
+
cropped = sitk.GetImageFromArray(np.ones((1, 1, 1), dtype=np.uint8))
|
|
158
|
+
cropped.SetOrigin((2, 2, 2))
|
|
159
|
+
sitk.WriteImage(cropped, str(tmp_path / "followup.nii.gz"))
|
|
160
|
+
result = compare(baseline_void_path=tmp_path / "baseline.nii.gz",
|
|
161
|
+
followup_void_path=tmp_path / "followup.nii.gz", output_dir=tmp_path / "out")
|
|
162
|
+
assert result.metrics.stable_volume_mm3 == 1
|
|
163
|
+
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
|