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.
Files changed (29) hide show
  1. {voidspace-0.1.0/src/voidspace.egg-info → voidspace-0.1.3}/PKG-INFO +23 -3
  2. {voidspace-0.1.0 → voidspace-0.1.3}/README.md +22 -2
  3. {voidspace-0.1.0 → voidspace-0.1.3}/pyproject.toml +1 -1
  4. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/__init__.py +4 -2
  5. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/cli.py +44 -1
  6. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/io.py +2 -2
  7. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/workflows.py +94 -1
  8. {voidspace-0.1.0 → voidspace-0.1.3/src/voidspace.egg-info}/PKG-INFO +23 -3
  9. {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_aim_io.py +16 -0
  10. {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_cli.py +73 -0
  11. voidspace-0.1.3/tests/test_workflows.py +121 -0
  12. voidspace-0.1.0/tests/test_workflows.py +0 -54
  13. {voidspace-0.1.0 → voidspace-0.1.3}/LICENSE +0 -0
  14. {voidspace-0.1.0 → voidspace-0.1.3}/setup.cfg +0 -0
  15. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/change.py +0 -0
  16. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/metrics.py +0 -0
  17. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/models.py +0 -0
  18. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/morphology.py +0 -0
  19. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace/segment.py +0 -0
  20. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/SOURCES.txt +0 -0
  21. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/dependency_links.txt +0 -0
  22. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/entry_points.txt +0 -0
  23. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/requires.txt +0 -0
  24. {voidspace-0.1.0 → voidspace-0.1.3}/src/voidspace.egg-info/top_level.txt +0 -0
  25. {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_change.py +0 -0
  26. {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_metrics.py +0 -0
  27. {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_models.py +0 -0
  28. {voidspace-0.1.0 → voidspace-0.1.3}/tests/test_morphology.py +0 -0
  29. {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.0
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 both commands. Use `--force` to overwrite existing
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 both commands. Use `--force` to overwrite existing
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "voidspace"
7
- version = "0.1.0"
7
+ version = "0.1.3"
8
8
  description = "Spacing-aware voidspace analysis for HR-pQCT segmentations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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.0"
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 isinstance(reference, AimReference) or is_aim_path(output):
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"voidspace_stable_mask{mask_extension}"
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.0
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 both commands. Use `--force` to overwrite existing
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