voidspace 0.1.0__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.
@@ -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,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,113 @@
1
+ # voidspace
2
+
3
+ Spacing-aware voidspace analysis for HR-pQCT segmentations.
4
+
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.
8
+
9
+ The command line interface reads common SimpleITK image formats and Scanco AIM
10
+ masks. AIM masks are read in native scaling, interpreted as foreground where
11
+ nonzero, and written back as native binary AIM masks using the input geometry.
12
+
13
+ ## Workflows
14
+
15
+ - Cross-sectional: pass a segmented bone image.
16
+ - Masked/common-region: pass a segmented bone image and a mask. If both a
17
+ periosteal contour and a common region are needed, combine them before calling
18
+ `voidspace`.
19
+ - Registered: pass an already registered segmentation and optional registered
20
+ mask.
21
+ - Dynamic change: pass already aligned baseline and follow-up voidspace masks.
22
+
23
+ When no periosteal mask is supplied, the segmentation-only workflow estimates
24
+ 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
+
28
+ ## Algorithm
29
+
30
+ The algorithm follows the original IPL voidspace concept:
31
+
32
+ 1. Remove tiny disconnected bone components.
33
+ 2. Close segmented bone to fill normal trabecular spacing.
34
+ 3. Invert the closed bone region to candidate marrow void.
35
+ 4. Erode candidate void away from trabecular surfaces.
36
+ 5. Remove tiny candidate void components.
37
+ 6. Keep all surviving voids and large voids above a physical volume threshold.
38
+
39
+ Unlike the IPL scripts, morphology uses a spacing-aware spherical or ellipsoidal
40
+ footprint. The default XtremeCT II settings are:
41
+
42
+ - closing radius: `0.738 mm`
43
+ - boundary erosion radius: `0.366 mm`
44
+ - minimum large-void volume: `16.5 mm3`
45
+ - bone and void speckle cleanup: `6 voxels`
46
+
47
+ The original IPL code used `72560` or `72660` voxels as scanner-specific large
48
+ void thresholds in different script/paper contexts. This package treats
49
+ `16.5 mm3` as the default source of truth and converts it from the supplied
50
+ image spacing.
51
+
52
+ ## CLI
53
+
54
+ ```bash
55
+ voidspace run-case \
56
+ --segmentation seg.AIM \
57
+ --mask analysis_domain.AIM \
58
+ --output-dir voidspace-out
59
+ ```
60
+
61
+ ```bash
62
+ voidspace compare \
63
+ --baseline-void baseline_voidspace_large_mask.AIM \
64
+ --followup-void followup_voidspace_large_mask.AIM \
65
+ --mask analysis_domain.AIM \
66
+ --output-dir voidspace-change
67
+ ```
68
+
69
+ `--mask` is optional for both commands. Use `--force` to overwrite existing
70
+ outputs.
71
+
72
+ ## Python
73
+
74
+ ```python
75
+ from voidspace import VoidspaceParameters, run_case
76
+
77
+ result = run_case(
78
+ segmentation_path="seg.AIM",
79
+ mask_path="analysis_domain.AIM",
80
+ output_dir="voidspace-out",
81
+ parameters=VoidspaceParameters.xtremectii_defaults(),
82
+ )
83
+ print(result.metrics.volume_mm3)
84
+ ```
85
+
86
+ ```python
87
+ from voidspace import compare
88
+
89
+ result = compare(
90
+ baseline_void_path="baseline_voidspace_large_mask.AIM",
91
+ followup_void_path="followup_voidspace_large_mask.AIM",
92
+ mask_path="analysis_domain.AIM",
93
+ output_dir="voidspace-change",
94
+ )
95
+ print(result.metrics.net_change_volume_mm3)
96
+ ```
97
+
98
+ ## Citation
99
+
100
+ For cross-sectional voidspace analysis, cite:
101
+
102
+ Whittier DE, Burt LA, Boyd SK. A new approach for quantifying localized bone
103
+ loss by measuring void spaces. Bone. 2021 Feb;143:115785.
104
+ doi: [10.1016/j.bone.2020.115785](https://doi.org/10.1016/j.bone.2020.115785).
105
+ Epub 2020 Dec 2. PMID: 33278655.
106
+
107
+ For dynamic voidspace analysis, cite:
108
+
109
+ Whittier DE, Walle M, Atkins PR, Collins CJ, Zumstein MA, Christen P, Lippuner
110
+ K, Müller R. Structural alterations during fracture healing lead to void spaces
111
+ developing in surrounding bone microarchitecture. Journal of Bone and Mineral
112
+ Research. 2025 Jun;40(6):791-798.
113
+ doi: [10.1093/jbmr/zjaf046](https://doi.org/10.1093/jbmr/zjaf046).
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "voidspace"
7
+ version = "0.1.0"
8
+ description = "Spacing-aware voidspace analysis for HR-pQCT segmentations"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{name = "Matthias Walle"}]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Image Processing",
23
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
24
+ ]
25
+ dependencies = [
26
+ "numpy>=1.26,<3.0",
27
+ "aimio-py>=0.1.8",
28
+ "scipy>=1.10",
29
+ "SimpleITK>=2.2",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ test = ["pytest>=8"]
34
+ dev = [
35
+ "build",
36
+ "pytest>=8",
37
+ "twine",
38
+ ]
39
+
40
+ [project.scripts]
41
+ voidspace = "voidspace.cli:main"
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/wallematthias/voidspace"
45
+ Repository = "https://github.com/wallematthias/voidspace"
46
+ Issues = "https://github.com/wallematthias/voidspace/issues"
47
+
48
+ [tool.setuptools]
49
+ package-dir = {"" = "src"}
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ addopts = "-ra"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
+ )
@@ -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())
@@ -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