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.
Files changed (30) hide show
  1. {voidspace-0.1.0/src/voidspace.egg-info → voidspace-0.1.4}/PKG-INFO +40 -7
  2. {voidspace-0.1.0 → voidspace-0.1.4}/README.md +39 -6
  3. {voidspace-0.1.0 → voidspace-0.1.4}/pyproject.toml +1 -1
  4. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/__init__.py +4 -2
  5. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/cli.py +44 -1
  6. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/io.py +2 -2
  7. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/morphology.py +5 -7
  8. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/segment.py +16 -3
  9. voidspace-0.1.4/src/voidspace/workflows.py +215 -0
  10. {voidspace-0.1.0 → voidspace-0.1.4/src/voidspace.egg-info}/PKG-INFO +40 -7
  11. {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_aim_io.py +16 -0
  12. {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_cli.py +73 -0
  13. {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_segment.py +28 -1
  14. voidspace-0.1.4/tests/test_workflows.py +163 -0
  15. voidspace-0.1.0/src/voidspace/workflows.py +0 -124
  16. voidspace-0.1.0/tests/test_workflows.py +0 -54
  17. {voidspace-0.1.0 → voidspace-0.1.4}/LICENSE +0 -0
  18. {voidspace-0.1.0 → voidspace-0.1.4}/setup.cfg +0 -0
  19. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/change.py +0 -0
  20. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/metrics.py +0 -0
  21. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace/models.py +0 -0
  22. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/SOURCES.txt +0 -0
  23. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/dependency_links.txt +0 -0
  24. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/entry_points.txt +0 -0
  25. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/requires.txt +0 -0
  26. {voidspace-0.1.0 → voidspace-0.1.4}/src/voidspace.egg-info/top_level.txt +0 -0
  27. {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_change.py +0 -0
  28. {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_metrics.py +0 -0
  29. {voidspace-0.1.0 → voidspace-0.1.4}/tests/test_models.py +0 -0
  30. {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.0
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, create common regions, or resample images. It expects inputs that are
40
- already in the image space to analyze.
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 the image border. Supplying an explicit mask remains preferred
59
- when that contour is available.
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 both commands. Use `--force` to overwrite existing
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, create common regions, or resample images. It expects inputs that are
7
- already in the image space to analyze.
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 the image border. Supplying an explicit mask remains preferred
26
- when that contour is available.
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 both commands. Use `--force` to overwrite existing
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:
@@ -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.4"
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.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 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
 
@@ -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(np.unique(labels[0, :, :]))
79
- border_labels.update(np.unique(labels[-1, :, :]))
80
- border_labels.update(np.unique(labels[:, 0, :]))
81
- border_labels.update(np.unique(labels[:, -1, :]))
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 = "segmentation_border_background"
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 = ndi.binary_closing(bone_in_domain, structure=closing_fp) & domain
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 = ndi.binary_erosion(candidate_void, structure=erosion_fp, border_value=0)
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.0
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, create common regions, or resample images. It expects inputs that are
40
- already in the image space to analyze.
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 the image border. Supplying an explicit mask remains preferred
59
- when that contour is available.
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 both commands. Use `--force` to overwrite existing
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"] == "segmentation_border_background"
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