bone-microarchitecture 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.
Files changed (26) hide show
  1. bone_microarchitecture-0.1.0/LICENSE +21 -0
  2. bone_microarchitecture-0.1.0/PKG-INFO +80 -0
  3. bone_microarchitecture-0.1.0/README.md +41 -0
  4. bone_microarchitecture-0.1.0/pyproject.toml +63 -0
  5. bone_microarchitecture-0.1.0/setup.cfg +4 -0
  6. bone_microarchitecture-0.1.0/src/bone_microarchitecture/__init__.py +32 -0
  7. bone_microarchitecture-0.1.0/src/bone_microarchitecture/geometry.py +38 -0
  8. bone_microarchitecture-0.1.0/src/bone_microarchitecture/metal.py +162 -0
  9. bone_microarchitecture-0.1.0/src/bone_microarchitecture/metrics.py +84 -0
  10. bone_microarchitecture-0.1.0/src/bone_microarchitecture/opencl.py +165 -0
  11. bone_microarchitecture-0.1.0/src/bone_microarchitecture/pipeline.py +211 -0
  12. bone_microarchitecture-0.1.0/src/bone_microarchitecture/results.py +164 -0
  13. bone_microarchitecture-0.1.0/src/bone_microarchitecture/thickness.py +343 -0
  14. bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/PKG-INFO +80 -0
  15. bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/SOURCES.txt +24 -0
  16. bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/dependency_links.txt +1 -0
  17. bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/requires.txt +26 -0
  18. bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/top_level.txt +1 -0
  19. bone_microarchitecture-0.1.0/tests/test_attribution.py +16 -0
  20. bone_microarchitecture-0.1.0/tests/test_backends.py +21 -0
  21. bone_microarchitecture-0.1.0/tests/test_geometry.py +57 -0
  22. bone_microarchitecture-0.1.0/tests/test_metrics.py +74 -0
  23. bone_microarchitecture-0.1.0/tests/test_package.py +15 -0
  24. bone_microarchitecture-0.1.0/tests/test_pipeline.py +302 -0
  25. bone_microarchitecture-0.1.0/tests/test_results.py +76 -0
  26. bone_microarchitecture-0.1.0/tests/test_thickness.py +96 -0
