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.
- bone_microarchitecture-0.1.0/LICENSE +21 -0
- bone_microarchitecture-0.1.0/PKG-INFO +80 -0
- bone_microarchitecture-0.1.0/README.md +41 -0
- bone_microarchitecture-0.1.0/pyproject.toml +63 -0
- bone_microarchitecture-0.1.0/setup.cfg +4 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/__init__.py +32 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/geometry.py +38 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/metal.py +162 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/metrics.py +84 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/opencl.py +165 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/pipeline.py +211 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/results.py +164 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture/thickness.py +343 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/PKG-INFO +80 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/SOURCES.txt +24 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/dependency_links.txt +1 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/requires.txt +26 -0
- bone_microarchitecture-0.1.0/src/bone_microarchitecture.egg-info/top_level.txt +1 -0
- bone_microarchitecture-0.1.0/tests/test_attribution.py +16 -0
- bone_microarchitecture-0.1.0/tests/test_backends.py +21 -0
- bone_microarchitecture-0.1.0/tests/test_geometry.py +57 -0
- bone_microarchitecture-0.1.0/tests/test_metrics.py +74 -0
- bone_microarchitecture-0.1.0/tests/test_package.py +15 -0
- bone_microarchitecture-0.1.0/tests/test_pipeline.py +302 -0
- bone_microarchitecture-0.1.0/tests/test_results.py +76 -0
- 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,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
|