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.
- voidspace-0.1.0/LICENSE +21 -0
- voidspace-0.1.0/PKG-INFO +146 -0
- voidspace-0.1.0/README.md +113 -0
- voidspace-0.1.0/pyproject.toml +56 -0
- voidspace-0.1.0/setup.cfg +4 -0
- voidspace-0.1.0/src/voidspace/__init__.py +32 -0
- voidspace-0.1.0/src/voidspace/change.py +45 -0
- voidspace-0.1.0/src/voidspace/cli.py +111 -0
- voidspace-0.1.0/src/voidspace/io.py +140 -0
- voidspace-0.1.0/src/voidspace/metrics.py +51 -0
- voidspace-0.1.0/src/voidspace/models.py +89 -0
- voidspace-0.1.0/src/voidspace/morphology.py +88 -0
- voidspace-0.1.0/src/voidspace/segment.py +85 -0
- voidspace-0.1.0/src/voidspace/workflows.py +124 -0
- voidspace-0.1.0/src/voidspace.egg-info/PKG-INFO +146 -0
- voidspace-0.1.0/src/voidspace.egg-info/SOURCES.txt +26 -0
- voidspace-0.1.0/src/voidspace.egg-info/dependency_links.txt +1 -0
- voidspace-0.1.0/src/voidspace.egg-info/entry_points.txt +2 -0
- voidspace-0.1.0/src/voidspace.egg-info/requires.txt +12 -0
- voidspace-0.1.0/src/voidspace.egg-info/top_level.txt +1 -0
- voidspace-0.1.0/tests/test_aim_io.py +58 -0
- voidspace-0.1.0/tests/test_change.py +46 -0
- voidspace-0.1.0/tests/test_cli.py +127 -0
- voidspace-0.1.0/tests/test_metrics.py +32 -0
- voidspace-0.1.0/tests/test_models.py +21 -0
- voidspace-0.1.0/tests/test_morphology.py +34 -0
- voidspace-0.1.0/tests/test_segment.py +70 -0
- voidspace-0.1.0/tests/test_workflows.py +54 -0
voidspace-0.1.0/LICENSE
ADDED
|
@@ -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.
|
voidspace-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|