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 +32 -0
- voidspace/change.py +45 -0
- voidspace/cli.py +111 -0
- voidspace/io.py +140 -0
- voidspace/metrics.py +51 -0
- voidspace/models.py +89 -0
- voidspace/morphology.py +88 -0
- voidspace/segment.py +85 -0
- voidspace/workflows.py +124 -0
- voidspace-0.1.0.dist-info/METADATA +146 -0
- voidspace-0.1.0.dist-info/RECORD +15 -0
- voidspace-0.1.0.dist-info/WHEEL +5 -0
- voidspace-0.1.0.dist-info/entry_points.txt +2 -0
- voidspace-0.1.0.dist-info/licenses/LICENSE +21 -0
- voidspace-0.1.0.dist-info/top_level.txt +1 -0
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
|
voidspace/morphology.py
ADDED
|
@@ -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,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
|