voidspace 0.1.0__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.0/src/voidspace.egg-info → voidspace-0.1.4}/PKG-INFO +40 -7
- {voidspace-0.1.0 → voidspace-0.1.4}/README.md +39 -6
- {voidspace-0.1.0 → voidspace-0.1.4}/pyproject.toml +1 -1
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/__init__.py +4 -2
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/cli.py +44 -1
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/io.py +2 -2
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/morphology.py +5 -7
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/segment.py +16 -3
- voidspace-0.1.4/src/voidspace/workflows.py +215 -0
- {voidspace-0.1.0 → voidspace-0.1.4/src/voidspace.egg-info}/PKG-INFO +40 -7
- {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_aim_io.py +16 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_cli.py +73 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_segment.py +28 -1
- voidspace-0.1.4/tests/test_workflows.py +163 -0
- voidspace-0.1.0/src/voidspace/workflows.py +0 -124
- voidspace-0.1.0/tests/test_workflows.py +0 -54
- {voidspace-0.1.0 → voidspace-0.1.4}/LICENSE +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/setup.cfg +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/change.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/metrics.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/models.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/SOURCES.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/dependency_links.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/entry_points.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/requires.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/top_level.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_change.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_metrics.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_models.py +0 -0
- {voidspace-0.1.0 → 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
|
|
|
@@ -91,6 +104,14 @@ voidspace run-case \
|
|
|
91
104
|
--output-dir voidspace-out
|
|
92
105
|
```
|
|
93
106
|
|
|
107
|
+
```bash
|
|
108
|
+
voidspace analyze-maps \
|
|
109
|
+
--large-void voidspace-out/voidspace_large_mask.AIM \
|
|
110
|
+
--all-void voidspace-out/voidspace_all_mask.AIM \
|
|
111
|
+
--mask common_region.AIM \
|
|
112
|
+
--output-dir voidspace-common-region
|
|
113
|
+
```
|
|
114
|
+
|
|
94
115
|
```bash
|
|
95
116
|
voidspace compare \
|
|
96
117
|
--baseline-void baseline_voidspace_large_mask.AIM \
|
|
@@ -99,8 +120,8 @@ voidspace compare \
|
|
|
99
120
|
--output-dir voidspace-change
|
|
100
121
|
```
|
|
101
122
|
|
|
102
|
-
`--mask` is optional for
|
|
103
|
-
outputs.
|
|
123
|
+
`--mask` is optional for `run-case` and `compare`, and required for
|
|
124
|
+
`analyze-maps`. Use `--force` to overwrite existing outputs.
|
|
104
125
|
|
|
105
126
|
## Python
|
|
106
127
|
|
|
@@ -128,6 +149,18 @@ result = compare(
|
|
|
128
149
|
print(result.metrics.net_change_volume_mm3)
|
|
129
150
|
```
|
|
130
151
|
|
|
152
|
+
```python
|
|
153
|
+
from voidspace import analyze_maps
|
|
154
|
+
|
|
155
|
+
result = analyze_maps(
|
|
156
|
+
large_void_path="voidspace-out/voidspace_large_mask.AIM",
|
|
157
|
+
all_void_path="voidspace-out/voidspace_all_mask.AIM",
|
|
158
|
+
mask_path="common_region.AIM",
|
|
159
|
+
output_dir="voidspace-common-region",
|
|
160
|
+
)
|
|
161
|
+
print(result.metrics.vstv_percent)
|
|
162
|
+
```
|
|
163
|
+
|
|
131
164
|
## Citation
|
|
132
165
|
|
|
133
166
|
For cross-sectional voidspace analysis, cite:
|
|
@@ -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
|
|
|
@@ -58,6 +71,14 @@ voidspace run-case \
|
|
|
58
71
|
--output-dir voidspace-out
|
|
59
72
|
```
|
|
60
73
|
|
|
74
|
+
```bash
|
|
75
|
+
voidspace analyze-maps \
|
|
76
|
+
--large-void voidspace-out/voidspace_large_mask.AIM \
|
|
77
|
+
--all-void voidspace-out/voidspace_all_mask.AIM \
|
|
78
|
+
--mask common_region.AIM \
|
|
79
|
+
--output-dir voidspace-common-region
|
|
80
|
+
```
|
|
81
|
+
|
|
61
82
|
```bash
|
|
62
83
|
voidspace compare \
|
|
63
84
|
--baseline-void baseline_voidspace_large_mask.AIM \
|
|
@@ -66,8 +87,8 @@ voidspace compare \
|
|
|
66
87
|
--output-dir voidspace-change
|
|
67
88
|
```
|
|
68
89
|
|
|
69
|
-
`--mask` is optional for
|
|
70
|
-
outputs.
|
|
90
|
+
`--mask` is optional for `run-case` and `compare`, and required for
|
|
91
|
+
`analyze-maps`. Use `--force` to overwrite existing outputs.
|
|
71
92
|
|
|
72
93
|
## Python
|
|
73
94
|
|
|
@@ -95,6 +116,18 @@ result = compare(
|
|
|
95
116
|
print(result.metrics.net_change_volume_mm3)
|
|
96
117
|
```
|
|
97
118
|
|
|
119
|
+
```python
|
|
120
|
+
from voidspace import analyze_maps
|
|
121
|
+
|
|
122
|
+
result = analyze_maps(
|
|
123
|
+
large_void_path="voidspace-out/voidspace_large_mask.AIM",
|
|
124
|
+
all_void_path="voidspace-out/voidspace_all_mask.AIM",
|
|
125
|
+
mask_path="common_region.AIM",
|
|
126
|
+
output_dir="voidspace-common-region",
|
|
127
|
+
)
|
|
128
|
+
print(result.metrics.vstv_percent)
|
|
129
|
+
```
|
|
130
|
+
|
|
98
131
|
## Citation
|
|
99
132
|
|
|
100
133
|
For cross-sectional voidspace analysis, cite:
|
|
@@ -10,9 +10,9 @@ from voidspace.models import (
|
|
|
10
10
|
VoidspaceRunResult,
|
|
11
11
|
)
|
|
12
12
|
from voidspace.segment import segment_voidspace
|
|
13
|
-
from voidspace.workflows import compare, run_case
|
|
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",
|
|
@@ -23,8 +23,10 @@ __all__ = [
|
|
|
23
23
|
"VoidspaceParameters",
|
|
24
24
|
"VoidspaceRunResult",
|
|
25
25
|
"__version__",
|
|
26
|
+
"analyze_maps",
|
|
26
27
|
"classify_voidspace_change",
|
|
27
28
|
"compare",
|
|
29
|
+
"intersect_masks",
|
|
28
30
|
"measure_voidspace",
|
|
29
31
|
"measure_voidspace_change",
|
|
30
32
|
"run_case",
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Sequence
|
|
6
6
|
|
|
7
7
|
from voidspace.models import VoidspaceParameters
|
|
8
|
-
from voidspace.workflows import compare, run_case
|
|
8
|
+
from voidspace.workflows import analyze_maps, compare, intersect_masks, run_case
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _parameters_from_args(args: argparse.Namespace) -> VoidspaceParameters:
|
|
@@ -64,6 +64,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
64
64
|
_add_parameter_arguments(run_case)
|
|
65
65
|
run_case.set_defaults(func=_cmd_run_case)
|
|
66
66
|
|
|
67
|
+
analyze = subparsers.add_parser("analyze-maps", help="Measure existing voidspace maps inside a mask.")
|
|
68
|
+
analyze.add_argument("--large-void", type=Path, required=True)
|
|
69
|
+
analyze.add_argument("--all-void", type=Path, required=True)
|
|
70
|
+
analyze.add_argument(
|
|
71
|
+
"--mask",
|
|
72
|
+
type=Path,
|
|
73
|
+
required=True,
|
|
74
|
+
help="Analysis-domain mask aligned with both voidspace maps.",
|
|
75
|
+
)
|
|
76
|
+
analyze.add_argument("--output-dir", type=Path, required=True)
|
|
77
|
+
analyze.add_argument("--connectivity", type=int, choices=(1, 2, 3), default=VoidspaceParameters.xtremectii_defaults().connectivity)
|
|
78
|
+
analyze.add_argument("--force", action="store_true")
|
|
79
|
+
analyze.set_defaults(func=_cmd_analyze_maps)
|
|
80
|
+
|
|
67
81
|
compare = subparsers.add_parser("compare", help="Compare already aligned voidspace masks.")
|
|
68
82
|
compare.add_argument("--baseline-void", type=Path, required=True)
|
|
69
83
|
compare.add_argument("--followup-void", type=Path, required=True)
|
|
@@ -76,6 +90,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
76
90
|
compare.add_argument("--force", action="store_true")
|
|
77
91
|
compare.set_defaults(func=_cmd_compare)
|
|
78
92
|
|
|
93
|
+
intersect = subparsers.add_parser("intersect-masks", help="Write the intersection of aligned masks.")
|
|
94
|
+
intersect.add_argument(
|
|
95
|
+
"--mask",
|
|
96
|
+
type=Path,
|
|
97
|
+
required=True,
|
|
98
|
+
action="append",
|
|
99
|
+
help="Mask to include in the intersection. Pass at least two.",
|
|
100
|
+
)
|
|
101
|
+
intersect.add_argument("--output", type=Path, required=True)
|
|
102
|
+
intersect.add_argument("--force", action="store_true")
|
|
103
|
+
intersect.set_defaults(func=_cmd_intersect_masks)
|
|
104
|
+
|
|
79
105
|
return parser
|
|
80
106
|
|
|
81
107
|
|
|
@@ -90,6 +116,18 @@ def _cmd_run_case(args: argparse.Namespace) -> int:
|
|
|
90
116
|
return 0
|
|
91
117
|
|
|
92
118
|
|
|
119
|
+
def _cmd_analyze_maps(args: argparse.Namespace) -> int:
|
|
120
|
+
analyze_maps(
|
|
121
|
+
large_void_path=args.large_void,
|
|
122
|
+
all_void_path=args.all_void,
|
|
123
|
+
mask_path=args.mask,
|
|
124
|
+
output_dir=args.output_dir,
|
|
125
|
+
connectivity=args.connectivity,
|
|
126
|
+
force=args.force,
|
|
127
|
+
)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
93
131
|
def _cmd_compare(args: argparse.Namespace) -> int:
|
|
94
132
|
compare(
|
|
95
133
|
baseline_void_path=args.baseline_void,
|
|
@@ -101,6 +139,11 @@ def _cmd_compare(args: argparse.Namespace) -> int:
|
|
|
101
139
|
return 0
|
|
102
140
|
|
|
103
141
|
|
|
142
|
+
def _cmd_intersect_masks(args: argparse.Namespace) -> int:
|
|
143
|
+
intersect_masks(args.mask, output_path=args.output, force=args.force)
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
104
147
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
105
148
|
parser = build_parser()
|
|
106
149
|
args = parser.parse_args(argv)
|
|
@@ -113,14 +113,14 @@ def _write_aim_mask(mask: np.ndarray, reference: AimReference, path: Path) -> No
|
|
|
113
113
|
def write_mask_like(mask: np.ndarray, reference: ImageReference, path: Path | str) -> Path:
|
|
114
114
|
output = Path(path)
|
|
115
115
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
-
if
|
|
116
|
+
if is_aim_path(output):
|
|
117
117
|
if not isinstance(reference, AimReference):
|
|
118
118
|
raise ValueError("AIM output requires an AIM reference image.")
|
|
119
119
|
_write_aim_mask(mask, reference, output)
|
|
120
120
|
return output
|
|
121
121
|
|
|
122
122
|
image = sitk.GetImageFromArray(np.asarray(mask, dtype=np.uint8))
|
|
123
|
-
image.CopyInformation(reference)
|
|
123
|
+
image.CopyInformation(reference.image if isinstance(reference, AimReference) else reference)
|
|
124
124
|
sitk.WriteImage(image, str(output))
|
|
125
125
|
return output
|
|
126
126
|
|
|
@@ -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
|
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import SimpleITK as sitk
|
|
7
|
+
|
|
8
|
+
from voidspace.change import classify_voidspace_change, measure_voidspace_change
|
|
9
|
+
from voidspace.io import AimReference, read_mask, write_mask_like, write_metrics_csv
|
|
10
|
+
from voidspace.metrics import measure_voidspace
|
|
11
|
+
from voidspace.models import VoidspaceCompareResult, VoidspaceParameters, VoidspaceRunResult
|
|
12
|
+
from voidspace.segment import segment_voidspace
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _metric_row(metrics, **context):
|
|
16
|
+
return {
|
|
17
|
+
**context,
|
|
18
|
+
"VS.TV": metrics.vstv_percent,
|
|
19
|
+
"VS.V": metrics.volume_mm3,
|
|
20
|
+
"Tt.V": metrics.total_volume_mm3,
|
|
21
|
+
"VS.N": metrics.component_count,
|
|
22
|
+
"VS.Ar": metrics.projected_area_mm2,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _mask_extension(reference) -> str:
|
|
27
|
+
return ".AIM" if isinstance(reference, AimReference) else ".nii.gz"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reference_image(reference) -> sitk.Image:
|
|
31
|
+
return reference.image if isinstance(reference, AimReference) else reference
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _array_on_reference_grid(array, source_reference, target_reference) -> np.ndarray:
|
|
35
|
+
source_image = _reference_image(source_reference)
|
|
36
|
+
target_image = _reference_image(target_reference)
|
|
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
|
+
):
|
|
41
|
+
return np.asarray(array, dtype=bool)
|
|
42
|
+
image = sitk.GetImageFromArray(np.asarray(array, dtype=np.uint8))
|
|
43
|
+
image.CopyInformation(source_image)
|
|
44
|
+
resampled = sitk.Resample(
|
|
45
|
+
image,
|
|
46
|
+
target_image,
|
|
47
|
+
sitk.Transform(),
|
|
48
|
+
sitk.sitkNearestNeighbor,
|
|
49
|
+
0,
|
|
50
|
+
sitk.sitkUInt8,
|
|
51
|
+
)
|
|
52
|
+
return sitk.GetArrayFromImage(resampled).astype(bool)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def intersect_masks(
|
|
56
|
+
mask_paths: list[Path | str] | tuple[Path | str, ...],
|
|
57
|
+
*,
|
|
58
|
+
output_path: Path | str,
|
|
59
|
+
force: bool = False,
|
|
60
|
+
) -> Path:
|
|
61
|
+
"""Write the voxelwise intersection of masks that share one image space."""
|
|
62
|
+
paths = [Path(path) for path in mask_paths]
|
|
63
|
+
if len(paths) < 2:
|
|
64
|
+
raise ValueError("at least two masks are required")
|
|
65
|
+
output_path = Path(output_path)
|
|
66
|
+
if output_path.exists() and not force:
|
|
67
|
+
raise FileExistsError(f"output already exists: {output_path}")
|
|
68
|
+
|
|
69
|
+
intersection, spacing, reference = read_mask(paths[0])
|
|
70
|
+
for path in paths[1:]:
|
|
71
|
+
mask, mask_spacing, mask_reference = read_mask(path)
|
|
72
|
+
if mask_spacing != spacing:
|
|
73
|
+
raise ValueError("all masks must have the same spacing")
|
|
74
|
+
mask = _array_on_reference_grid(mask, mask_reference, reference)
|
|
75
|
+
if mask.shape != intersection.shape:
|
|
76
|
+
raise ValueError("all masks must overlap the reference image space")
|
|
77
|
+
intersection = intersection & mask
|
|
78
|
+
|
|
79
|
+
write_mask_like(intersection, reference, output_path)
|
|
80
|
+
return output_path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_case(
|
|
84
|
+
*,
|
|
85
|
+
segmentation_path: Path | str,
|
|
86
|
+
output_dir: Path | str,
|
|
87
|
+
mask_path: Path | str | None = None,
|
|
88
|
+
parameters: VoidspaceParameters | None = None,
|
|
89
|
+
force: bool = False,
|
|
90
|
+
) -> VoidspaceRunResult:
|
|
91
|
+
"""Run cross-sectional voidspace segmentation and measurement for one scan."""
|
|
92
|
+
output_dir = Path(output_dir)
|
|
93
|
+
segmentation, spacing, reference = read_mask(segmentation_path)
|
|
94
|
+
mask_extension = _mask_extension(reference)
|
|
95
|
+
large_path = output_dir / f"voidspace_large_mask{mask_extension}"
|
|
96
|
+
all_path = output_dir / f"voidspace_all_mask{mask_extension}"
|
|
97
|
+
measurements_path = output_dir / "voidspace_measurements.csv"
|
|
98
|
+
|
|
99
|
+
existing_outputs = [path for path in (large_path, all_path, measurements_path) if path.exists()]
|
|
100
|
+
if existing_outputs and not force:
|
|
101
|
+
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
102
|
+
|
|
103
|
+
if mask_path is not None:
|
|
104
|
+
domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
|
|
105
|
+
domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
|
|
106
|
+
else:
|
|
107
|
+
domain_mask = None
|
|
108
|
+
|
|
109
|
+
params = parameters or VoidspaceParameters.xtremectii_defaults()
|
|
110
|
+
masks = segment_voidspace(segmentation, spacing, mask=domain_mask, parameters=params)
|
|
111
|
+
write_mask_like(masks.large_void, reference, large_path)
|
|
112
|
+
write_mask_like(masks.all_void, reference, all_path)
|
|
113
|
+
|
|
114
|
+
total_mask = domain_mask if domain_mask is not None else (masks.filled_bone | masks.all_void)
|
|
115
|
+
metrics = measure_voidspace(masks.large_void, total_mask, spacing, connectivity=params.connectivity)
|
|
116
|
+
write_metrics_csv(measurements_path, [_metric_row(metrics)])
|
|
117
|
+
|
|
118
|
+
return VoidspaceRunResult(large_path, all_path, measurements_path, metrics)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def analyze_maps(
|
|
122
|
+
*,
|
|
123
|
+
large_void_path: Path | str,
|
|
124
|
+
all_void_path: Path | str,
|
|
125
|
+
mask_path: Path | str,
|
|
126
|
+
output_dir: Path | str,
|
|
127
|
+
force: bool = False,
|
|
128
|
+
connectivity: int = 3,
|
|
129
|
+
) -> VoidspaceRunResult:
|
|
130
|
+
"""Measure existing voidspace maps within an analysis mask without recomputing maps."""
|
|
131
|
+
output_dir = Path(output_dir)
|
|
132
|
+
large_void, spacing, reference = read_mask(large_void_path)
|
|
133
|
+
mask_extension = _mask_extension(reference)
|
|
134
|
+
large_path = output_dir / f"voidspace_large_mask{mask_extension}"
|
|
135
|
+
all_path = output_dir / f"voidspace_all_mask{mask_extension}"
|
|
136
|
+
measurements_path = output_dir / "voidspace_measurements.csv"
|
|
137
|
+
|
|
138
|
+
existing_outputs = [path for path in (large_path, all_path, measurements_path) if path.exists()]
|
|
139
|
+
if existing_outputs and not force:
|
|
140
|
+
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
141
|
+
|
|
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
|
+
|
|
147
|
+
masked_large_void = large_void & domain_mask
|
|
148
|
+
masked_all_void = all_void & domain_mask
|
|
149
|
+
write_mask_like(masked_large_void, reference, large_path)
|
|
150
|
+
write_mask_like(masked_all_void, reference, all_path)
|
|
151
|
+
|
|
152
|
+
metrics = measure_voidspace(masked_large_void, domain_mask, spacing, connectivity=connectivity)
|
|
153
|
+
write_metrics_csv(measurements_path, [_metric_row(metrics)])
|
|
154
|
+
|
|
155
|
+
return VoidspaceRunResult(large_path, all_path, measurements_path, metrics)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def compare(
|
|
159
|
+
*,
|
|
160
|
+
baseline_void_path: Path | str,
|
|
161
|
+
followup_void_path: Path | str,
|
|
162
|
+
output_dir: Path | str,
|
|
163
|
+
mask_path: Path | str | None = None,
|
|
164
|
+
force: bool = False,
|
|
165
|
+
) -> VoidspaceCompareResult:
|
|
166
|
+
"""Compare already aligned baseline and follow-up voidspace masks."""
|
|
167
|
+
output_dir = Path(output_dir)
|
|
168
|
+
baseline, spacing, reference = read_mask(baseline_void_path)
|
|
169
|
+
mask_extension = _mask_extension(reference)
|
|
170
|
+
stable_path = output_dir / f"voidspace_quiescent_mask{mask_extension}"
|
|
171
|
+
expanded_path = output_dir / f"voidspace_expanded_mask{mask_extension}"
|
|
172
|
+
contracted_path = output_dir / f"voidspace_contracted_mask{mask_extension}"
|
|
173
|
+
measurements_path = output_dir / "voidspace_change_measurements.csv"
|
|
174
|
+
existing_outputs = [
|
|
175
|
+
path for path in (stable_path, expanded_path, contracted_path, measurements_path) if path.exists()
|
|
176
|
+
]
|
|
177
|
+
if existing_outputs and not force:
|
|
178
|
+
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
179
|
+
|
|
180
|
+
followup, _followup_spacing, followup_reference = read_mask(followup_void_path)
|
|
181
|
+
followup = _array_on_reference_grid(followup, followup_reference, reference)
|
|
182
|
+
|
|
183
|
+
domain_mask = None
|
|
184
|
+
if mask_path is not None:
|
|
185
|
+
domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
|
|
186
|
+
domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
|
|
187
|
+
|
|
188
|
+
change = classify_voidspace_change(baseline, followup, mask=domain_mask)
|
|
189
|
+
write_mask_like(change.stable, reference, stable_path)
|
|
190
|
+
write_mask_like(change.expanded, reference, expanded_path)
|
|
191
|
+
write_mask_like(change.contracted, reference, contracted_path)
|
|
192
|
+
metrics = measure_voidspace_change(change, spacing)
|
|
193
|
+
write_metrics_csv(
|
|
194
|
+
measurements_path,
|
|
195
|
+
[
|
|
196
|
+
{
|
|
197
|
+
"stable.VS.V": metrics.stable_volume_mm3,
|
|
198
|
+
"quiescent.VS.V": metrics.stable_volume_mm3,
|
|
199
|
+
"expanded.VS.V": metrics.expanded_volume_mm3,
|
|
200
|
+
"contracted.VS.V": metrics.contracted_volume_mm3,
|
|
201
|
+
"net.VS.V": metrics.net_change_volume_mm3,
|
|
202
|
+
}
|
|
203
|
+
],
|
|
204
|
+
)
|
|
205
|
+
return VoidspaceCompareResult(
|
|
206
|
+
stable_path,
|
|
207
|
+
expanded_path,
|
|
208
|
+
contracted_path,
|
|
209
|
+
measurements_path,
|
|
210
|
+
metrics,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
run_voidspace_case = run_case
|
|
215
|
+
run_voidspace_change_case = compare
|
|
@@ -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
|
|
|
@@ -91,6 +104,14 @@ voidspace run-case \
|
|
|
91
104
|
--output-dir voidspace-out
|
|
92
105
|
```
|
|
93
106
|
|
|
107
|
+
```bash
|
|
108
|
+
voidspace analyze-maps \
|
|
109
|
+
--large-void voidspace-out/voidspace_large_mask.AIM \
|
|
110
|
+
--all-void voidspace-out/voidspace_all_mask.AIM \
|
|
111
|
+
--mask common_region.AIM \
|
|
112
|
+
--output-dir voidspace-common-region
|
|
113
|
+
```
|
|
114
|
+
|
|
94
115
|
```bash
|
|
95
116
|
voidspace compare \
|
|
96
117
|
--baseline-void baseline_voidspace_large_mask.AIM \
|
|
@@ -99,8 +120,8 @@ voidspace compare \
|
|
|
99
120
|
--output-dir voidspace-change
|
|
100
121
|
```
|
|
101
122
|
|
|
102
|
-
`--mask` is optional for
|
|
103
|
-
outputs.
|
|
123
|
+
`--mask` is optional for `run-case` and `compare`, and required for
|
|
124
|
+
`analyze-maps`. Use `--force` to overwrite existing outputs.
|
|
104
125
|
|
|
105
126
|
## Python
|
|
106
127
|
|
|
@@ -128,6 +149,18 @@ result = compare(
|
|
|
128
149
|
print(result.metrics.net_change_volume_mm3)
|
|
129
150
|
```
|
|
130
151
|
|
|
152
|
+
```python
|
|
153
|
+
from voidspace import analyze_maps
|
|
154
|
+
|
|
155
|
+
result = analyze_maps(
|
|
156
|
+
large_void_path="voidspace-out/voidspace_large_mask.AIM",
|
|
157
|
+
all_void_path="voidspace-out/voidspace_all_mask.AIM",
|
|
158
|
+
mask_path="common_region.AIM",
|
|
159
|
+
output_dir="voidspace-common-region",
|
|
160
|
+
)
|
|
161
|
+
print(result.metrics.vstv_percent)
|
|
162
|
+
```
|
|
163
|
+
|
|
131
164
|
## Citation
|
|
132
165
|
|
|
133
166
|
For cross-sectional voidspace analysis, cite:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
import py_aimio
|
|
3
|
+
import SimpleITK as sitk
|
|
3
4
|
|
|
4
5
|
from voidspace.io import is_aim_path, read_mask, write_mask_like
|
|
5
6
|
|
|
@@ -56,3 +57,18 @@ def test_write_mask_like_round_trips_scanco_aim(tmp_path):
|
|
|
56
57
|
0.0820000022649765,
|
|
57
58
|
)
|
|
58
59
|
assert np.array_equal(roundtrip, subset)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_write_mask_like_can_write_nifti_from_scanco_aim_reference(tmp_path):
|
|
63
|
+
source = np.zeros((3, 4, 5), dtype=np.int8)
|
|
64
|
+
source[1, 2, 3] = 127
|
|
65
|
+
input_path = tmp_path / "seg.AIM"
|
|
66
|
+
_write_aim(input_path, source)
|
|
67
|
+
segmentation, _spacing, reference = read_mask(input_path)
|
|
68
|
+
|
|
69
|
+
output = write_mask_like(segmentation, reference, tmp_path / "analysis_mask.nii.gz")
|
|
70
|
+
|
|
71
|
+
image = sitk.ReadImage(str(output))
|
|
72
|
+
roundtrip = sitk.GetArrayFromImage(image).astype(bool)
|
|
73
|
+
assert image.GetSize() == (5, 4, 3)
|
|
74
|
+
assert np.array_equal(roundtrip, segmentation)
|
|
@@ -83,10 +83,71 @@ def test_compare_cli_writes_expanded_and_contracted_masks(tmp_path):
|
|
|
83
83
|
)
|
|
84
84
|
|
|
85
85
|
assert rc == 0
|
|
86
|
+
assert (tmp_path / "change" / "voidspace_quiescent_mask.nii.gz").is_file()
|
|
86
87
|
assert (tmp_path / "change" / "voidspace_expanded_mask.nii.gz").is_file()
|
|
87
88
|
assert (tmp_path / "change" / "voidspace_contracted_mask.nii.gz").is_file()
|
|
88
89
|
|
|
89
90
|
|
|
91
|
+
def test_intersect_masks_cli_writes_combined_mask(tmp_path):
|
|
92
|
+
full = np.ones((3, 3, 3), dtype=bool)
|
|
93
|
+
full[2, :, :] = False
|
|
94
|
+
common = np.zeros_like(full)
|
|
95
|
+
common[:, 1:, :] = True
|
|
96
|
+
_write_mask(tmp_path / "full.nii.gz", full)
|
|
97
|
+
_write_mask(tmp_path / "common.nii.gz", common)
|
|
98
|
+
|
|
99
|
+
rc = main(
|
|
100
|
+
[
|
|
101
|
+
"intersect-masks",
|
|
102
|
+
"--mask",
|
|
103
|
+
str(tmp_path / "full.nii.gz"),
|
|
104
|
+
"--mask",
|
|
105
|
+
str(tmp_path / "common.nii.gz"),
|
|
106
|
+
"--output",
|
|
107
|
+
str(tmp_path / "analysis_mask.nii.gz"),
|
|
108
|
+
"--force",
|
|
109
|
+
]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert rc == 0
|
|
113
|
+
result = sitk.GetArrayFromImage(sitk.ReadImage(str(tmp_path / "analysis_mask.nii.gz"))).astype(bool)
|
|
114
|
+
assert np.array_equal(result, full & common)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_analyze_maps_cli_writes_masked_copies_and_measurements(tmp_path):
|
|
118
|
+
all_void = np.ones((3, 3, 3), dtype=bool)
|
|
119
|
+
large_void = np.zeros_like(all_void)
|
|
120
|
+
large_void[0, 0, 0] = True
|
|
121
|
+
large_void[2, 2, 2] = True
|
|
122
|
+
mask = np.zeros_like(all_void)
|
|
123
|
+
mask[0, 0, 0] = True
|
|
124
|
+
mask[0, 0, 1] = True
|
|
125
|
+
_write_mask(tmp_path / "all.nii.gz", all_void)
|
|
126
|
+
_write_mask(tmp_path / "large.nii.gz", large_void)
|
|
127
|
+
_write_mask(tmp_path / "mask.nii.gz", mask)
|
|
128
|
+
|
|
129
|
+
rc = main(
|
|
130
|
+
[
|
|
131
|
+
"analyze-maps",
|
|
132
|
+
"--large-void",
|
|
133
|
+
str(tmp_path / "large.nii.gz"),
|
|
134
|
+
"--all-void",
|
|
135
|
+
str(tmp_path / "all.nii.gz"),
|
|
136
|
+
"--mask",
|
|
137
|
+
str(tmp_path / "mask.nii.gz"),
|
|
138
|
+
"--output-dir",
|
|
139
|
+
str(tmp_path / "masked"),
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert rc == 0
|
|
144
|
+
assert (tmp_path / "masked" / "voidspace_large_mask.nii.gz").is_file()
|
|
145
|
+
assert (tmp_path / "masked" / "voidspace_all_mask.nii.gz").is_file()
|
|
146
|
+
with (tmp_path / "masked" / "voidspace_measurements.csv").open(newline="", encoding="utf-8") as stream:
|
|
147
|
+
rows = list(csv.DictReader(stream))
|
|
148
|
+
assert rows[0]["VS.V"] == "1.0"
|
|
149
|
+
|
|
150
|
+
|
|
90
151
|
def test_run_case_cli_writes_aim_masks_for_aim_input(tmp_path):
|
|
91
152
|
segmentation = np.ones((9, 9, 9), dtype=bool)
|
|
92
153
|
segmentation[4, 4, 4] = False
|
|
@@ -125,3 +186,15 @@ def test_run_case_help_uses_single_mask_argument_and_no_study_metadata(capsys):
|
|
|
125
186
|
assert "--subject" not in help_text
|
|
126
187
|
assert "--session" not in help_text
|
|
127
188
|
assert "--site" not in help_text
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_analyze_maps_help_uses_existing_map_inputs(capsys):
|
|
192
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
193
|
+
main(["analyze-maps", "--help"])
|
|
194
|
+
|
|
195
|
+
assert exc_info.value.code == 0
|
|
196
|
+
help_text = capsys.readouterr().out
|
|
197
|
+
assert "--large-void" in help_text
|
|
198
|
+
assert "--all-void" in help_text
|
|
199
|
+
assert "--mask" in help_text
|
|
200
|
+
assert "--segmentation" not in help_text
|
|
@@ -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()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import SimpleITK as sitk
|
|
3
|
+
|
|
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()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_mask(path, array):
|
|
25
|
+
image = sitk.GetImageFromArray(array.astype("uint8"))
|
|
26
|
+
image.SetSpacing((1.0, 1.0, 1.0))
|
|
27
|
+
sitk.WriteImage(image, str(path))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_run_case_returns_metrics_and_output_paths(tmp_path):
|
|
31
|
+
segmentation = np.ones((9, 9, 9), dtype=bool)
|
|
32
|
+
segmentation[4, 4, 4] = False
|
|
33
|
+
_write_mask(tmp_path / "seg.nii.gz", segmentation)
|
|
34
|
+
|
|
35
|
+
result = run_case(
|
|
36
|
+
segmentation_path=tmp_path / "seg.nii.gz",
|
|
37
|
+
output_dir=tmp_path / "out",
|
|
38
|
+
parameters=VoidspaceParameters(
|
|
39
|
+
closing_radius_mm=0.0,
|
|
40
|
+
boundary_erosion_radius_mm=0.0,
|
|
41
|
+
min_large_void_volume_mm3=0.0,
|
|
42
|
+
bone_speckle_min_voxels=1,
|
|
43
|
+
void_speckle_min_voxels=1,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
assert result.large_mask_path.is_file()
|
|
48
|
+
assert result.all_mask_path.is_file()
|
|
49
|
+
assert result.measurements_path.is_file()
|
|
50
|
+
assert result.metrics.component_count == 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_compare_returns_change_metrics_and_output_paths(tmp_path):
|
|
54
|
+
baseline = np.zeros((2, 2, 2), dtype=bool)
|
|
55
|
+
followup = np.zeros_like(baseline)
|
|
56
|
+
baseline[0, 0, 0] = True
|
|
57
|
+
followup[1, 1, 1] = True
|
|
58
|
+
_write_mask(tmp_path / "baseline.nii.gz", baseline)
|
|
59
|
+
_write_mask(tmp_path / "followup.nii.gz", followup)
|
|
60
|
+
|
|
61
|
+
result = compare(
|
|
62
|
+
baseline_void_path=tmp_path / "baseline.nii.gz",
|
|
63
|
+
followup_void_path=tmp_path / "followup.nii.gz",
|
|
64
|
+
output_dir=tmp_path / "out",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert result.expanded_mask_path.is_file()
|
|
68
|
+
assert result.contracted_mask_path.is_file()
|
|
69
|
+
assert result.measurements_path.is_file()
|
|
70
|
+
assert result.metrics.expanded_volume_mm3 == 1.0
|
|
71
|
+
assert result.metrics.contracted_volume_mm3 == 1.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_analyze_maps_reuses_existing_void_masks_and_measures_inside_mask(tmp_path):
|
|
75
|
+
all_void = np.ones((3, 3, 3), dtype=bool)
|
|
76
|
+
large_void = np.zeros_like(all_void)
|
|
77
|
+
large_void[0, 0, 0] = True
|
|
78
|
+
large_void[2, 2, 2] = True
|
|
79
|
+
mask = np.zeros_like(all_void)
|
|
80
|
+
mask[0, 0, 0] = True
|
|
81
|
+
mask[0, 0, 1] = True
|
|
82
|
+
_write_mask(tmp_path / "all.nii.gz", all_void)
|
|
83
|
+
_write_mask(tmp_path / "large.nii.gz", large_void)
|
|
84
|
+
_write_mask(tmp_path / "mask.nii.gz", mask)
|
|
85
|
+
|
|
86
|
+
result = analyze_maps(
|
|
87
|
+
large_void_path=tmp_path / "large.nii.gz",
|
|
88
|
+
all_void_path=tmp_path / "all.nii.gz",
|
|
89
|
+
mask_path=tmp_path / "mask.nii.gz",
|
|
90
|
+
output_dir=tmp_path / "masked",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert result.large_mask_path.is_file()
|
|
94
|
+
assert result.all_mask_path.is_file()
|
|
95
|
+
assert result.measurements_path.is_file()
|
|
96
|
+
assert result.metrics.volume_mm3 == 1.0
|
|
97
|
+
assert result.metrics.total_volume_mm3 == 2.0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_intersect_masks_writes_boolean_intersection(tmp_path):
|
|
101
|
+
full = np.ones((3, 3, 3), dtype=bool)
|
|
102
|
+
full[2, :, :] = False
|
|
103
|
+
common = np.zeros_like(full)
|
|
104
|
+
common[:, 1:, :] = True
|
|
105
|
+
_write_mask(tmp_path / "full.nii.gz", full)
|
|
106
|
+
_write_mask(tmp_path / "common.nii.gz", common)
|
|
107
|
+
|
|
108
|
+
output_path = intersect_masks(
|
|
109
|
+
[tmp_path / "full.nii.gz", tmp_path / "common.nii.gz"],
|
|
110
|
+
output_path=tmp_path / "analysis_mask.nii.gz",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
image = sitk.ReadImage(str(output_path))
|
|
114
|
+
result = sitk.GetArrayFromImage(image).astype(bool)
|
|
115
|
+
assert result.sum() == 12
|
|
116
|
+
assert np.array_equal(result, full & common)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_intersect_masks_resamples_cropped_mask_to_reference_grid(tmp_path):
|
|
120
|
+
full = np.ones((5, 5, 5), dtype=bool)
|
|
121
|
+
common = np.ones((3, 3, 3), dtype=bool)
|
|
122
|
+
_write_mask(tmp_path / "full.nii.gz", full)
|
|
123
|
+
common_image = sitk.GetImageFromArray(common.astype("uint8"))
|
|
124
|
+
common_image.SetSpacing((1.0, 1.0, 1.0))
|
|
125
|
+
common_image.SetOrigin((1.0, 1.0, 1.0))
|
|
126
|
+
sitk.WriteImage(common_image, str(tmp_path / "common.nii.gz"))
|
|
127
|
+
|
|
128
|
+
output_path = intersect_masks(
|
|
129
|
+
[tmp_path / "full.nii.gz", tmp_path / "common.nii.gz"],
|
|
130
|
+
output_path=tmp_path / "analysis_mask.nii.gz",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
image = sitk.ReadImage(str(output_path))
|
|
134
|
+
result = sitk.GetArrayFromImage(image).astype(bool)
|
|
135
|
+
expected = np.zeros_like(full)
|
|
136
|
+
expected[1:4, 1:4, 1:4] = True
|
|
137
|
+
assert image.GetSize() == (5, 5, 5)
|
|
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
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from voidspace.change import classify_voidspace_change, measure_voidspace_change
|
|
6
|
-
from voidspace.io import AimReference, read_mask, write_mask_like, write_metrics_csv
|
|
7
|
-
from voidspace.metrics import measure_voidspace
|
|
8
|
-
from voidspace.models import VoidspaceCompareResult, VoidspaceParameters, VoidspaceRunResult
|
|
9
|
-
from voidspace.segment import segment_voidspace
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _metric_row(metrics, **context):
|
|
13
|
-
return {
|
|
14
|
-
**context,
|
|
15
|
-
"VS.TV": metrics.vstv_percent,
|
|
16
|
-
"VS.V": metrics.volume_mm3,
|
|
17
|
-
"Tt.V": metrics.total_volume_mm3,
|
|
18
|
-
"VS.N": metrics.component_count,
|
|
19
|
-
"VS.Ar": metrics.projected_area_mm2,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _mask_extension(reference) -> str:
|
|
24
|
-
return ".AIM" if isinstance(reference, AimReference) else ".nii.gz"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def run_case(
|
|
28
|
-
*,
|
|
29
|
-
segmentation_path: Path | str,
|
|
30
|
-
output_dir: Path | str,
|
|
31
|
-
mask_path: Path | str | None = None,
|
|
32
|
-
parameters: VoidspaceParameters | None = None,
|
|
33
|
-
force: bool = False,
|
|
34
|
-
) -> VoidspaceRunResult:
|
|
35
|
-
"""Run cross-sectional voidspace segmentation and measurement for one scan."""
|
|
36
|
-
output_dir = Path(output_dir)
|
|
37
|
-
segmentation, spacing, reference = read_mask(segmentation_path)
|
|
38
|
-
mask_extension = _mask_extension(reference)
|
|
39
|
-
large_path = output_dir / f"voidspace_large_mask{mask_extension}"
|
|
40
|
-
all_path = output_dir / f"voidspace_all_mask{mask_extension}"
|
|
41
|
-
measurements_path = output_dir / "voidspace_measurements.csv"
|
|
42
|
-
|
|
43
|
-
existing_outputs = [path for path in (large_path, all_path, measurements_path) if path.exists()]
|
|
44
|
-
if existing_outputs and not force:
|
|
45
|
-
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
46
|
-
|
|
47
|
-
if mask_path is not None:
|
|
48
|
-
domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
|
|
49
|
-
if mask_spacing != spacing or domain_mask.shape != segmentation.shape:
|
|
50
|
-
raise ValueError("mask must be in the same space as segmentation")
|
|
51
|
-
else:
|
|
52
|
-
domain_mask = None
|
|
53
|
-
|
|
54
|
-
params = parameters or VoidspaceParameters.xtremectii_defaults()
|
|
55
|
-
masks = segment_voidspace(segmentation, spacing, mask=domain_mask, parameters=params)
|
|
56
|
-
write_mask_like(masks.large_void, reference, large_path)
|
|
57
|
-
write_mask_like(masks.all_void, reference, all_path)
|
|
58
|
-
|
|
59
|
-
total_mask = domain_mask if domain_mask is not None else (masks.filled_bone | masks.all_void)
|
|
60
|
-
metrics = measure_voidspace(masks.large_void, total_mask, spacing, connectivity=params.connectivity)
|
|
61
|
-
write_metrics_csv(measurements_path, [_metric_row(metrics)])
|
|
62
|
-
|
|
63
|
-
return VoidspaceRunResult(large_path, all_path, measurements_path, metrics)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def compare(
|
|
67
|
-
*,
|
|
68
|
-
baseline_void_path: Path | str,
|
|
69
|
-
followup_void_path: Path | str,
|
|
70
|
-
output_dir: Path | str,
|
|
71
|
-
mask_path: Path | str | None = None,
|
|
72
|
-
force: bool = False,
|
|
73
|
-
) -> VoidspaceCompareResult:
|
|
74
|
-
"""Compare already aligned baseline and follow-up voidspace masks."""
|
|
75
|
-
output_dir = Path(output_dir)
|
|
76
|
-
baseline, spacing, reference = read_mask(baseline_void_path)
|
|
77
|
-
mask_extension = _mask_extension(reference)
|
|
78
|
-
stable_path = output_dir / f"voidspace_stable_mask{mask_extension}"
|
|
79
|
-
expanded_path = output_dir / f"voidspace_expanded_mask{mask_extension}"
|
|
80
|
-
contracted_path = output_dir / f"voidspace_contracted_mask{mask_extension}"
|
|
81
|
-
measurements_path = output_dir / "voidspace_change_measurements.csv"
|
|
82
|
-
existing_outputs = [
|
|
83
|
-
path for path in (stable_path, expanded_path, contracted_path, measurements_path) if path.exists()
|
|
84
|
-
]
|
|
85
|
-
if existing_outputs and not force:
|
|
86
|
-
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
87
|
-
|
|
88
|
-
followup, followup_spacing, _followup_reference = read_mask(followup_void_path)
|
|
89
|
-
if followup_spacing != spacing or followup.shape != baseline.shape:
|
|
90
|
-
raise ValueError("baseline and followup void masks must already be aligned")
|
|
91
|
-
|
|
92
|
-
domain_mask = None
|
|
93
|
-
if mask_path is not None:
|
|
94
|
-
domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
|
|
95
|
-
if mask_spacing != spacing or domain_mask.shape != baseline.shape:
|
|
96
|
-
raise ValueError("mask must be aligned with the void masks")
|
|
97
|
-
|
|
98
|
-
change = classify_voidspace_change(baseline, followup, mask=domain_mask)
|
|
99
|
-
write_mask_like(change.stable, reference, stable_path)
|
|
100
|
-
write_mask_like(change.expanded, reference, expanded_path)
|
|
101
|
-
write_mask_like(change.contracted, reference, contracted_path)
|
|
102
|
-
metrics = measure_voidspace_change(change, spacing)
|
|
103
|
-
write_metrics_csv(
|
|
104
|
-
measurements_path,
|
|
105
|
-
[
|
|
106
|
-
{
|
|
107
|
-
"stable.VS.V": metrics.stable_volume_mm3,
|
|
108
|
-
"expanded.VS.V": metrics.expanded_volume_mm3,
|
|
109
|
-
"contracted.VS.V": metrics.contracted_volume_mm3,
|
|
110
|
-
"net.VS.V": metrics.net_change_volume_mm3,
|
|
111
|
-
}
|
|
112
|
-
],
|
|
113
|
-
)
|
|
114
|
-
return VoidspaceCompareResult(
|
|
115
|
-
stable_path,
|
|
116
|
-
expanded_path,
|
|
117
|
-
contracted_path,
|
|
118
|
-
measurements_path,
|
|
119
|
-
metrics,
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
run_voidspace_case = run_case
|
|
124
|
-
run_voidspace_change_case = compare
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import numpy as np
|
|
2
|
-
import SimpleITK as sitk
|
|
3
|
-
|
|
4
|
-
from voidspace import VoidspaceParameters, compare, run_case
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def _write_mask(path, array):
|
|
8
|
-
image = sitk.GetImageFromArray(array.astype("uint8"))
|
|
9
|
-
image.SetSpacing((1.0, 1.0, 1.0))
|
|
10
|
-
sitk.WriteImage(image, str(path))
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def test_run_case_returns_metrics_and_output_paths(tmp_path):
|
|
14
|
-
segmentation = np.ones((9, 9, 9), dtype=bool)
|
|
15
|
-
segmentation[4, 4, 4] = False
|
|
16
|
-
_write_mask(tmp_path / "seg.nii.gz", segmentation)
|
|
17
|
-
|
|
18
|
-
result = run_case(
|
|
19
|
-
segmentation_path=tmp_path / "seg.nii.gz",
|
|
20
|
-
output_dir=tmp_path / "out",
|
|
21
|
-
parameters=VoidspaceParameters(
|
|
22
|
-
closing_radius_mm=0.0,
|
|
23
|
-
boundary_erosion_radius_mm=0.0,
|
|
24
|
-
min_large_void_volume_mm3=0.0,
|
|
25
|
-
bone_speckle_min_voxels=1,
|
|
26
|
-
void_speckle_min_voxels=1,
|
|
27
|
-
),
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
assert result.large_mask_path.is_file()
|
|
31
|
-
assert result.all_mask_path.is_file()
|
|
32
|
-
assert result.measurements_path.is_file()
|
|
33
|
-
assert result.metrics.component_count == 1
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_compare_returns_change_metrics_and_output_paths(tmp_path):
|
|
37
|
-
baseline = np.zeros((2, 2, 2), dtype=bool)
|
|
38
|
-
followup = np.zeros_like(baseline)
|
|
39
|
-
baseline[0, 0, 0] = True
|
|
40
|
-
followup[1, 1, 1] = True
|
|
41
|
-
_write_mask(tmp_path / "baseline.nii.gz", baseline)
|
|
42
|
-
_write_mask(tmp_path / "followup.nii.gz", followup)
|
|
43
|
-
|
|
44
|
-
result = compare(
|
|
45
|
-
baseline_void_path=tmp_path / "baseline.nii.gz",
|
|
46
|
-
followup_void_path=tmp_path / "followup.nii.gz",
|
|
47
|
-
output_dir=tmp_path / "out",
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
assert result.expanded_mask_path.is_file()
|
|
51
|
-
assert result.contracted_mask_path.is_file()
|
|
52
|
-
assert result.measurements_path.is_file()
|
|
53
|
-
assert result.metrics.expanded_volume_mm3 == 1.0
|
|
54
|
-
assert result.metrics.contracted_volume_mm3 == 1.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
|