@@ -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,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: bone-microarchitecture
3
+ Version: 0.1.0
4
+ Summary: Lightweight bone microarchitecture measurements from masks and calibrated grayscale arrays.
5
+ Author: Matthias Walle
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/wallematthias/bone-microarchitecture
8
+ Project-URL: Repository, https://github.com/wallematthias/bone-microarchitecture
9
+ Project-URL: Issues, https://github.com/wallematthias/bone-microarchitecture/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.10
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 :: Medical Science Apps.
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy>=1.23
22
+ Requires-Dist: scipy>=1.10
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8; extra == "test"
25
+ Requires-Dist: pytest-cov; extra == "test"
26
+ Provides-Extra: dev
27
+ Requires-Dist: build; extra == "dev"
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: pytest-cov; extra == "dev"
30
+ Requires-Dist: twine; extra == "dev"
31
+ Provides-Extra: mps
32
+ Requires-Dist: pyobjc-framework-Metal>=10; extra == "mps"
33
+ Provides-Extra: opencl
34
+ Requires-Dist: pyopencl>=2024.1; extra == "opencl"
35
+ Provides-Extra: gpu
36
+ Requires-Dist: pyobjc-framework-Metal>=10; sys_platform == "darwin" and extra == "gpu"
37
+ Requires-Dist: pyopencl>=2024.1; sys_platform != "darwin" and extra == "gpu"
38
+ Dynamic: license-file
39
+
40
+ # Bone Microarchitecture
41
+
42
+ Lightweight microarchitecture measurements from binary masks and optional calibrated grayscale arrays.
43
+
44
+ This package intentionally has no Slicer dependency and no image I/O dependency. Callers are responsible for loading images, calibration, and putting masks on a common grid.
45
+
46
+ ## GPU Backends
47
+
48
+ Exact Hildebrand sphere fitting supports three diameter-accumulation backends:
49
+
50
+ - `cpu`: NumPy/SciPy fallback.
51
+ - `mps`: native Apple Metal backend for macOS.
52
+ - `opencl`: OpenCL backend for Windows/Linux systems with `pyopencl` and a working GPU OpenCL runtime.
53
+
54
+ Use `thickness_backend="auto"` to select Metal on macOS when available, OpenCL on Windows/Linux when available, and CPU otherwise.
55
+
56
+ Optional installs:
57
+
58
+ ```bash
59
+ pip install "bone-microarchitecture[mps]"
60
+ pip install "bone-microarchitecture[opencl]"
61
+ pip install "bone-microarchitecture[gpu]"
62
+ ```
63
+
64
+ ## Parameter Definitions
65
+
66
+ - `Tb.BMD`: mean calibrated grayscale value inside the trabecular compartment.
67
+ - `Tb.BV/TV`: trabecular bone volume divided by trabecular total volume.
68
+ - `Tb.Th`: maximal-sphere local thickness of trabecular bone.
69
+ - `Tb.Sp`: maximal-sphere local thickness of non-bone space in the trabecular compartment.
70
+ - `Tb.N`: inverse ridge-to-ridge spacing estimate in the trabecular compartment.
71
+ - `Tb.1/N.SD`: standard deviation of ridge-to-ridge spacing.
72
+ - `Tb.BV`: trabecular bone volume.
73
+ - `Tb.TV`: trabecular compartment volume.
74
+ - `Ct.BMD`: mean calibrated grayscale value inside the cortical compartment.
75
+ - `Ct.Th`: maximal-sphere local thickness of cortical bone.
76
+ - `Ct.Po`: cortical pore volume divided by cortical total volume.
77
+ - `Ct.Po.V`: cortical pore volume.
78
+ - `Ct.Po.Dm`: maximal-sphere local diameter of cortical pore space.
79
+ - `Ct.BV`: cortical bone volume.
80
+ - `Ct.TV`: cortical compartment volume.
@@ -0,0 +1,41 @@
1
+ # Bone Microarchitecture
2
+
3
+ Lightweight microarchitecture measurements from binary masks and optional calibrated grayscale arrays.
4
+
5
+ This package intentionally has no Slicer dependency and no image I/O dependency. Callers are responsible for loading images, calibration, and putting masks on a common grid.
6
+
7
+ ## GPU Backends
8
+
9
+ Exact Hildebrand sphere fitting supports three diameter-accumulation backends:
10
+
11
+ - `cpu`: NumPy/SciPy fallback.
12
+ - `mps`: native Apple Metal backend for macOS.
13
+ - `opencl`: OpenCL backend for Windows/Linux systems with `pyopencl` and a working GPU OpenCL runtime.
14
+
15
+ Use `thickness_backend="auto"` to select Metal on macOS when available, OpenCL on Windows/Linux when available, and CPU otherwise.
16
+
17
+ Optional installs:
18
+
19
+ ```bash
20
+ pip install "bone-microarchitecture[mps]"
21
+ pip install "bone-microarchitecture[opencl]"
22
+ pip install "bone-microarchitecture[gpu]"
23
+ ```
24
+
25
+ ## Parameter Definitions
26
+
27
+ - `Tb.BMD`: mean calibrated grayscale value inside the trabecular compartment.
28
+ - `Tb.BV/TV`: trabecular bone volume divided by trabecular total volume.
29
+ - `Tb.Th`: maximal-sphere local thickness of trabecular bone.
30
+ - `Tb.Sp`: maximal-sphere local thickness of non-bone space in the trabecular compartment.
31
+ - `Tb.N`: inverse ridge-to-ridge spacing estimate in the trabecular compartment.
32
+ - `Tb.1/N.SD`: standard deviation of ridge-to-ridge spacing.
33
+ - `Tb.BV`: trabecular bone volume.
34
+ - `Tb.TV`: trabecular compartment volume.
35
+ - `Ct.BMD`: mean calibrated grayscale value inside the cortical compartment.
36
+ - `Ct.Th`: maximal-sphere local thickness of cortical bone.
37
+ - `Ct.Po`: cortical pore volume divided by cortical total volume.
38
+ - `Ct.Po.V`: cortical pore volume.
39
+ - `Ct.Po.Dm`: maximal-sphere local diameter of cortical pore space.
40
+ - `Ct.BV`: cortical bone volume.
41
+ - `Ct.TV`: cortical compartment volume.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bone-microarchitecture"
7
+ version = "0.1.0"
8
+ description = "Lightweight bone microarchitecture measurements from masks and calibrated grayscale arrays."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
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.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
23
+ ]
24
+ dependencies = [
25
+ "numpy>=1.23",
26
+ "scipy>=1.10",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ test = [
31
+ "pytest>=8",
32
+ "pytest-cov",
33
+ ]
34
+ dev = [
35
+ "build",
36
+ "pytest>=8",
37
+ "pytest-cov",
38
+ "twine",
39
+ ]
40
+ mps = [
41
+ "pyobjc-framework-Metal>=10",
42
+ ]
43
+ opencl = [
44
+ "pyopencl>=2024.1",
45
+ ]
46
+ gpu = [
47
+ "pyobjc-framework-Metal>=10; sys_platform == 'darwin'",
48
+ "pyopencl>=2024.1; sys_platform != 'darwin'",
49
+ ]
50
+
51
+ [project.urls]
52
+ Homepage = "https://github.com/wallematthias/bone-microarchitecture"
53
+ Repository = "https://github.com/wallematthias/bone-microarchitecture"
54
+ Issues = "https://github.com/wallematthias/bone-microarchitecture/issues"
55
+
56
+ [tool.setuptools]
57
+ package-dir = {"" = "src"}
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
+ [tool.pytest.ini_options]
63
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ """Direct 3D bone microarchitecture measurements from aligned arrays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from .opencl import opencl_hildebrand_thickness_map
8
+ from .pipeline import compute_microarchitecture
9
+ from .results import MicroarchitectureResult, PARAMETER_DEFINITIONS
10
+ from .thickness import (
11
+ default_thickness_backend,
12
+ hildebrand_thickness_map,
13
+ local_thickness_map,
14
+ trabecular_number_map,
15
+ )
16
+
17
+ try:
18
+ __version__ = version("bone-microarchitecture")
19
+ except PackageNotFoundError:
20
+ __version__ = "0.0.0"
21
+
22
+ __all__ = [
23
+ "MicroarchitectureResult",
24
+ "PARAMETER_DEFINITIONS",
25
+ "__version__",
26
+ "compute_microarchitecture",
27
+ "default_thickness_backend",
28
+ "hildebrand_thickness_map",
29
+ "local_thickness_map",
30
+ "opencl_hildebrand_thickness_map",
31
+ "trabecular_number_map",
32
+ ]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ import numpy as np
6
+
7
+
8
+ def as_bool_mask(mask, name: str) -> np.ndarray:
9
+ """Validate and convert an input array to a 3D boolean mask."""
10
+ array = np.asarray(mask)
11
+ if array.ndim != 3:
12
+ raise ValueError(f"{name} must be a 3D mask.")
13
+ return array > 0
14
+
15
+
16
+ def validate_spacing(spacing) -> tuple[float, float, float]:
17
+ """Return spacing as three positive floats."""
18
+ values = tuple(float(value) for value in spacing)
19
+ if len(values) != 3 or any(value <= 0 for value in values):
20
+ raise ValueError("spacing must contain three positive values.")
21
+ return values
22
+
23
+
24
+ def validate_same_shape(arrays: Mapping[str, np.ndarray]) -> tuple[int, int, int]:
25
+ """Validate that all supplied arrays share one shape."""
26
+ shapes = {name: tuple(array.shape) for name, array in arrays.items() if array is not None}
27
+ unique_shapes = set(shapes.values())
28
+ if len(unique_shapes) > 1:
29
+ detail = ", ".join(f"{name}={shape}" for name, shape in shapes.items())
30
+ raise ValueError(f"All masks and images must have the same shape: {detail}.")
31
+ if not unique_shapes:
32
+ raise ValueError("At least one mask is required.")
33
+ return next(iter(unique_shapes))
34
+
35
+
36
+ def voxel_volume(spacing: tuple[float, float, float]) -> float:
37
+ """Return voxel volume in mm^3 for array-ordered spacing."""
38
+ return float(np.prod(np.asarray(spacing, dtype=float)))
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ METAL_SOURCE = r"""
7
+ #include <metal_stdlib>
8
+ using namespace metal;
9
+
10
+ kernel void accumulate_local_diameters(
11
+ device const uint* seed_z [[buffer(0)]],
12
+ device const uint* seed_y [[buffer(1)]],
13
+ device const uint* seed_x [[buffer(2)]],
14
+ device const float* seed_radius [[buffer(3)]],
15
+ device atomic_uint* diameter_map [[buffer(4)]],
16
+ device const uint* volume_shape [[buffer(5)]],
17
+ device const float* spacing [[buffer(6)]],
18
+ constant float& diameter_margin [[buffer(7)]],
19
+ constant float& output_scale [[buffer(8)]],
20
+ constant float& inclusion_tolerance [[buffer(9)]],
21
+ uint gid [[thread_position_in_grid]]
22
+ ) {
23
+ const float radius = seed_radius[gid];
24
+ const float value_mm = max(2.0f * (radius - diameter_margin), 0.0f);
25
+ if (value_mm <= 0.0f) {
26
+ return;
27
+ }
28
+ const uint value = uint(round(value_mm * output_scale));
29
+
30
+ const int zc = int(seed_z[gid]);
31
+ const int yc = int(seed_y[gid]);
32
+ const int xc = int(seed_x[gid]);
33
+ const int z_extent = int(ceil(radius / spacing[0]));
34
+ const int y_extent = int(ceil(radius / spacing[1]));
35
+ const int x_extent = int(ceil(radius / spacing[2]));
36
+
37
+ const int z0 = max(0, zc - z_extent);
38
+ const int y0 = max(0, yc - y_extent);
39
+ const int x0 = max(0, xc - x_extent);
40
+ const int z1 = min(int(volume_shape[0]), zc + z_extent + 1);
41
+ const int y1 = min(int(volume_shape[1]), yc + y_extent + 1);
42
+ const int x1 = min(int(volume_shape[2]), xc + x_extent + 1);
43
+ const float r2 = radius * radius;
44
+
45
+ for (int z = z0; z < z1; ++z) {
46
+ const float dz = float(z - zc) * spacing[0];
47
+ for (int y = y0; y < y1; ++y) {
48
+ const float dy = float(y - yc) * spacing[1];
49
+ for (int x = x0; x < x1; ++x) {
50
+ const float dx = float(x - xc) * spacing[2];
51
+ if ((dx * dx + dy * dy + dz * dz) <= (r2 + inclusion_tolerance)) {
52
+ const uint index = (uint(z) * volume_shape[1] + uint(y)) * volume_shape[2] + uint(x);
53
+ atomic_fetch_max_explicit(&diameter_map[index], value, memory_order_relaxed);
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ """
60
+
61
+
62
+ def is_metal_available() -> bool:
63
+ """Return whether Apple Metal compute is available through PyObjC."""
64
+ try:
65
+ import Metal
66
+ except Exception:
67
+ return False
68
+ try:
69
+ return Metal.MTLCreateSystemDefaultDevice() is not None
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def metal_hildebrand_thickness_map(
75
+ *,
76
+ shape: tuple[int, int, int],
77
+ seed_z,
78
+ seed_y,
79
+ seed_x,
80
+ seed_radius,
81
+ spacing: tuple[float, float, float],
82
+ diameter_margin: float,
83
+ inclusion_tolerance: float,
84
+ output_scale: float = 1_000_000.0,
85
+ ) -> np.ndarray:
86
+ """Accumulate maximal-sphere diameter values with a native Metal kernel.
87
+
88
+ Seed locations and inscribed radii are selected by :mod:`bone_microarchitecture.thickness`.
89
+ This backend only applies the corresponding diameters to the output volume
90
+ using atomic maximum updates.
91
+ """
92
+ try:
93
+ import Metal
94
+ except Exception as exc:
95
+ raise RuntimeError("Apple Metal backend requires PyObjC Metal bindings.") from exc
96
+
97
+ device = Metal.MTLCreateSystemDefaultDevice()
98
+ if device is None:
99
+ raise RuntimeError("Apple Metal backend is not available on this machine.")
100
+
101
+ seed_z = np.asarray(seed_z, dtype=np.uint32)
102
+ seed_y = np.asarray(seed_y, dtype=np.uint32)
103
+ seed_x = np.asarray(seed_x, dtype=np.uint32)
104
+ seed_radius = np.asarray(seed_radius, dtype=np.float32)
105
+ if not (seed_z.size == seed_y.size == seed_x.size == seed_radius.size):
106
+ raise ValueError("Seed coordinate and radius arrays must have the same length.")
107
+ if seed_radius.size == 0:
108
+ return np.zeros(tuple(shape), dtype=np.float32)
109
+
110
+ library, error = device.newLibraryWithSource_options_error_(METAL_SOURCE, None, None)
111
+ if library is None:
112
+ raise RuntimeError(f"Could not compile Metal sphere fitting kernel: {error}")
113
+ function = library.newFunctionWithName_("accumulate_local_diameters")
114
+ pipeline, error = device.newComputePipelineStateWithFunction_error_(function, None)
115
+ if pipeline is None:
116
+ raise RuntimeError(f"Could not create Metal sphere fitting pipeline: {error}")
117
+
118
+ queue = device.newCommandQueue()
119
+ if queue is None:
120
+ raise RuntimeError("Could not create Metal command queue.")
121
+
122
+ output = np.zeros(int(np.prod(shape)), dtype=np.uint32)
123
+ buffers = [
124
+ _readonly_buffer(device, seed_z),
125
+ _readonly_buffer(device, seed_y),
126
+ _readonly_buffer(device, seed_x),
127
+ _readonly_buffer(device, seed_radius),
128
+ _shared_buffer(device, output),
129
+ _readonly_buffer(device, np.asarray(shape, dtype=np.uint32)),
130
+ _readonly_buffer(device, np.asarray(spacing, dtype=np.float32)),
131
+ _readonly_buffer(device, np.asarray([diameter_margin], dtype=np.float32)),
132
+ _readonly_buffer(device, np.asarray([output_scale], dtype=np.float32)),
133
+ _readonly_buffer(device, np.asarray([inclusion_tolerance], dtype=np.float32)),
134
+ ]
135
+
136
+ command_buffer = queue.commandBuffer()
137
+ encoder = command_buffer.computeCommandEncoder()
138
+ encoder.setComputePipelineState_(pipeline)
139
+ for index, buffer in enumerate(buffers):
140
+ encoder.setBuffer_offset_atIndex_(buffer, 0, index)
141
+
142
+ threads_per_group = Metal.MTLSizeMake(min(int(pipeline.maxTotalThreadsPerThreadgroup()), 256), 1, 1)
143
+ grid = Metal.MTLSizeMake(int(seed_radius.size), 1, 1)
144
+ encoder.dispatchThreads_threadsPerThreadgroup_(grid, threads_per_group)
145
+ encoder.endEncoding()
146
+ command_buffer.commit()
147
+ command_buffer.waitUntilCompleted()
148
+ if command_buffer.error() is not None:
149
+ raise RuntimeError(f"Metal sphere fitting command failed: {command_buffer.error()}")
150
+
151
+ output_view = np.frombuffer(buffers[4].contents().as_buffer(output.nbytes), dtype=np.uint32)
152
+ return (output_view.reshape(tuple(shape)).astype(np.float32) / float(output_scale)).copy()
153
+
154
+
155
+ def _readonly_buffer(device, array: np.ndarray):
156
+ array = np.ascontiguousarray(array)
157
+ return device.newBufferWithBytes_length_options_(array.tobytes(), array.nbytes, 0)
158
+
159
+
160
+ def _shared_buffer(device, array: np.ndarray):
161
+ array = np.ascontiguousarray(array)
162
+ return device.newBufferWithBytes_length_options_(array.tobytes(), array.nbytes, 0)
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from .geometry import voxel_volume
6
+
7
+
8
+ def count_volume(mask, spacing: tuple[float, float, float]) -> float:
9
+ """Return the physical volume represented by non-zero voxels.
10
+
11
+ Args:
12
+ mask: 3D binary mask. Non-zero values are counted.
13
+ spacing: Voxel spacing in millimetres, ordered like the array axes.
14
+
15
+ Returns:
16
+ Volume in mm^3.
17
+ """
18
+ return float(np.count_nonzero(mask) * voxel_volume(spacing))
19
+
20
+
21
+ def masked_mean_sd(image, mask) -> tuple[float, float]:
22
+ """Return mean and standard deviation inside a mask.
23
+
24
+ Non-finite image values are ignored. Empty masks return ``(0.0, 0.0)``.
25
+ """
26
+ values = np.asarray(image, dtype=float)[np.asarray(mask) > 0]
27
+ values = values[np.isfinite(values)]
28
+ if values.size == 0:
29
+ return 0.0, 0.0
30
+ return float(values.mean()), float(values.std(ddof=0))
31
+
32
+
33
+ def compartment_metrics(
34
+ *,
35
+ bone_mask,
36
+ periosteal_mask,
37
+ trabecular_mask,
38
+ cortical_mask=None,
39
+ spacing: tuple[float, float, float],
40
+ mean_tb_th: float = 0.0,
41
+ ) -> dict[str, float]:
42
+ """Calculate scalar compartment measures from binary masks.
43
+
44
+ The trabecular compartment is the intersection of ``trabecular_mask`` and
45
+ ``periosteal_mask``, excluding the cortical compartment when one is supplied.
46
+ Bone volume measures use ``bone_mask`` intersected with the relevant
47
+ compartment. Ratio outputs are unitless fractions.
48
+
49
+ Calculated parameters:
50
+ ``Tb.BV``: trabecular bone volume, in mm^3.
51
+ ``Tb.TV``: trabecular compartment volume, in mm^3.
52
+ ``Tb.BV/TV``: ``Tb.BV / Tb.TV``, as a fraction.
53
+ ``Ct.BV``: cortical bone volume, in mm^3.
54
+ ``Ct.TV``: cortical compartment volume, in mm^3.
55
+ ``Ct.Po.V``: ``Ct.TV - Ct.BV``, in mm^3.
56
+ ``Ct.Po``: ``Ct.Po.V / Ct.TV``, as a fraction.
57
+ ``Tb.N``: fallback scalar ``Tb.BV/TV / mean(Tb.Th)``. The pipeline
58
+ replaces this with the map-based trabecular number estimate.
59
+ """
60
+ trab_region = np.asarray(trabecular_mask) > 0
61
+ peri = np.asarray(periosteal_mask) > 0
62
+ bone = np.asarray(bone_mask) > 0 if bone_mask is not None else trab_region
63
+ cort = np.asarray(cortical_mask) > 0 if cortical_mask is not None else np.zeros_like(trab_region)
64
+ trab_region = trab_region & peri & ~cort
65
+ cort_region = cort
66
+ trab_bone = bone & trab_region
67
+ cortical_bone = bone & cort_region
68
+ tb_bv = count_volume(trab_bone, spacing)
69
+ tb_tv = count_volume(trab_region, spacing)
70
+ ct_bv = count_volume(cortical_bone, spacing)
71
+ ct_tv = count_volume(cort_region, spacing)
72
+ bvtv = tb_bv / tb_tv if tb_tv else 0.0
73
+ ct_po = max(ct_tv - ct_bv, 0.0) / ct_tv if ct_tv else 0.0
74
+ metrics = {
75
+ "Tb.BV/TV": bvtv,
76
+ "Tb.BV": tb_bv,
77
+ "Tb.TV": tb_tv,
78
+ "Ct.BV": ct_bv,
79
+ "Ct.TV": ct_tv,
80
+ "Ct.Po.V": max(ct_tv - ct_bv, 0.0),
81
+ "Ct.Po": ct_po,
82
+ }
83
+ metrics["Tb.N"] = bvtv / mean_tb_th if mean_tb_th else 0.0
84
+ return metrics