voidspace 0.1.0__tar.gz → 0.1.3__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.3}/PKG-INFO +23 -3
- {voidspace-0.1.0 → voidspace-0.1.3}/README.md +22 -2
- {voidspace-0.1.0 → voidspace-0.1.3}/pyproject.toml +1 -1
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/__init__.py +4 -2
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/cli.py +44 -1
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/io.py +2 -2
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/workflows.py +94 -1
- {voidspace-0.1.0 → voidspace-0.1.3/src/voidspace.egg-info}/PKG-INFO +23 -3
- {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_aim_io.py +16 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_cli.py +73 -0
- voidspace-0.1.3/tests/test_workflows.py +121 -0
- voidspace-0.1.0/tests/test_workflows.py +0 -54
- {voidspace-0.1.0 → voidspace-0.1.3}/LICENSE +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/setup.cfg +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/change.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/metrics.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/models.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/morphology.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/segment.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/SOURCES.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/dependency_links.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/entry_points.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/requires.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/top_level.txt +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_change.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_metrics.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_models.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_morphology.py +0 -0
- {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_segment.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.3
|
|
4
4
|
Summary: Spacing-aware voidspace analysis for HR-pQCT segmentations
|
|
5
5
|
Author: Matthias Walle
|
|
6
6
|
License-Expression: MIT
|
|
@@ -91,6 +91,14 @@ voidspace run-case \
|
|
|
91
91
|
--output-dir voidspace-out
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
```bash
|
|
95
|
+
voidspace analyze-maps \
|
|
96
|
+
--large-void voidspace-out/voidspace_large_mask.AIM \
|
|
97
|
+
--all-void voidspace-out/voidspace_all_mask.AIM \
|
|
98
|
+
--mask common_region.AIM \
|
|
99
|
+
--output-dir voidspace-common-region
|
|
100
|
+
```
|
|
101
|
+
|
|
94
102
|
```bash
|
|
95
103
|
voidspace compare \
|
|
96
104
|
--baseline-void baseline_voidspace_large_mask.AIM \
|
|
@@ -99,8 +107,8 @@ voidspace compare \
|
|
|
99
107
|
--output-dir voidspace-change
|
|
100
108
|
```
|
|
101
109
|
|
|
102
|
-
`--mask` is optional for
|
|
103
|
-
outputs.
|
|
110
|
+
`--mask` is optional for `run-case` and `compare`, and required for
|
|
111
|
+
`analyze-maps`. Use `--force` to overwrite existing outputs.
|
|
104
112
|
|
|
105
113
|
## Python
|
|
106
114
|
|
|
@@ -128,6 +136,18 @@ result = compare(
|
|
|
128
136
|
print(result.metrics.net_change_volume_mm3)
|
|
129
137
|
```
|
|
130
138
|
|
|
139
|
+
```python
|
|
140
|
+
from voidspace import analyze_maps
|
|
141
|
+
|
|
142
|
+
result = analyze_maps(
|
|
143
|
+
large_void_path="voidspace-out/voidspace_large_mask.AIM",
|
|
144
|
+
all_void_path="voidspace-out/voidspace_all_mask.AIM",
|
|
145
|
+
mask_path="common_region.AIM",
|
|
146
|
+
output_dir="voidspace-common-region",
|
|
147
|
+
)
|
|
148
|
+
print(result.metrics.vstv_percent)
|
|
149
|
+
```
|
|
150
|
+
|
|
131
151
|
## Citation
|
|
132
152
|
|
|
133
153
|
For cross-sectional voidspace analysis, cite:
|
|
@@ -58,6 +58,14 @@ voidspace run-case \
|
|
|
58
58
|
--output-dir voidspace-out
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
```bash
|
|
62
|
+
voidspace analyze-maps \
|
|
63
|
+
--large-void voidspace-out/voidspace_large_mask.AIM \
|
|
64
|
+
--all-void voidspace-out/voidspace_all_mask.AIM \
|
|
65
|
+
--mask common_region.AIM \
|
|
66
|
+
--output-dir voidspace-common-region
|
|
67
|
+
```
|
|
68
|
+
|
|
61
69
|
```bash
|
|
62
70
|
voidspace compare \
|
|
63
71
|
--baseline-void baseline_voidspace_large_mask.AIM \
|
|
@@ -66,8 +74,8 @@ voidspace compare \
|
|
|
66
74
|
--output-dir voidspace-change
|
|
67
75
|
```
|
|
68
76
|
|
|
69
|
-
`--mask` is optional for
|
|
70
|
-
outputs.
|
|
77
|
+
`--mask` is optional for `run-case` and `compare`, and required for
|
|
78
|
+
`analyze-maps`. Use `--force` to overwrite existing outputs.
|
|
71
79
|
|
|
72
80
|
## Python
|
|
73
81
|
|
|
@@ -95,6 +103,18 @@ result = compare(
|
|
|
95
103
|
print(result.metrics.net_change_volume_mm3)
|
|
96
104
|
```
|
|
97
105
|
|
|
106
|
+
```python
|
|
107
|
+
from voidspace import analyze_maps
|
|
108
|
+
|
|
109
|
+
result = analyze_maps(
|
|
110
|
+
large_void_path="voidspace-out/voidspace_large_mask.AIM",
|
|
111
|
+
all_void_path="voidspace-out/voidspace_all_mask.AIM",
|
|
112
|
+
mask_path="common_region.AIM",
|
|
113
|
+
output_dir="voidspace-common-region",
|
|
114
|
+
)
|
|
115
|
+
print(result.metrics.vstv_percent)
|
|
116
|
+
```
|
|
117
|
+
|
|
98
118
|
## Citation
|
|
99
119
|
|
|
100
120
|
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.3"
|
|
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
|
|
|
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
+
import numpy as np
|
|
6
|
+
import SimpleITK as sitk
|
|
7
|
+
|
|
5
8
|
from voidspace.change import classify_voidspace_change, measure_voidspace_change
|
|
6
9
|
from voidspace.io import AimReference, read_mask, write_mask_like, write_metrics_csv
|
|
7
10
|
from voidspace.metrics import measure_voidspace
|
|
@@ -24,6 +27,56 @@ def _mask_extension(reference) -> str:
|
|
|
24
27
|
return ".AIM" if isinstance(reference, AimReference) else ".nii.gz"
|
|
25
28
|
|
|
26
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 source_image.GetOrigin() == target_image.GetOrigin():
|
|
38
|
+
return np.asarray(array, dtype=bool)
|
|
39
|
+
image = sitk.GetImageFromArray(np.asarray(array, dtype=np.uint8))
|
|
40
|
+
image.CopyInformation(source_image)
|
|
41
|
+
resampled = sitk.Resample(
|
|
42
|
+
image,
|
|
43
|
+
target_image,
|
|
44
|
+
sitk.Transform(),
|
|
45
|
+
sitk.sitkNearestNeighbor,
|
|
46
|
+
0,
|
|
47
|
+
sitk.sitkUInt8,
|
|
48
|
+
)
|
|
49
|
+
return sitk.GetArrayFromImage(resampled).astype(bool)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def intersect_masks(
|
|
53
|
+
mask_paths: list[Path | str] | tuple[Path | str, ...],
|
|
54
|
+
*,
|
|
55
|
+
output_path: Path | str,
|
|
56
|
+
force: bool = False,
|
|
57
|
+
) -> Path:
|
|
58
|
+
"""Write the voxelwise intersection of masks that share one image space."""
|
|
59
|
+
paths = [Path(path) for path in mask_paths]
|
|
60
|
+
if len(paths) < 2:
|
|
61
|
+
raise ValueError("at least two masks are required")
|
|
62
|
+
output_path = Path(output_path)
|
|
63
|
+
if output_path.exists() and not force:
|
|
64
|
+
raise FileExistsError(f"output already exists: {output_path}")
|
|
65
|
+
|
|
66
|
+
intersection, spacing, reference = read_mask(paths[0])
|
|
67
|
+
for path in paths[1:]:
|
|
68
|
+
mask, mask_spacing, mask_reference = read_mask(path)
|
|
69
|
+
if mask_spacing != spacing:
|
|
70
|
+
raise ValueError("all masks must have the same spacing")
|
|
71
|
+
mask = _array_on_reference_grid(mask, mask_reference, reference)
|
|
72
|
+
if mask.shape != intersection.shape:
|
|
73
|
+
raise ValueError("all masks must overlap the reference image space")
|
|
74
|
+
intersection = intersection & mask
|
|
75
|
+
|
|
76
|
+
write_mask_like(intersection, reference, output_path)
|
|
77
|
+
return output_path
|
|
78
|
+
|
|
79
|
+
|
|
27
80
|
def run_case(
|
|
28
81
|
*,
|
|
29
82
|
segmentation_path: Path | str,
|
|
@@ -63,6 +116,45 @@ def run_case(
|
|
|
63
116
|
return VoidspaceRunResult(large_path, all_path, measurements_path, metrics)
|
|
64
117
|
|
|
65
118
|
|
|
119
|
+
def analyze_maps(
|
|
120
|
+
*,
|
|
121
|
+
large_void_path: Path | str,
|
|
122
|
+
all_void_path: Path | str,
|
|
123
|
+
mask_path: Path | str,
|
|
124
|
+
output_dir: Path | str,
|
|
125
|
+
force: bool = False,
|
|
126
|
+
connectivity: int = 3,
|
|
127
|
+
) -> VoidspaceRunResult:
|
|
128
|
+
"""Measure existing voidspace maps within an analysis mask without recomputing maps."""
|
|
129
|
+
output_dir = Path(output_dir)
|
|
130
|
+
large_void, spacing, reference = read_mask(large_void_path)
|
|
131
|
+
mask_extension = _mask_extension(reference)
|
|
132
|
+
large_path = output_dir / f"voidspace_large_mask{mask_extension}"
|
|
133
|
+
all_path = output_dir / f"voidspace_all_mask{mask_extension}"
|
|
134
|
+
measurements_path = output_dir / "voidspace_measurements.csv"
|
|
135
|
+
|
|
136
|
+
existing_outputs = [path for path in (large_path, all_path, measurements_path) if path.exists()]
|
|
137
|
+
if existing_outputs and not force:
|
|
138
|
+
raise FileExistsError(f"output already exists: {existing_outputs[0]}")
|
|
139
|
+
|
|
140
|
+
all_void, all_spacing, _all_reference = read_mask(all_void_path)
|
|
141
|
+
if all_spacing != spacing or all_void.shape != large_void.shape:
|
|
142
|
+
raise ValueError("large and all void masks must be in the same space")
|
|
143
|
+
domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
|
|
144
|
+
if mask_spacing != spacing or domain_mask.shape != large_void.shape:
|
|
145
|
+
raise ValueError("mask must be aligned with the void masks")
|
|
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
|
+
|
|
66
158
|
def compare(
|
|
67
159
|
*,
|
|
68
160
|
baseline_void_path: Path | str,
|
|
@@ -75,7 +167,7 @@ def compare(
|
|
|
75
167
|
output_dir = Path(output_dir)
|
|
76
168
|
baseline, spacing, reference = read_mask(baseline_void_path)
|
|
77
169
|
mask_extension = _mask_extension(reference)
|
|
78
|
-
stable_path = output_dir / f"
|
|
170
|
+
stable_path = output_dir / f"voidspace_quiescent_mask{mask_extension}"
|
|
79
171
|
expanded_path = output_dir / f"voidspace_expanded_mask{mask_extension}"
|
|
80
172
|
contracted_path = output_dir / f"voidspace_contracted_mask{mask_extension}"
|
|
81
173
|
measurements_path = output_dir / "voidspace_change_measurements.csv"
|
|
@@ -105,6 +197,7 @@ def compare(
|
|
|
105
197
|
[
|
|
106
198
|
{
|
|
107
199
|
"stable.VS.V": metrics.stable_volume_mm3,
|
|
200
|
+
"quiescent.VS.V": metrics.stable_volume_mm3,
|
|
108
201
|
"expanded.VS.V": metrics.expanded_volume_mm3,
|
|
109
202
|
"contracted.VS.V": metrics.contracted_volume_mm3,
|
|
110
203
|
"net.VS.V": metrics.net_change_volume_mm3,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voidspace
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Spacing-aware voidspace analysis for HR-pQCT segmentations
|
|
5
5
|
Author: Matthias Walle
|
|
6
6
|
License-Expression: MIT
|
|
@@ -91,6 +91,14 @@ voidspace run-case \
|
|
|
91
91
|
--output-dir voidspace-out
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
```bash
|
|
95
|
+
voidspace analyze-maps \
|
|
96
|
+
--large-void voidspace-out/voidspace_large_mask.AIM \
|
|
97
|
+
--all-void voidspace-out/voidspace_all_mask.AIM \
|
|
98
|
+
--mask common_region.AIM \
|
|
99
|
+
--output-dir voidspace-common-region
|
|
100
|
+
```
|
|
101
|
+
|
|
94
102
|
```bash
|
|
95
103
|
voidspace compare \
|
|
96
104
|
--baseline-void baseline_voidspace_large_mask.AIM \
|
|
@@ -99,8 +107,8 @@ voidspace compare \
|
|
|
99
107
|
--output-dir voidspace-change
|
|
100
108
|
```
|
|
101
109
|
|
|
102
|
-
`--mask` is optional for
|
|
103
|
-
outputs.
|
|
110
|
+
`--mask` is optional for `run-case` and `compare`, and required for
|
|
111
|
+
`analyze-maps`. Use `--force` to overwrite existing outputs.
|
|
104
112
|
|
|
105
113
|
## Python
|
|
106
114
|
|
|
@@ -128,6 +136,18 @@ result = compare(
|
|
|
128
136
|
print(result.metrics.net_change_volume_mm3)
|
|
129
137
|
```
|
|
130
138
|
|
|
139
|
+
```python
|
|
140
|
+
from voidspace import analyze_maps
|
|
141
|
+
|
|
142
|
+
result = analyze_maps(
|
|
143
|
+
large_void_path="voidspace-out/voidspace_large_mask.AIM",
|
|
144
|
+
all_void_path="voidspace-out/voidspace_all_mask.AIM",
|
|
145
|
+
mask_path="common_region.AIM",
|
|
146
|
+
output_dir="voidspace-common-region",
|
|
147
|
+
)
|
|
148
|
+
print(result.metrics.vstv_percent)
|
|
149
|
+
```
|
|
150
|
+
|
|
131
151
|
## Citation
|
|
132
152
|
|
|
133
153
|
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
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import SimpleITK as sitk
|
|
3
|
+
|
|
4
|
+
from voidspace import VoidspaceParameters, analyze_maps, compare, intersect_masks, 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
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_analyze_maps_reuses_existing_void_masks_and_measures_inside_mask(tmp_path):
|
|
58
|
+
all_void = np.ones((3, 3, 3), dtype=bool)
|
|
59
|
+
large_void = np.zeros_like(all_void)
|
|
60
|
+
large_void[0, 0, 0] = True
|
|
61
|
+
large_void[2, 2, 2] = True
|
|
62
|
+
mask = np.zeros_like(all_void)
|
|
63
|
+
mask[0, 0, 0] = True
|
|
64
|
+
mask[0, 0, 1] = True
|
|
65
|
+
_write_mask(tmp_path / "all.nii.gz", all_void)
|
|
66
|
+
_write_mask(tmp_path / "large.nii.gz", large_void)
|
|
67
|
+
_write_mask(tmp_path / "mask.nii.gz", mask)
|
|
68
|
+
|
|
69
|
+
result = analyze_maps(
|
|
70
|
+
large_void_path=tmp_path / "large.nii.gz",
|
|
71
|
+
all_void_path=tmp_path / "all.nii.gz",
|
|
72
|
+
mask_path=tmp_path / "mask.nii.gz",
|
|
73
|
+
output_dir=tmp_path / "masked",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
assert result.large_mask_path.is_file()
|
|
77
|
+
assert result.all_mask_path.is_file()
|
|
78
|
+
assert result.measurements_path.is_file()
|
|
79
|
+
assert result.metrics.volume_mm3 == 1.0
|
|
80
|
+
assert result.metrics.total_volume_mm3 == 2.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_intersect_masks_writes_boolean_intersection(tmp_path):
|
|
84
|
+
full = np.ones((3, 3, 3), dtype=bool)
|
|
85
|
+
full[2, :, :] = False
|
|
86
|
+
common = np.zeros_like(full)
|
|
87
|
+
common[:, 1:, :] = True
|
|
88
|
+
_write_mask(tmp_path / "full.nii.gz", full)
|
|
89
|
+
_write_mask(tmp_path / "common.nii.gz", common)
|
|
90
|
+
|
|
91
|
+
output_path = intersect_masks(
|
|
92
|
+
[tmp_path / "full.nii.gz", tmp_path / "common.nii.gz"],
|
|
93
|
+
output_path=tmp_path / "analysis_mask.nii.gz",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
image = sitk.ReadImage(str(output_path))
|
|
97
|
+
result = sitk.GetArrayFromImage(image).astype(bool)
|
|
98
|
+
assert result.sum() == 12
|
|
99
|
+
assert np.array_equal(result, full & common)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_intersect_masks_resamples_cropped_mask_to_reference_grid(tmp_path):
|
|
103
|
+
full = np.ones((5, 5, 5), dtype=bool)
|
|
104
|
+
common = np.ones((3, 3, 3), dtype=bool)
|
|
105
|
+
_write_mask(tmp_path / "full.nii.gz", full)
|
|
106
|
+
common_image = sitk.GetImageFromArray(common.astype("uint8"))
|
|
107
|
+
common_image.SetSpacing((1.0, 1.0, 1.0))
|
|
108
|
+
common_image.SetOrigin((1.0, 1.0, 1.0))
|
|
109
|
+
sitk.WriteImage(common_image, str(tmp_path / "common.nii.gz"))
|
|
110
|
+
|
|
111
|
+
output_path = intersect_masks(
|
|
112
|
+
[tmp_path / "full.nii.gz", tmp_path / "common.nii.gz"],
|
|
113
|
+
output_path=tmp_path / "analysis_mask.nii.gz",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
image = sitk.ReadImage(str(output_path))
|
|
117
|
+
result = sitk.GetArrayFromImage(image).astype(bool)
|
|
118
|
+
expected = np.zeros_like(full)
|
|
119
|
+
expected[1:4, 1:4, 1:4] = True
|
|
120
|
+
assert image.GetSize() == (5, 5, 5)
|
|
121
|
+
assert np.array_equal(result, expected)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|