voidspace 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ from voidspace.change import classify_voidspace_change, measure_voidspace_change
2
+ from voidspace.metrics import measure_voidspace
3
+ from voidspace.models import (
4
+ VoidspaceChangeMasks,
5
+ VoidspaceChangeMetrics,
6
+ VoidspaceMasks,
7
+ VoidspaceMetrics,
8
+ VoidspaceParameters,
9
+ VoidspaceCompareResult,
10
+ VoidspaceRunResult,
11
+ )
12
+ from voidspace.segment import segment_voidspace
13
+ from voidspace.workflows import compare, run_case
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "VoidspaceChangeMasks",
19
+ "VoidspaceChangeMetrics",
20
+ "VoidspaceCompareResult",
21
+ "VoidspaceMasks",
22
+ "VoidspaceMetrics",
23
+ "VoidspaceParameters",
24
+ "VoidspaceRunResult",
25
+ "__version__",
26
+ "classify_voidspace_change",
27
+ "compare",
28
+ "measure_voidspace",
29
+ "measure_voidspace_change",
30
+ "run_case",
31
+ "segment_voidspace",
32
+ ]
voidspace/change.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from voidspace.models import VoidspaceChangeMasks, VoidspaceChangeMetrics
6
+ from voidspace.morphology import voxel_volume_mm3
7
+
8
+
9
+ def classify_voidspace_change(
10
+ baseline_void: np.ndarray,
11
+ followup_void: np.ndarray,
12
+ mask: np.ndarray | None = None,
13
+ ) -> VoidspaceChangeMasks:
14
+ baseline = np.asarray(baseline_void, dtype=bool)
15
+ followup = np.asarray(followup_void, dtype=bool)
16
+ if baseline.shape != followup.shape:
17
+ raise ValueError("baseline_void and followup_void must have the same shape")
18
+ if mask is not None:
19
+ mask = np.asarray(mask, dtype=bool)
20
+ if mask.shape != baseline.shape:
21
+ raise ValueError("mask must match void mask shape")
22
+ baseline = baseline & mask
23
+ followup = followup & mask
24
+
25
+ return VoidspaceChangeMasks(
26
+ stable=baseline & followup,
27
+ expanded=followup & ~baseline,
28
+ contracted=baseline & ~followup,
29
+ )
30
+
31
+
32
+ def measure_voidspace_change(
33
+ change: VoidspaceChangeMasks,
34
+ spacing_mm: tuple[float, float, float],
35
+ ) -> VoidspaceChangeMetrics:
36
+ voxel_volume = voxel_volume_mm3(spacing_mm)
37
+ stable = float(np.asarray(change.stable, dtype=bool).sum() * voxel_volume)
38
+ expanded = float(np.asarray(change.expanded, dtype=bool).sum() * voxel_volume)
39
+ contracted = float(np.asarray(change.contracted, dtype=bool).sum() * voxel_volume)
40
+ return VoidspaceChangeMetrics(
41
+ stable_volume_mm3=stable,
42
+ expanded_volume_mm3=expanded,
43
+ contracted_volume_mm3=contracted,
44
+ net_change_volume_mm3=expanded - contracted,
45
+ )
voidspace/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ from typing import Sequence
6
+
7
+ from voidspace.models import VoidspaceParameters
8
+ from voidspace.workflows import compare, run_case
9
+
10
+
11
+ def _parameters_from_args(args: argparse.Namespace) -> VoidspaceParameters:
12
+ return VoidspaceParameters(
13
+ closing_radius_mm=args.closing_radius_mm,
14
+ boundary_erosion_radius_mm=args.boundary_erosion_radius_mm,
15
+ min_large_void_volume_mm3=args.min_large_void_volume_mm3,
16
+ bone_speckle_min_voxels=args.bone_speckle_min_voxels,
17
+ void_speckle_min_voxels=args.void_speckle_min_voxels,
18
+ connectivity=args.connectivity,
19
+ )
20
+
21
+
22
+ def _add_parameter_arguments(parser: argparse.ArgumentParser) -> None:
23
+ defaults = VoidspaceParameters.xtremectii_defaults()
24
+ parser.add_argument("--closing-radius-mm", type=float, default=defaults.closing_radius_mm)
25
+ parser.add_argument(
26
+ "--boundary-erosion-radius-mm",
27
+ type=float,
28
+ default=defaults.boundary_erosion_radius_mm,
29
+ )
30
+ parser.add_argument(
31
+ "--min-large-void-volume-mm3",
32
+ type=float,
33
+ default=defaults.min_large_void_volume_mm3,
34
+ )
35
+ parser.add_argument(
36
+ "--bone-speckle-min-voxels",
37
+ type=int,
38
+ default=defaults.bone_speckle_min_voxels,
39
+ )
40
+ parser.add_argument(
41
+ "--void-speckle-min-voxels",
42
+ type=int,
43
+ default=defaults.void_speckle_min_voxels,
44
+ )
45
+ parser.add_argument("--connectivity", type=int, choices=(1, 2, 3), default=defaults.connectivity)
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="voidspace",
51
+ description="Run spacing-aware voidspace analysis on prepared HR-pQCT masks.",
52
+ )
53
+ subparsers = parser.add_subparsers(dest="command", required=True)
54
+
55
+ run_case = subparsers.add_parser("run-case", help="Segment and measure one prepared scan.")
56
+ run_case.add_argument("--segmentation", type=Path, required=True)
57
+ run_case.add_argument(
58
+ "--mask",
59
+ type=Path,
60
+ help="Optional analysis-domain mask. Combine periosteal/common-region masks before passing.",
61
+ )
62
+ run_case.add_argument("--output-dir", type=Path, required=True)
63
+ run_case.add_argument("--force", action="store_true")
64
+ _add_parameter_arguments(run_case)
65
+ run_case.set_defaults(func=_cmd_run_case)
66
+
67
+ compare = subparsers.add_parser("compare", help="Compare already aligned voidspace masks.")
68
+ compare.add_argument("--baseline-void", type=Path, required=True)
69
+ compare.add_argument("--followup-void", type=Path, required=True)
70
+ compare.add_argument(
71
+ "--mask",
72
+ type=Path,
73
+ help="Optional analysis-domain mask aligned with both voidspace masks.",
74
+ )
75
+ compare.add_argument("--output-dir", type=Path, required=True)
76
+ compare.add_argument("--force", action="store_true")
77
+ compare.set_defaults(func=_cmd_compare)
78
+
79
+ return parser
80
+
81
+
82
+ def _cmd_run_case(args: argparse.Namespace) -> int:
83
+ run_case(
84
+ segmentation_path=args.segmentation,
85
+ mask_path=args.mask,
86
+ output_dir=args.output_dir,
87
+ parameters=_parameters_from_args(args),
88
+ force=args.force,
89
+ )
90
+ return 0
91
+
92
+
93
+ def _cmd_compare(args: argparse.Namespace) -> int:
94
+ compare(
95
+ baseline_void_path=args.baseline_void,
96
+ followup_void_path=args.followup_void,
97
+ mask_path=args.mask,
98
+ output_dir=args.output_dir,
99
+ force=args.force,
100
+ )
101
+ return 0
102
+
103
+
104
+ def main(argv: Sequence[str] | None = None) -> int:
105
+ parser = build_parser()
106
+ args = parser.parse_args(argv)
107
+ return int(args.func(args))
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
voidspace/io.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ import re
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import SimpleITK as sitk
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AimReference:
15
+ path: Path
16
+ metadata: dict[str, Any]
17
+ image: sitk.Image
18
+
19
+
20
+ ImageReference = sitk.Image | AimReference
21
+
22
+
23
+ def is_aim_path(path: Path | str) -> bool:
24
+ return re.search(r"\.aim(?:;\d+)?$", Path(path).name, re.IGNORECASE) is not None
25
+
26
+
27
+ def _load_py_aimio():
28
+ try:
29
+ import py_aimio
30
+ except ImportError as exc:
31
+ raise RuntimeError(
32
+ "AIM files require the PyPI package 'aimio-py'. Install with "
33
+ "`python -m pip install aimio-py`."
34
+ ) from exc
35
+ return py_aimio
36
+
37
+
38
+ def _spacing_zyx_from_image(image: sitk.Image) -> tuple[float, float, float]:
39
+ spacing_xyz = tuple(float(v) for v in image.GetSpacing())
40
+ return (spacing_xyz[2], spacing_xyz[1], spacing_xyz[0])
41
+
42
+
43
+ def _as_zyx(array: np.ndarray, dimensions_xyz: tuple[int, int, int] | None) -> np.ndarray:
44
+ if array.ndim != 3:
45
+ raise ValueError(f"Expected a 3D AIM array, got shape {array.shape}.")
46
+ if dimensions_xyz is None:
47
+ return array
48
+ expected_zyx = (dimensions_xyz[2], dimensions_xyz[1], dimensions_xyz[0])
49
+ if tuple(array.shape) == expected_zyx:
50
+ return array
51
+ if tuple(array.shape) == dimensions_xyz:
52
+ return np.transpose(array, (2, 1, 0))
53
+ return array
54
+
55
+
56
+ def _image_from_aim_array(array: np.ndarray, metadata: dict[str, Any]) -> sitk.Image:
57
+ image = sitk.GetImageFromArray(array)
58
+ spacing = metadata.get("element_size", metadata.get("spacing", (1.0, 1.0, 1.0)))
59
+ if not (isinstance(spacing, (tuple, list)) and len(spacing) == 3):
60
+ spacing = (1.0, 1.0, 1.0)
61
+ image.SetSpacing(tuple(float(v) for v in spacing))
62
+ origin = metadata.get("origin", (0.0, 0.0, 0.0))
63
+ if isinstance(origin, (tuple, list)) and len(origin) >= 3:
64
+ image.SetOrigin(tuple(float(v) for v in origin[:3]))
65
+ direction = metadata.get("direction")
66
+ if isinstance(direction, (tuple, list)) and len(direction) == 9:
67
+ image.SetDirection(tuple(float(v) for v in direction))
68
+ return image
69
+
70
+
71
+ def _read_aim_mask(path: Path) -> tuple[np.ndarray, tuple[float, float, float], AimReference]:
72
+ py_aimio = _load_py_aimio()
73
+ array, metadata = py_aimio.read_aim(str(path), density=False, hu=False)
74
+ metadata = dict(metadata)
75
+ dimensions_raw = metadata.get("dimensions")
76
+ dimensions_xyz = (
77
+ tuple(int(v) for v in dimensions_raw)
78
+ if isinstance(dimensions_raw, (tuple, list)) and len(dimensions_raw) == 3
79
+ else None
80
+ )
81
+ array = _as_zyx(np.asarray(array), dimensions_xyz)
82
+ image = _image_from_aim_array(array, metadata)
83
+ return array.astype(bool), _spacing_zyx_from_image(image), AimReference(path, metadata, image)
84
+
85
+
86
+ def read_mask(path: Path | str) -> tuple[np.ndarray, tuple[float, float, float], ImageReference]:
87
+ path = Path(path)
88
+ if is_aim_path(path):
89
+ return _read_aim_mask(path)
90
+
91
+ image = sitk.ReadImage(str(path))
92
+ array = sitk.GetArrayFromImage(image).astype(bool)
93
+ return array, _spacing_zyx_from_image(image), image
94
+
95
+
96
+ def _write_aim_mask(mask: np.ndarray, reference: AimReference, path: Path) -> None:
97
+ py_aimio = _load_py_aimio()
98
+ image = sitk.GetImageFromArray(np.asarray(mask, dtype=np.uint8))
99
+ image.CopyInformation(reference.image)
100
+ metadata = dict(reference.metadata)
101
+ metadata["dimensions"] = tuple(int(v) for v in image.GetSize())
102
+ metadata["spacing"] = tuple(float(v) for v in image.GetSpacing())
103
+ metadata["element_size"] = tuple(float(v) for v in image.GetSpacing())
104
+ metadata["origin"] = tuple(float(v) for v in image.GetOrigin())
105
+ metadata["direction"] = tuple(float(v) for v in image.GetDirection())
106
+ metadata["unit"] = "native"
107
+ if isinstance(metadata.get("processing_log"), dict) and "processing_log_raw" not in metadata:
108
+ metadata["processing_log_raw"] = py_aimio.dict_to_log(metadata["processing_log"])
109
+ array = (127 * (sitk.GetArrayFromImage(image) > 0)).astype(np.int8)
110
+ py_aimio.write_aim(str(path), array, metadata, unit="native")
111
+
112
+
113
+ def write_mask_like(mask: np.ndarray, reference: ImageReference, path: Path | str) -> Path:
114
+ output = Path(path)
115
+ output.parent.mkdir(parents=True, exist_ok=True)
116
+ if isinstance(reference, AimReference) or is_aim_path(output):
117
+ if not isinstance(reference, AimReference):
118
+ raise ValueError("AIM output requires an AIM reference image.")
119
+ _write_aim_mask(mask, reference, output)
120
+ return output
121
+
122
+ image = sitk.GetImageFromArray(np.asarray(mask, dtype=np.uint8))
123
+ image.CopyInformation(reference)
124
+ sitk.WriteImage(image, str(output))
125
+ return output
126
+
127
+
128
+ def write_metrics_csv(path: Path | str, rows: list[dict[str, object]]) -> Path:
129
+ output = Path(path)
130
+ output.parent.mkdir(parents=True, exist_ok=True)
131
+ fieldnames: list[str] = []
132
+ for row in rows:
133
+ for key in row:
134
+ if key not in fieldnames:
135
+ fieldnames.append(key)
136
+ with output.open("w", newline="", encoding="utf-8") as stream:
137
+ writer = csv.DictWriter(stream, fieldnames=fieldnames)
138
+ writer.writeheader()
139
+ writer.writerows(rows)
140
+ return output
voidspace/metrics.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from scipy import ndimage as ndi
5
+
6
+ from voidspace.models import VoidspaceMetrics
7
+ from voidspace.morphology import connectivity_structure, voxel_volume_mm3
8
+
9
+
10
+ def _validate_same_shape(*arrays: np.ndarray) -> None:
11
+ shapes = {np.asarray(array).shape for array in arrays}
12
+ if len(shapes) != 1:
13
+ raise ValueError(f"all masks must have the same shape, got {sorted(shapes)}")
14
+
15
+
16
+ def measure_voidspace(
17
+ void_mask: np.ndarray,
18
+ total_mask: np.ndarray,
19
+ spacing_mm: tuple[float, float, float],
20
+ mask: np.ndarray | None = None,
21
+ connectivity: int = 3,
22
+ ) -> VoidspaceMetrics:
23
+ void_mask = np.asarray(void_mask, dtype=bool)
24
+ total_mask = np.asarray(total_mask, dtype=bool)
25
+ if void_mask.ndim != 3 or total_mask.ndim != 3:
26
+ raise ValueError("void_mask and total_mask must be 3D arrays")
27
+ _validate_same_shape(void_mask, total_mask)
28
+
29
+ if mask is not None:
30
+ mask = np.asarray(mask, dtype=bool)
31
+ _validate_same_shape(void_mask, mask)
32
+ total = total_mask & mask
33
+ else:
34
+ total = total_mask
35
+
36
+ void = void_mask & total
37
+ voxel_volume = voxel_volume_mm3(spacing_mm)
38
+ void_volume = float(void.sum() * voxel_volume)
39
+ total_volume = float(total.sum() * voxel_volume)
40
+ _labels, component_count = ndi.label(void, structure=connectivity_structure(connectivity))
41
+ projected_voxels = int(np.any(void, axis=0).sum())
42
+ projected_area = float(projected_voxels * float(spacing_mm[1]) * float(spacing_mm[2]))
43
+ vstv = 0.0 if total_volume == 0 else float(100.0 * void_volume / total_volume)
44
+
45
+ return VoidspaceMetrics(
46
+ volume_mm3=void_volume,
47
+ total_volume_mm3=total_volume,
48
+ vstv_percent=vstv,
49
+ component_count=int(component_count),
50
+ projected_area_mm2=projected_area,
51
+ )
voidspace/models.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Mapping
6
+
7
+ import numpy as np
8
+
9
+
10
+ def _validate_non_negative(name: str, value: float | int) -> None:
11
+ if value < 0:
12
+ raise ValueError(f"{name} must be >= 0")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class VoidspaceParameters:
17
+ closing_radius_mm: float = 0.738
18
+ boundary_erosion_radius_mm: float = 0.366
19
+ min_large_void_volume_mm3: float = 16.5
20
+ bone_speckle_min_voxels: int = 6
21
+ void_speckle_min_voxels: int = 6
22
+ connectivity: int = 3
23
+
24
+ def __post_init__(self) -> None:
25
+ for name in (
26
+ "closing_radius_mm",
27
+ "boundary_erosion_radius_mm",
28
+ "min_large_void_volume_mm3",
29
+ "bone_speckle_min_voxels",
30
+ "void_speckle_min_voxels",
31
+ ):
32
+ _validate_non_negative(name, getattr(self, name))
33
+ if self.connectivity not in (1, 2, 3):
34
+ raise ValueError("connectivity must be 1, 2, or 3")
35
+
36
+ @classmethod
37
+ def xtremectii_defaults(cls) -> "VoidspaceParameters":
38
+ return cls()
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class VoidspaceMasks:
43
+ all_void: np.ndarray
44
+ large_void: np.ndarray
45
+ filled_bone: np.ndarray
46
+ metadata: Mapping[str, object] = field(default_factory=dict)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class VoidspaceMetrics:
51
+ volume_mm3: float
52
+ total_volume_mm3: float
53
+ vstv_percent: float
54
+ component_count: int
55
+ projected_area_mm2: float
56
+ metadata: Mapping[str, object] = field(default_factory=dict)
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class VoidspaceChangeMasks:
61
+ stable: np.ndarray
62
+ expanded: np.ndarray
63
+ contracted: np.ndarray
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class VoidspaceChangeMetrics:
68
+ stable_volume_mm3: float
69
+ expanded_volume_mm3: float
70
+ contracted_volume_mm3: float
71
+ net_change_volume_mm3: float
72
+ metadata: Mapping[str, object] = field(default_factory=dict)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class VoidspaceRunResult:
77
+ large_mask_path: Path
78
+ all_mask_path: Path
79
+ measurements_path: Path
80
+ metrics: VoidspaceMetrics
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class VoidspaceCompareResult:
85
+ stable_mask_path: Path
86
+ expanded_mask_path: Path
87
+ contracted_mask_path: Path
88
+ measurements_path: Path
89
+ metrics: VoidspaceChangeMetrics
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ from scipy import ndimage as ndi
7
+
8
+
9
+ def _spacing_array(spacing_mm: tuple[float, float, float]) -> np.ndarray:
10
+ spacing = np.asarray(spacing_mm, dtype=float)
11
+ if spacing.shape != (3,):
12
+ raise ValueError("spacing_mm must contain exactly three values")
13
+ if np.any(spacing <= 0):
14
+ raise ValueError("spacing_mm values must be > 0")
15
+ return spacing
16
+
17
+
18
+ def voxel_volume_mm3(spacing_mm: tuple[float, float, float]) -> float:
19
+ return float(np.prod(_spacing_array(spacing_mm)))
20
+
21
+
22
+ def min_voxels_for_volume(volume_mm3: float, spacing_mm: tuple[float, float, float]) -> int:
23
+ if volume_mm3 < 0:
24
+ raise ValueError("volume_mm3 must be >= 0")
25
+ return int(math.ceil(float(volume_mm3) / voxel_volume_mm3(spacing_mm)))
26
+
27
+
28
+ def ellipsoid_footprint(radius_mm: float, spacing_mm: tuple[float, float, float]) -> np.ndarray:
29
+ if radius_mm < 0:
30
+ raise ValueError("radius_mm must be >= 0")
31
+ spacing = _spacing_array(spacing_mm)
32
+ radii_voxels = np.ceil(radius_mm / spacing).astype(int)
33
+ if radius_mm == 0:
34
+ return np.ones(tuple(2 * radii_voxels + 1), dtype=bool)
35
+
36
+ grids = np.ogrid[
37
+ -radii_voxels[0] : radii_voxels[0] + 1,
38
+ -radii_voxels[1] : radii_voxels[1] + 1,
39
+ -radii_voxels[2] : radii_voxels[2] + 1,
40
+ ]
41
+ distance2 = np.zeros(tuple(2 * radii_voxels + 1), dtype=float)
42
+ for axis_grid, axis_spacing in zip(grids, spacing, strict=True):
43
+ distance2 = distance2 + ((axis_grid * axis_spacing) / radius_mm) ** 2
44
+ return distance2 <= 1.0
45
+
46
+
47
+ def connectivity_structure(connectivity: int) -> np.ndarray:
48
+ if connectivity not in (1, 2, 3):
49
+ raise ValueError("connectivity must be 1, 2, or 3")
50
+ return ndi.generate_binary_structure(rank=3, connectivity=connectivity)
51
+
52
+
53
+ def remove_small_components(mask: np.ndarray, min_voxels: int, connectivity: int = 3) -> np.ndarray:
54
+ mask = np.asarray(mask, dtype=bool)
55
+ if mask.ndim != 3:
56
+ raise ValueError("mask must be a 3D array")
57
+ if min_voxels <= 1:
58
+ return mask.copy()
59
+
60
+ labels, count = ndi.label(mask, structure=connectivity_structure(connectivity))
61
+ if count == 0:
62
+ return np.zeros_like(mask, dtype=bool)
63
+ sizes = np.bincount(labels.ravel())
64
+ keep = sizes >= int(min_voxels)
65
+ keep[0] = False
66
+ return keep[labels]
67
+
68
+
69
+ def remove_border_connected_components(mask: np.ndarray, connectivity: int = 3) -> np.ndarray:
70
+ mask = np.asarray(mask, dtype=bool)
71
+ if mask.ndim != 3:
72
+ raise ValueError("mask must be a 3D array")
73
+
74
+ labels, count = ndi.label(mask, structure=connectivity_structure(connectivity))
75
+ if count == 0:
76
+ return np.zeros_like(mask, dtype=bool)
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]))
84
+ border_labels.discard(0)
85
+ remove = np.zeros(count + 1, dtype=bool)
86
+ if border_labels:
87
+ remove[list(border_labels)] = True
88
+ return mask & ~remove[labels]
voidspace/segment.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from scipy import ndimage as ndi
5
+
6
+ from voidspace.models import VoidspaceMasks, VoidspaceParameters
7
+ from voidspace.morphology import (
8
+ ellipsoid_footprint,
9
+ min_voxels_for_volume,
10
+ remove_border_connected_components,
11
+ remove_small_components,
12
+ )
13
+
14
+
15
+ def _validate_3d(name: str, array: np.ndarray) -> np.ndarray:
16
+ mask = np.asarray(array, dtype=bool)
17
+ if mask.ndim != 3:
18
+ raise ValueError(f"{name} must be a 3D array")
19
+ return mask
20
+
21
+
22
+ def _validate_same_shape(*arrays: np.ndarray) -> None:
23
+ shapes = {array.shape for array in arrays}
24
+ if len(shapes) != 1:
25
+ raise ValueError(f"all masks must have the same shape, got {sorted(shapes)}")
26
+
27
+
28
+ def segment_voidspace(
29
+ segmentation: np.ndarray,
30
+ spacing_mm: tuple[float, float, float],
31
+ mask: np.ndarray | None = None,
32
+ parameters: VoidspaceParameters | None = None,
33
+ ) -> VoidspaceMasks:
34
+ params = parameters or VoidspaceParameters.xtremectii_defaults()
35
+ bone = _validate_3d("segmentation", segmentation)
36
+ if mask is None:
37
+ domain = np.ones_like(bone, dtype=bool)
38
+ domain_source = "segmentation_border_background"
39
+ else:
40
+ domain = _validate_3d("mask", mask)
41
+ _validate_same_shape(bone, domain)
42
+ domain_source = "mask"
43
+
44
+ bone_in_domain = remove_small_components(
45
+ bone & domain,
46
+ params.bone_speckle_min_voxels,
47
+ connectivity=params.connectivity,
48
+ )
49
+ closing_fp = ellipsoid_footprint(params.closing_radius_mm, spacing_mm)
50
+ filled_bone = ndi.binary_closing(bone_in_domain, structure=closing_fp) & domain
51
+ if mask is None:
52
+ interior_background = remove_border_connected_components(
53
+ ~filled_bone,
54
+ connectivity=params.connectivity,
55
+ )
56
+ domain = filled_bone | interior_background
57
+ filled_bone = filled_bone & domain
58
+ candidate_void = domain & ~filled_bone
59
+
60
+ if params.boundary_erosion_radius_mm > 0:
61
+ 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)
63
+ candidate_void = candidate_void & domain
64
+
65
+ all_void = remove_small_components(
66
+ candidate_void,
67
+ params.void_speckle_min_voxels,
68
+ connectivity=params.connectivity,
69
+ )
70
+ large_min_voxels = min_voxels_for_volume(params.min_large_void_volume_mm3, spacing_mm)
71
+ large_void = remove_small_components(all_void, large_min_voxels, connectivity=params.connectivity)
72
+
73
+ return VoidspaceMasks(
74
+ all_void=all_void,
75
+ large_void=large_void,
76
+ filled_bone=filled_bone,
77
+ metadata={
78
+ "spacing_mm": tuple(float(v) for v in spacing_mm),
79
+ "closing_radius_mm": params.closing_radius_mm,
80
+ "boundary_erosion_radius_mm": params.boundary_erosion_radius_mm,
81
+ "min_large_void_volume_mm3": params.min_large_void_volume_mm3,
82
+ "structuring_element": "spacing_aware_ellipsoid",
83
+ "domain_source": domain_source,
84
+ },
85
+ )
voidspace/workflows.py ADDED
@@ -0,0 +1,124 @@
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
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: voidspace
3
+ Version: 0.1.0
4
+ Summary: Spacing-aware voidspace analysis for HR-pQCT segmentations
5
+ Author: Matthias Walle
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/wallematthias/voidspace
8
+ Project-URL: Repository, https://github.com/wallematthias/voidspace
9
+ Project-URL: Issues, https://github.com/wallematthias/voidspace/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
18
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy<3.0,>=1.26
23
+ Requires-Dist: aimio-py>=0.1.8
24
+ Requires-Dist: scipy>=1.10
25
+ Requires-Dist: SimpleITK>=2.2
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=8; extra == "test"
28
+ Provides-Extra: dev
29
+ Requires-Dist: build; extra == "dev"
30
+ Requires-Dist: pytest>=8; extra == "dev"
31
+ Requires-Dist: twine; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # voidspace
35
+
36
+ Spacing-aware voidspace analysis for HR-pQCT segmentations.
37
+
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.
41
+
42
+ The command line interface reads common SimpleITK image formats and Scanco AIM
43
+ masks. AIM masks are read in native scaling, interpreted as foreground where
44
+ nonzero, and written back as native binary AIM masks using the input geometry.
45
+
46
+ ## Workflows
47
+
48
+ - Cross-sectional: pass a segmented bone image.
49
+ - Masked/common-region: pass a segmented bone image and a mask. If both a
50
+ periosteal contour and a common region are needed, combine them before calling
51
+ `voidspace`.
52
+ - Registered: pass an already registered segmentation and optional registered
53
+ mask.
54
+ - Dynamic change: pass already aligned baseline and follow-up voidspace masks.
55
+
56
+ When no periosteal mask is supplied, the segmentation-only workflow estimates
57
+ 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
+
61
+ ## Algorithm
62
+
63
+ The algorithm follows the original IPL voidspace concept:
64
+
65
+ 1. Remove tiny disconnected bone components.
66
+ 2. Close segmented bone to fill normal trabecular spacing.
67
+ 3. Invert the closed bone region to candidate marrow void.
68
+ 4. Erode candidate void away from trabecular surfaces.
69
+ 5. Remove tiny candidate void components.
70
+ 6. Keep all surviving voids and large voids above a physical volume threshold.
71
+
72
+ Unlike the IPL scripts, morphology uses a spacing-aware spherical or ellipsoidal
73
+ footprint. The default XtremeCT II settings are:
74
+
75
+ - closing radius: `0.738 mm`
76
+ - boundary erosion radius: `0.366 mm`
77
+ - minimum large-void volume: `16.5 mm3`
78
+ - bone and void speckle cleanup: `6 voxels`
79
+
80
+ The original IPL code used `72560` or `72660` voxels as scanner-specific large
81
+ void thresholds in different script/paper contexts. This package treats
82
+ `16.5 mm3` as the default source of truth and converts it from the supplied
83
+ image spacing.
84
+
85
+ ## CLI
86
+
87
+ ```bash
88
+ voidspace run-case \
89
+ --segmentation seg.AIM \
90
+ --mask analysis_domain.AIM \
91
+ --output-dir voidspace-out
92
+ ```
93
+
94
+ ```bash
95
+ voidspace compare \
96
+ --baseline-void baseline_voidspace_large_mask.AIM \
97
+ --followup-void followup_voidspace_large_mask.AIM \
98
+ --mask analysis_domain.AIM \
99
+ --output-dir voidspace-change
100
+ ```
101
+
102
+ `--mask` is optional for both commands. Use `--force` to overwrite existing
103
+ outputs.
104
+
105
+ ## Python
106
+
107
+ ```python
108
+ from voidspace import VoidspaceParameters, run_case
109
+
110
+ result = run_case(
111
+ segmentation_path="seg.AIM",
112
+ mask_path="analysis_domain.AIM",
113
+ output_dir="voidspace-out",
114
+ parameters=VoidspaceParameters.xtremectii_defaults(),
115
+ )
116
+ print(result.metrics.volume_mm3)
117
+ ```
118
+
119
+ ```python
120
+ from voidspace import compare
121
+
122
+ result = compare(
123
+ baseline_void_path="baseline_voidspace_large_mask.AIM",
124
+ followup_void_path="followup_voidspace_large_mask.AIM",
125
+ mask_path="analysis_domain.AIM",
126
+ output_dir="voidspace-change",
127
+ )
128
+ print(result.metrics.net_change_volume_mm3)
129
+ ```
130
+
131
+ ## Citation
132
+
133
+ For cross-sectional voidspace analysis, cite:
134
+
135
+ Whittier DE, Burt LA, Boyd SK. A new approach for quantifying localized bone
136
+ loss by measuring void spaces. Bone. 2021 Feb;143:115785.
137
+ doi: [10.1016/j.bone.2020.115785](https://doi.org/10.1016/j.bone.2020.115785).
138
+ Epub 2020 Dec 2. PMID: 33278655.
139
+
140
+ For dynamic voidspace analysis, cite:
141
+
142
+ Whittier DE, Walle M, Atkins PR, Collins CJ, Zumstein MA, Christen P, Lippuner
143
+ K, Müller R. Structural alterations during fracture healing lead to void spaces
144
+ developing in surrounding bone microarchitecture. Journal of Bone and Mineral
145
+ Research. 2025 Jun;40(6):791-798.
146
+ doi: [10.1093/jbmr/zjaf046](https://doi.org/10.1093/jbmr/zjaf046).
@@ -0,0 +1,15 @@
1
+ voidspace/__init__.py,sha256=lvcBRhs0JRNB5RDXGpE1hlDZ3MqRARFdZeqLJ6IW0hw,823
2
+ voidspace/change.py,sha256=gI5y5NBOZj0MVTzkOGwCFMEcax_bTOO7YmX42XMEabo,1614
3
+ voidspace/cli.py,sha256=KnDqWWM8qsLWNpiElFIPmbD7B4_yEfDMzzMQ1Xc_9TU,3702
4
+ voidspace/io.py,sha256=GyoD_Ek_Z53ZgTuaUhnDZeqAX-WS-Xo2e7lopcaoESo,5293
5
+ voidspace/metrics.py,sha256=9RaCi3ik_Ea8Ql_HZpZ3taA7KXUebmDrbuuWjZ9ij9M,1835
6
+ voidspace/models.py,sha256=duy39QYj3xoo1TbGGmPX8FGkpHpbm3LxhXag2b5Nt44,2281
7
+ voidspace/morphology.py,sha256=U03AbMzsB6uD1ehGl-ju0eivzQqX0d132qtcr8JE3Ps,3218
8
+ voidspace/segment.py,sha256=XyifSLR9je46DCqD9VUcnhXSkDM57aqtI_jF_5_gr1s,3093
9
+ voidspace/workflows.py,sha256=uqk0r3x_aYmtZzrGqN69TdHe3YIhFbVmdX6Ww3TMd7c,5029
10
+ voidspace-0.1.0.dist-info/licenses/LICENSE,sha256=s4uvriA89nP0_DvvrvWoI7dplf8AtGVGOFp9yDO74IE,1071
11
+ voidspace-0.1.0.dist-info/METADATA,sha256=VgI_UW-08BSqdSnSq1eyqKKyfwN6eaOwmPE0PRMmOV4,5086
12
+ voidspace-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ voidspace-0.1.0.dist-info/entry_points.txt,sha256=7qlvG-szDtei8AJifqUjbIfvuyICJfqVVFaE3oIDweM,49
14
+ voidspace-0.1.0.dist-info/top_level.txt,sha256=TXLDXiIXhXiYiJCImh9KpdNOqJDznV3tevg41-O4hOs,10
15
+ voidspace-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ voidspace = voidspace.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthias Walle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ voidspace