bone-microarchitecture 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bone_microarchitecture/__init__.py +32 -0
- bone_microarchitecture/geometry.py +38 -0
- bone_microarchitecture/metal.py +162 -0
- bone_microarchitecture/metrics.py +84 -0
- bone_microarchitecture/opencl.py +165 -0
- bone_microarchitecture/pipeline.py +211 -0
- bone_microarchitecture/results.py +164 -0
- bone_microarchitecture/thickness.py +343 -0
- bone_microarchitecture-0.1.0.dist-info/METADATA +80 -0
- bone_microarchitecture-0.1.0.dist-info/RECORD +13 -0
- bone_microarchitecture-0.1.0.dist-info/WHEEL +5 -0
- bone_microarchitecture-0.1.0.dist-info/licenses/LICENSE +21 -0
- bone_microarchitecture-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
OPENCL_SOURCE = r"""
|
|
7
|
+
__kernel void accumulate_local_diameters_kernel(
|
|
8
|
+
__global const unsigned int *seed_z,
|
|
9
|
+
__global const unsigned int *seed_y,
|
|
10
|
+
__global const unsigned int *seed_x,
|
|
11
|
+
__global const float *seed_radius,
|
|
12
|
+
const unsigned int seed_count,
|
|
13
|
+
__global volatile unsigned int *diameter_map,
|
|
14
|
+
__global const unsigned int *volume_shape,
|
|
15
|
+
__global const float *spacing,
|
|
16
|
+
const float diameter_margin,
|
|
17
|
+
const float output_scale,
|
|
18
|
+
const float inclusion_tolerance
|
|
19
|
+
) {
|
|
20
|
+
const int gid = get_global_id(0);
|
|
21
|
+
if ((unsigned int)gid >= seed_count) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const float radius = seed_radius[gid];
|
|
25
|
+
const float value_mm = fmax(2.0f * (radius - diameter_margin), 0.0f);
|
|
26
|
+
if (value_mm <= 0.0f) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const unsigned int value = (unsigned int)rint(value_mm * output_scale);
|
|
30
|
+
|
|
31
|
+
const int zc = (int)seed_z[gid];
|
|
32
|
+
const int yc = (int)seed_y[gid];
|
|
33
|
+
const int xc = (int)seed_x[gid];
|
|
34
|
+
const int z_extent = (int)ceil(radius / spacing[0]);
|
|
35
|
+
const int y_extent = (int)ceil(radius / spacing[1]);
|
|
36
|
+
const int x_extent = (int)ceil(radius / spacing[2]);
|
|
37
|
+
|
|
38
|
+
const int z0 = max(0, zc - z_extent);
|
|
39
|
+
const int y0 = max(0, yc - y_extent);
|
|
40
|
+
const int x0 = max(0, xc - x_extent);
|
|
41
|
+
const int z1 = min((int)volume_shape[0], zc + z_extent + 1);
|
|
42
|
+
const int y1 = min((int)volume_shape[1], yc + y_extent + 1);
|
|
43
|
+
const int x1 = min((int)volume_shape[2], xc + x_extent + 1);
|
|
44
|
+
const float r2 = radius * radius;
|
|
45
|
+
|
|
46
|
+
for (int z = z0; z < z1; ++z) {
|
|
47
|
+
const float dz = (float)(z - zc) * spacing[0];
|
|
48
|
+
for (int y = y0; y < y1; ++y) {
|
|
49
|
+
const float dy = (float)(y - yc) * spacing[1];
|
|
50
|
+
for (int x = x0; x < x1; ++x) {
|
|
51
|
+
const float dx = (float)(x - xc) * spacing[2];
|
|
52
|
+
if ((dx * dx + dy * dy + dz * dz) <= (r2 + inclusion_tolerance)) {
|
|
53
|
+
const unsigned int index = ((unsigned int)z * volume_shape[1] + (unsigned int)y) * volume_shape[2] + (unsigned int)x;
|
|
54
|
+
atomic_max(&diameter_map[index], value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
_OPENCL_CACHE = {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_opencl_available(prefer_gpu: bool = True) -> bool:
|
|
66
|
+
"""Return whether an OpenCL device is available through PyOpenCL."""
|
|
67
|
+
try:
|
|
68
|
+
import pyopencl as cl
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
_select_device(cl, prefer_gpu=prefer_gpu)
|
|
73
|
+
return True
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def opencl_hildebrand_thickness_map(
|
|
79
|
+
*,
|
|
80
|
+
shape: tuple[int, int, int],
|
|
81
|
+
seed_z,
|
|
82
|
+
seed_y,
|
|
83
|
+
seed_x,
|
|
84
|
+
seed_radius,
|
|
85
|
+
spacing: tuple[float, float, float],
|
|
86
|
+
diameter_margin: float,
|
|
87
|
+
inclusion_tolerance: float,
|
|
88
|
+
output_scale: float = 1_000_000.0,
|
|
89
|
+
) -> np.ndarray:
|
|
90
|
+
"""Accumulate maximal-sphere diameter values with an OpenCL kernel.
|
|
91
|
+
|
|
92
|
+
Seed locations and inscribed radii are selected by :mod:`bone_microarchitecture.thickness`.
|
|
93
|
+
This backend only applies the corresponding diameters to the output volume
|
|
94
|
+
using atomic maximum updates.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
import pyopencl as cl
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
raise RuntimeError("OpenCL backend requires pyopencl and an OpenCL runtime.") from exc
|
|
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
|
+
ctx, queue, program = _get_opencl_program(cl)
|
|
111
|
+
flags = cl.mem_flags
|
|
112
|
+
output = np.zeros(int(np.prod(shape)), dtype=np.uint32)
|
|
113
|
+
buffers = [
|
|
114
|
+
cl.Buffer(ctx, flags.READ_ONLY | flags.COPY_HOST_PTR, hostbuf=np.ascontiguousarray(seed_z)),
|
|
115
|
+
cl.Buffer(ctx, flags.READ_ONLY | flags.COPY_HOST_PTR, hostbuf=np.ascontiguousarray(seed_y)),
|
|
116
|
+
cl.Buffer(ctx, flags.READ_ONLY | flags.COPY_HOST_PTR, hostbuf=np.ascontiguousarray(seed_x)),
|
|
117
|
+
cl.Buffer(ctx, flags.READ_ONLY | flags.COPY_HOST_PTR, hostbuf=np.ascontiguousarray(seed_radius)),
|
|
118
|
+
np.uint32(seed_radius.size),
|
|
119
|
+
cl.Buffer(ctx, flags.READ_WRITE | flags.COPY_HOST_PTR, hostbuf=output),
|
|
120
|
+
cl.Buffer(ctx, flags.READ_ONLY | flags.COPY_HOST_PTR, hostbuf=np.asarray(shape, dtype=np.uint32)),
|
|
121
|
+
cl.Buffer(ctx, flags.READ_ONLY | flags.COPY_HOST_PTR, hostbuf=np.asarray(spacing, dtype=np.float32)),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
local_size = 256
|
|
125
|
+
global_size = int(((int(seed_radius.size) + local_size - 1) // local_size) * local_size)
|
|
126
|
+
program.accumulate_local_diameters_kernel(
|
|
127
|
+
queue,
|
|
128
|
+
(global_size,),
|
|
129
|
+
(local_size,),
|
|
130
|
+
*buffers,
|
|
131
|
+
np.float32(diameter_margin),
|
|
132
|
+
np.float32(output_scale),
|
|
133
|
+
np.float32(inclusion_tolerance),
|
|
134
|
+
)
|
|
135
|
+
cl.enqueue_copy(queue, output, buffers[5])
|
|
136
|
+
queue.finish()
|
|
137
|
+
return (output.reshape(tuple(shape)).astype(np.float32) / float(output_scale)).copy()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_opencl_program(cl):
|
|
141
|
+
key = "default"
|
|
142
|
+
if key in _OPENCL_CACHE:
|
|
143
|
+
return _OPENCL_CACHE[key]
|
|
144
|
+
device = _select_device(cl, prefer_gpu=True)
|
|
145
|
+
ctx = cl.Context([device])
|
|
146
|
+
command_queue = cl.CommandQueue(ctx)
|
|
147
|
+
program = cl.Program(ctx, OPENCL_SOURCE).build()
|
|
148
|
+
_OPENCL_CACHE[key] = (ctx, command_queue, program)
|
|
149
|
+
return _OPENCL_CACHE[key]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _select_device(cl, *, prefer_gpu: bool):
|
|
153
|
+
fallback = None
|
|
154
|
+
for platform in cl.get_platforms():
|
|
155
|
+
devices = platform.get_devices()
|
|
156
|
+
for device in devices:
|
|
157
|
+
if fallback is None:
|
|
158
|
+
fallback = device
|
|
159
|
+
if prefer_gpu and device.type & cl.device_type.GPU:
|
|
160
|
+
return device
|
|
161
|
+
if fallback is not None and not prefer_gpu:
|
|
162
|
+
return fallback
|
|
163
|
+
if fallback is not None:
|
|
164
|
+
return fallback
|
|
165
|
+
raise RuntimeError("No OpenCL devices are available.")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .geometry import as_bool_mask, validate_same_shape, validate_spacing
|
|
6
|
+
from .metrics import compartment_metrics, masked_mean_sd
|
|
7
|
+
from .results import MicroarchitectureResult
|
|
8
|
+
from .thickness import (
|
|
9
|
+
default_thickness_backend,
|
|
10
|
+
hildebrand_thickness_map,
|
|
11
|
+
local_thickness_map,
|
|
12
|
+
summary,
|
|
13
|
+
trabecular_number_map,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_microarchitecture(
|
|
18
|
+
*,
|
|
19
|
+
periosteal_mask,
|
|
20
|
+
trabecular_mask,
|
|
21
|
+
spacing,
|
|
22
|
+
bone_mask=None,
|
|
23
|
+
cortical_mask=None,
|
|
24
|
+
grayscale=None,
|
|
25
|
+
thickness_method: str = "hildebrand",
|
|
26
|
+
thickness_backend: str = "auto",
|
|
27
|
+
) -> MicroarchitectureResult:
|
|
28
|
+
"""Compute HR-pQCT microarchitecture measurements from aligned arrays.
|
|
29
|
+
|
|
30
|
+
This function is intentionally image-I/O agnostic. Callers must load images,
|
|
31
|
+
calibrate grayscale data when BMD is desired, and resample all masks/images
|
|
32
|
+
to a shared voxel grid before calling it.
|
|
33
|
+
|
|
34
|
+
Masks are interpreted as:
|
|
35
|
+
``periosteal_mask``: full analysis compartment.
|
|
36
|
+
``trabecular_mask``: trabecular compartment.
|
|
37
|
+
``cortical_mask``: optional cortical compartment.
|
|
38
|
+
``bone_mask``: mineralized bone phase. If omitted, the trabecular mask is
|
|
39
|
+
treated as the bone phase for backward-compatible trabecular-only use.
|
|
40
|
+
|
|
41
|
+
Calculated trabecular outputs:
|
|
42
|
+
``Tb.BMD``: mean grayscale/BMD inside the trabecular compartment.
|
|
43
|
+
``Tb.BV/TV``: trabecular bone volume divided by trabecular total volume,
|
|
44
|
+
reported as a unitless fraction.
|
|
45
|
+
``Tb.Th``: mean local thickness of trabecular bone, in mm.
|
|
46
|
+
``Tb.Sp``: mean local thickness of non-bone space in the trabecular
|
|
47
|
+
compartment, in mm.
|
|
48
|
+
``Tb.N``: mean inverse ridge-to-ridge spacing estimate, in 1/mm.
|
|
49
|
+
``Tb.1/N.SD``: standard deviation of ridge-to-ridge spacing, in mm.
|
|
50
|
+
``Tb.BV`` and ``Tb.TV``: trabecular bone and compartment volumes, in mm^3.
|
|
51
|
+
|
|
52
|
+
Calculated cortical outputs when ``cortical_mask`` is provided:
|
|
53
|
+
``Ct.BMD``: mean grayscale/BMD inside the cortical compartment.
|
|
54
|
+
``Ct.Th``: mean local thickness of cortical bone, in mm.
|
|
55
|
+
``Ct.Po``: cortical pore volume divided by cortical total volume,
|
|
56
|
+
reported as a unitless fraction.
|
|
57
|
+
``Ct.Po.V``: cortical pore volume, in mm^3.
|
|
58
|
+
``Ct.Po.Dm``: mean local pore diameter, in mm.
|
|
59
|
+
``Ct.BV`` and ``Ct.TV``: cortical bone and compartment volumes, in mm^3.
|
|
60
|
+
|
|
61
|
+
Map-backed parameters include distribution statistics in
|
|
62
|
+
:func:`bone_microarchitecture.results.measurement_rows`.
|
|
63
|
+
"""
|
|
64
|
+
spacing = validate_spacing(spacing)
|
|
65
|
+
peri = as_bool_mask(periosteal_mask, "periosteal_mask")
|
|
66
|
+
trab = as_bool_mask(trabecular_mask, "trabecular_mask")
|
|
67
|
+
bone = as_bool_mask(bone_mask, "bone_mask") if bone_mask is not None else trab
|
|
68
|
+
cort = as_bool_mask(cortical_mask, "cortical_mask") if cortical_mask is not None else None
|
|
69
|
+
image = None if grayscale is None else np.asarray(grayscale, dtype=np.float32)
|
|
70
|
+
validate_same_shape(
|
|
71
|
+
{
|
|
72
|
+
"periosteal_mask": peri,
|
|
73
|
+
"trabecular_mask": trab,
|
|
74
|
+
"bone_mask": bone,
|
|
75
|
+
"cortical_mask": cort,
|
|
76
|
+
"grayscale": image,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
thickness_method = str(thickness_method or "hildebrand").strip().lower()
|
|
81
|
+
thickness_backend = str(thickness_backend or "auto").strip().lower()
|
|
82
|
+
resolved_thickness_backend = default_thickness_backend() if thickness_backend == "auto" else thickness_backend
|
|
83
|
+
trab_region = trab & peri
|
|
84
|
+
if cort is not None:
|
|
85
|
+
trab_region = trab_region & ~cort
|
|
86
|
+
cort_region = cort & peri
|
|
87
|
+
else:
|
|
88
|
+
cort_region = None
|
|
89
|
+
trab_bone = bone & trab_region
|
|
90
|
+
cort_bone = None if cort_region is None else bone & cort_region
|
|
91
|
+
tb_th_map = _thickness_map(
|
|
92
|
+
trab_bone,
|
|
93
|
+
spacing,
|
|
94
|
+
thickness_method=thickness_method,
|
|
95
|
+
thickness_backend=resolved_thickness_backend,
|
|
96
|
+
)
|
|
97
|
+
tb_sp_map = _thickness_map(
|
|
98
|
+
trab_region & ~trab_bone,
|
|
99
|
+
spacing,
|
|
100
|
+
thickness_method=thickness_method,
|
|
101
|
+
thickness_backend=resolved_thickness_backend,
|
|
102
|
+
)
|
|
103
|
+
tb_th = summary(tb_th_map[trab_bone])
|
|
104
|
+
tb_sp = summary(tb_sp_map[trab_region & ~trab_bone])
|
|
105
|
+
tb_n_map = trabecular_number_map(
|
|
106
|
+
trab_bone,
|
|
107
|
+
trab_region,
|
|
108
|
+
spacing,
|
|
109
|
+
backend=resolved_thickness_backend,
|
|
110
|
+
)
|
|
111
|
+
tb_n = summary(tb_n_map[trab_region])
|
|
112
|
+
tb_inverse_number_map = np.zeros(trab_region.shape, dtype=np.float32)
|
|
113
|
+
valid_tb_n = trab_region & np.isfinite(tb_n_map) & (tb_n_map > 0)
|
|
114
|
+
tb_inverse_number_map[valid_tb_n] = (1.0 / tb_n_map[valid_tb_n]).astype(np.float32, copy=False)
|
|
115
|
+
tb_inverse_number = summary(tb_inverse_number_map[valid_tb_n])
|
|
116
|
+
|
|
117
|
+
metrics = compartment_metrics(
|
|
118
|
+
bone_mask=bone,
|
|
119
|
+
periosteal_mask=peri,
|
|
120
|
+
trabecular_mask=trab,
|
|
121
|
+
cortical_mask=cort,
|
|
122
|
+
spacing=spacing,
|
|
123
|
+
mean_tb_th=tb_th["mean"],
|
|
124
|
+
)
|
|
125
|
+
metrics["Tb.N"] = tb_n["mean"]
|
|
126
|
+
metrics.update(
|
|
127
|
+
{
|
|
128
|
+
"Tb.Th": tb_th["mean"],
|
|
129
|
+
"Tb.Th SD": tb_th["sd"],
|
|
130
|
+
"Tb.Th Min": tb_th["min"],
|
|
131
|
+
"Tb.Th Max": tb_th["max"],
|
|
132
|
+
"Tb.Sp": tb_sp["mean"],
|
|
133
|
+
"Tb.Sp SD": tb_sp["sd"],
|
|
134
|
+
"Tb.Sp Min": tb_sp["min"],
|
|
135
|
+
"Tb.Sp Max": tb_sp["max"],
|
|
136
|
+
"Tb.N Median": tb_n["median"],
|
|
137
|
+
"Tb.N SD": tb_n["sd"],
|
|
138
|
+
"Tb.N P5": tb_n["p5"],
|
|
139
|
+
"Tb.N P25": tb_n["p25"],
|
|
140
|
+
"Tb.N P75": tb_n["p75"],
|
|
141
|
+
"Tb.N P95": tb_n["p95"],
|
|
142
|
+
"Tb.N Min": tb_n["min"],
|
|
143
|
+
"Tb.N Max": tb_n["max"],
|
|
144
|
+
"Tb.1/N.SD": tb_inverse_number["sd"],
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
maps = {"Tb.Th": tb_th_map, "Tb.Sp": tb_sp_map, "Tb.N": tb_n_map}
|
|
149
|
+
|
|
150
|
+
if cort_bone is not None:
|
|
151
|
+
ct_th_map = _thickness_map(
|
|
152
|
+
cort_bone,
|
|
153
|
+
spacing,
|
|
154
|
+
thickness_method=thickness_method,
|
|
155
|
+
thickness_backend=resolved_thickness_backend,
|
|
156
|
+
)
|
|
157
|
+
ct_th = summary(ct_th_map[cort_bone])
|
|
158
|
+
metrics.update(
|
|
159
|
+
{
|
|
160
|
+
"Ct.Th": ct_th["mean"],
|
|
161
|
+
"Ct.Th SD": ct_th["sd"],
|
|
162
|
+
"Ct.Th Min": ct_th["min"],
|
|
163
|
+
"Ct.Th Max": ct_th["max"],
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
maps["Ct.Th"] = ct_th_map
|
|
167
|
+
pore_map = _thickness_map(
|
|
168
|
+
cort_region & ~cort_bone,
|
|
169
|
+
spacing,
|
|
170
|
+
thickness_method=thickness_method,
|
|
171
|
+
thickness_backend=resolved_thickness_backend,
|
|
172
|
+
)
|
|
173
|
+
pore_summary = summary(pore_map[cort_region & ~cort_bone])
|
|
174
|
+
metrics.update(
|
|
175
|
+
{
|
|
176
|
+
"Ct.Po.Dm": pore_summary["mean"],
|
|
177
|
+
"Ct.Po.Dm SD": pore_summary["sd"],
|
|
178
|
+
"Ct.Po.Dm Min": pore_summary["min"],
|
|
179
|
+
"Ct.Po.Dm Max": pore_summary["max"],
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
maps["Ct.Po.Dm"] = pore_map
|
|
183
|
+
|
|
184
|
+
if image is not None:
|
|
185
|
+
tb_mean, tb_sd = masked_mean_sd(image, trab_region)
|
|
186
|
+
metrics["Tb.BMD"] = tb_mean
|
|
187
|
+
metrics["Tb.BMD SD"] = tb_sd
|
|
188
|
+
maps["Tb.BMD"] = np.where(trab_region, image, 0).astype(np.float32)
|
|
189
|
+
if cort_region is not None:
|
|
190
|
+
ct_mean, ct_sd = masked_mean_sd(image, cort_region)
|
|
191
|
+
metrics["Ct.BMD"] = ct_mean
|
|
192
|
+
metrics["Ct.BMD SD"] = ct_sd
|
|
193
|
+
maps["Ct.BMD"] = np.where(cort_region, image, 0).astype(np.float32)
|
|
194
|
+
|
|
195
|
+
return MicroarchitectureResult(
|
|
196
|
+
measurements=metrics,
|
|
197
|
+
maps=maps,
|
|
198
|
+
metadata={
|
|
199
|
+
"thickness_method": thickness_method,
|
|
200
|
+
"thickness_backend": resolved_thickness_backend,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _thickness_map(mask, spacing, *, thickness_method: str, thickness_backend: str):
|
|
206
|
+
"""Dispatch one binary mask to the requested thickness-map implementation."""
|
|
207
|
+
if thickness_method in {"edt", "distance", "distance_transform"}:
|
|
208
|
+
return local_thickness_map(mask, spacing)
|
|
209
|
+
if thickness_method in {"hildebrand", "sphere_fitting", "sphere-fitting", "exact"}:
|
|
210
|
+
return hildebrand_thickness_map(mask, spacing, backend=thickness_backend)
|
|
211
|
+
raise ValueError("Thickness method must be one of: edt or hildebrand.")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import csv
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MEASUREMENT_ORDER = (
|
|
11
|
+
"Tb.BMD",
|
|
12
|
+
"Tb.BV/TV",
|
|
13
|
+
"Tb.Th",
|
|
14
|
+
"Tb.Sp",
|
|
15
|
+
"Tb.N",
|
|
16
|
+
"Tb.1/N.SD",
|
|
17
|
+
"Tb.BV",
|
|
18
|
+
"Tb.TV",
|
|
19
|
+
"Ct.BMD",
|
|
20
|
+
"Ct.Th",
|
|
21
|
+
"Ct.Po",
|
|
22
|
+
"Ct.Po.V",
|
|
23
|
+
"Ct.Po.Dm",
|
|
24
|
+
"Ct.BV",
|
|
25
|
+
"Ct.TV",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
SUMMARY_COLUMNS = (
|
|
29
|
+
"Parameter",
|
|
30
|
+
"Mean",
|
|
31
|
+
"Median",
|
|
32
|
+
"SD",
|
|
33
|
+
"P5",
|
|
34
|
+
"P25",
|
|
35
|
+
"P75",
|
|
36
|
+
"P95",
|
|
37
|
+
"Min",
|
|
38
|
+
"Max",
|
|
39
|
+
"Units",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_DISTRIBUTION_STAT_KEYS = {
|
|
43
|
+
"Tb.Th": ("Tb.Th SD", "Tb.Th Min", "Tb.Th Max"),
|
|
44
|
+
"Tb.Sp": ("Tb.Sp SD", "Tb.Sp Min", "Tb.Sp Max"),
|
|
45
|
+
"Tb.N": (
|
|
46
|
+
"Tb.N Median",
|
|
47
|
+
"Tb.N SD",
|
|
48
|
+
"Tb.N P5",
|
|
49
|
+
"Tb.N P25",
|
|
50
|
+
"Tb.N P75",
|
|
51
|
+
"Tb.N P95",
|
|
52
|
+
"Tb.N Min",
|
|
53
|
+
"Tb.N Max",
|
|
54
|
+
),
|
|
55
|
+
"Ct.Th": ("Ct.Th SD", "Ct.Th Min", "Ct.Th Max"),
|
|
56
|
+
"Ct.Po.Dm": ("Ct.Po.Dm SD", "Ct.Po.Dm Min", "Ct.Po.Dm Max"),
|
|
57
|
+
"Tb.BMD": ("Tb.BMD SD",),
|
|
58
|
+
"Ct.BMD": ("Ct.BMD SD",),
|
|
59
|
+
}
|
|
60
|
+
_SECONDARY_MEASUREMENTS = {name for names in _DISTRIBUTION_STAT_KEYS.values() for name in names}
|
|
61
|
+
|
|
62
|
+
PARAMETER_DEFINITIONS = {
|
|
63
|
+
"Tb.BMD": "Mean grayscale/BMD value inside the trabecular compartment.",
|
|
64
|
+
"Tb.BV/TV": "Trabecular bone volume divided by trabecular total volume, reported as a fraction.",
|
|
65
|
+
"Tb.Th": "Mean maximal-sphere local thickness of trabecular bone.",
|
|
66
|
+
"Tb.Sp": "Mean maximal-sphere local thickness of non-bone space in the trabecular compartment.",
|
|
67
|
+
"Tb.N": "Mean inverse ridge-to-ridge spacing estimate in the trabecular compartment.",
|
|
68
|
+
"Tb.1/N.SD": "Standard deviation of ridge-to-ridge spacing used to estimate trabecular number.",
|
|
69
|
+
"Tb.BV": "Trabecular bone volume.",
|
|
70
|
+
"Tb.TV": "Trabecular compartment volume.",
|
|
71
|
+
"Ct.BMD": "Mean grayscale/BMD value inside the cortical compartment.",
|
|
72
|
+
"Ct.Th": "Mean maximal-sphere local thickness of cortical bone.",
|
|
73
|
+
"Ct.Po": "Cortical pore volume divided by cortical total volume, reported as a fraction.",
|
|
74
|
+
"Ct.Po.V": "Cortical pore volume.",
|
|
75
|
+
"Ct.Po.Dm": "Mean maximal-sphere local diameter of cortical pore space.",
|
|
76
|
+
"Ct.BV": "Cortical bone volume.",
|
|
77
|
+
"Ct.TV": "Cortical compartment volume.",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class MicroarchitectureResult:
|
|
83
|
+
"""Container returned by :func:`compute_microarchitecture`.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
measurements: Scalar parameter values and secondary distribution values.
|
|
87
|
+
maps: Parameter maps keyed by names such as ``"Tb.Th"`` and
|
|
88
|
+
``"Ct.Po.Dm"``. Non-map scalar parameters are absent.
|
|
89
|
+
metadata: Calculation metadata, for example selected thickness backend.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
measurements: dict[str, float]
|
|
93
|
+
maps: dict[str, np.ndarray] = field(default_factory=dict)
|
|
94
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def units_for_measurement(name: str) -> str:
|
|
98
|
+
"""Return display units for a measurement name."""
|
|
99
|
+
if name.endswith("BV/TV") or name.endswith(".Po"):
|
|
100
|
+
return "fraction"
|
|
101
|
+
if name.endswith(".BMD") or name.endswith("BMD SD"):
|
|
102
|
+
return "mgHA/cm^3"
|
|
103
|
+
if name.endswith(".BV") or name.endswith(".TV") or name.endswith(".V"):
|
|
104
|
+
return "mm^3"
|
|
105
|
+
if name == "Tb.N":
|
|
106
|
+
return "1/mm"
|
|
107
|
+
if ".Th" in name or ".Sp" in name or name.endswith(".Dm") or name == "Tb.1/N.SD":
|
|
108
|
+
return "mm"
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def measurement_rows(metrics: dict[str, float], maps: dict[str, np.ndarray] | None = None) -> list[dict[str, object]]:
|
|
113
|
+
"""Format measurements as table rows for Slicer display or CSV export.
|
|
114
|
+
|
|
115
|
+
Scalar parameters place their value in the ``Mean`` column. Parameters with
|
|
116
|
+
an available map are summarized across positive finite map voxels and include
|
|
117
|
+
median, standard deviation, percentiles, and range.
|
|
118
|
+
"""
|
|
119
|
+
ordered = [name for name in MEASUREMENT_ORDER if name in metrics]
|
|
120
|
+
ordered.extend(sorted(name for name in metrics if name not in set(ordered)))
|
|
121
|
+
return [
|
|
122
|
+
_measurement_row(name, metrics, maps or {})
|
|
123
|
+
for name in ordered
|
|
124
|
+
if name not in _SECONDARY_MEASUREMENTS
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_measurement_csv(path, metrics: dict[str, float], maps: dict[str, np.ndarray] | None = None) -> None:
|
|
129
|
+
"""Write formatted measurement rows to a CSV file."""
|
|
130
|
+
rows = measurement_rows(metrics, maps)
|
|
131
|
+
with Path(path).open("w", newline="", encoding="utf-8") as handle:
|
|
132
|
+
writer = csv.DictWriter(handle, fieldnames=list(SUMMARY_COLUMNS))
|
|
133
|
+
writer.writeheader()
|
|
134
|
+
writer.writerows(rows)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _measurement_row(name: str, metrics: dict[str, float], maps: dict[str, np.ndarray]) -> dict[str, object]:
|
|
138
|
+
row = {column: "" for column in SUMMARY_COLUMNS}
|
|
139
|
+
row["Parameter"] = name
|
|
140
|
+
row["Mean"] = float(metrics[name])
|
|
141
|
+
row["Units"] = units_for_measurement(name)
|
|
142
|
+
if name in maps:
|
|
143
|
+
values = _map_values(maps[name])
|
|
144
|
+
if values.size:
|
|
145
|
+
row.update(
|
|
146
|
+
{
|
|
147
|
+
"Mean": float(values.mean()),
|
|
148
|
+
"Median": float(np.median(values)),
|
|
149
|
+
"SD": float(values.std(ddof=0)),
|
|
150
|
+
"P5": float(np.percentile(values, 5)),
|
|
151
|
+
"P25": float(np.percentile(values, 25)),
|
|
152
|
+
"P75": float(np.percentile(values, 75)),
|
|
153
|
+
"P95": float(np.percentile(values, 95)),
|
|
154
|
+
"Min": float(values.min()),
|
|
155
|
+
"Max": float(values.max()),
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return row
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _map_values(array: np.ndarray) -> np.ndarray:
|
|
162
|
+
values = np.asarray(array, dtype=float)
|
|
163
|
+
values = values[np.isfinite(values) & (values != 0)]
|
|
164
|
+
return values
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import ndimage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_NEIGHBOR_OFFSETS = tuple(
|
|
10
|
+
(dz, dy, dx)
|
|
11
|
+
for dz in (-1, 0, 1)
|
|
12
|
+
for dy in (-1, 0, 1)
|
|
13
|
+
for dx in (-1, 0, 1)
|
|
14
|
+
if (dz, dy, dx) != (0, 0, 0)
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def local_thickness_map(mask, spacing: tuple[float, float, float]) -> np.ndarray:
|
|
19
|
+
"""Return a bounded distance-transform thickness preview.
|
|
20
|
+
|
|
21
|
+
This fast map assigns each foreground voxel twice its distance to the
|
|
22
|
+
background. It is useful as a lightweight preview or fallback, but it is not
|
|
23
|
+
the maximal-sphere local thickness used for primary reporting.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
mask: 3D binary object mask.
|
|
27
|
+
spacing: Voxel spacing in millimetres, ordered like the array axes.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Float32 thickness map in millimetres, zero outside ``mask``.
|
|
31
|
+
"""
|
|
32
|
+
binary = np.asarray(mask) > 0
|
|
33
|
+
if not binary.any():
|
|
34
|
+
return np.zeros(binary.shape, dtype=np.float32)
|
|
35
|
+
distance = ndimage.distance_transform_edt(binary, sampling=spacing)
|
|
36
|
+
distance *= 2.0
|
|
37
|
+
return distance.astype(np.float32, copy=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def hildebrand_thickness_map(
|
|
41
|
+
mask,
|
|
42
|
+
spacing: tuple[float, float, float],
|
|
43
|
+
*,
|
|
44
|
+
backend: str = "auto",
|
|
45
|
+
center_dominance_mm: float | None = None,
|
|
46
|
+
diameter_margin_voxels: float = 0.5,
|
|
47
|
+
) -> np.ndarray:
|
|
48
|
+
"""Return a Hildebrand-style maximal-sphere local thickness map.
|
|
49
|
+
|
|
50
|
+
This follows the local-thickness concept used in ORMiR-XCT: compute the
|
|
51
|
+
Euclidean distance transform, keep non-dominated sphere centers, and assign
|
|
52
|
+
each object voxel the largest sphere diameter that contains it. The
|
|
53
|
+
implementation here is independent and keeps the candidate-selection rule
|
|
54
|
+
deliberately small: a center is skipped only when a neighboring center has a
|
|
55
|
+
clearly larger inscribed radius.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
mask: 3D binary object mask.
|
|
59
|
+
spacing: Voxel spacing in millimetres, ordered like the array axes.
|
|
60
|
+
backend: Diameter-accumulation backend: ``"auto"``, ``"cpu"``, ``"mps"``, or
|
|
61
|
+
``"opencl"``.
|
|
62
|
+
center_dominance_mm: Minimum radius advantage, in millimetres, required for a
|
|
63
|
+
neighboring center to dominate the current center. Defaults to
|
|
64
|
+
``0.9 * min(spacing)``.
|
|
65
|
+
diameter_margin_voxels: Small voxel-scaled subtraction from sphere radius
|
|
66
|
+
before reporting diameter. This keeps assignment conservative at the
|
|
67
|
+
discretized boundary.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Float32 local-thickness map in millimetres, zero outside ``mask``.
|
|
71
|
+
"""
|
|
72
|
+
binary = np.asarray(mask) > 0
|
|
73
|
+
if not binary.any():
|
|
74
|
+
return np.zeros(binary.shape, dtype=np.float32)
|
|
75
|
+
spacing = tuple(float(value) for value in spacing)
|
|
76
|
+
distance = ndimage.distance_transform_edt(binary, sampling=spacing)
|
|
77
|
+
dominance_margin = float(center_dominance_mm if center_dominance_mm is not None else min(spacing) * 0.9)
|
|
78
|
+
medial_axis = _medial_axis(distance, dominance_margin)
|
|
79
|
+
backend = str(backend or "auto").strip().lower()
|
|
80
|
+
if backend == "auto":
|
|
81
|
+
backend = default_thickness_backend()
|
|
82
|
+
if backend == "mps":
|
|
83
|
+
return _mask_output(_accumulate_local_diameters_mps(distance, medial_axis, spacing, diameter_margin_voxels), binary)
|
|
84
|
+
if backend == "opencl":
|
|
85
|
+
return _mask_output(_accumulate_local_diameters_opencl(distance, medial_axis, spacing, diameter_margin_voxels), binary)
|
|
86
|
+
if backend == "cpu":
|
|
87
|
+
return _mask_output(_accumulate_local_diameters_cpu(distance, medial_axis, spacing, diameter_margin_voxels), binary)
|
|
88
|
+
raise ValueError("Thickness backend must be one of: auto, cpu, mps, or opencl.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_thickness_backend() -> str:
|
|
92
|
+
"""Choose the fastest available diameter-accumulation backend for this machine."""
|
|
93
|
+
if sys.platform == "darwin":
|
|
94
|
+
try:
|
|
95
|
+
from .metal import is_metal_available
|
|
96
|
+
|
|
97
|
+
if is_metal_available():
|
|
98
|
+
return "mps"
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
else:
|
|
102
|
+
try:
|
|
103
|
+
from .opencl import is_opencl_available
|
|
104
|
+
|
|
105
|
+
if is_opencl_available():
|
|
106
|
+
return "opencl"
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return "cpu"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def separation_map(periosteal_mask, trabecular_mask, spacing: tuple[float, float, float]) -> np.ndarray:
|
|
113
|
+
"""Return a marrow-space thickness map inside the periosteal compartment.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
periosteal_mask: Full periosteal compartment mask.
|
|
117
|
+
trabecular_mask: Trabecular bone mask.
|
|
118
|
+
spacing: Voxel spacing in millimetres, ordered like the array axes.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Fast distance-transform separation map in millimetres.
|
|
122
|
+
"""
|
|
123
|
+
peri = np.asarray(periosteal_mask) > 0
|
|
124
|
+
trab = np.asarray(trabecular_mask) > 0
|
|
125
|
+
marrow = peri & ~trab
|
|
126
|
+
return local_thickness_map(marrow, spacing)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def trabecular_number_map(
|
|
130
|
+
bone_mask,
|
|
131
|
+
domain_mask,
|
|
132
|
+
spacing: tuple[float, float, float],
|
|
133
|
+
*,
|
|
134
|
+
backend: str = "auto",
|
|
135
|
+
material_center_dominance_voxels: float = 0.5,
|
|
136
|
+
spacing_center_dominance_voxels: float = 0.9,
|
|
137
|
+
diameter_margin_voxels: float = 0.5,
|
|
138
|
+
) -> np.ndarray:
|
|
139
|
+
"""Estimate local trabecular number from ridge-to-ridge spacing.
|
|
140
|
+
|
|
141
|
+
The map is built in two stages. First, trabecular bone ridges are extracted
|
|
142
|
+
from the bone distance transform. Second, a local spacing field is estimated
|
|
143
|
+
in the trabecular domain after removing those ridges. Local trabecular number
|
|
144
|
+
is the inverse of that spacing field.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
bone_mask: Trabecular bone phase mask.
|
|
148
|
+
domain_mask: Trabecular compartment mask.
|
|
149
|
+
spacing: Voxel spacing in millimetres, ordered like the array axes.
|
|
150
|
+
backend: Diameter-accumulation backend used for the local spacing field.
|
|
151
|
+
material_center_dominance_voxels: Voxel-scaled dominance threshold for extracting
|
|
152
|
+
trabecular material ridges.
|
|
153
|
+
spacing_center_dominance_voxels: Voxel-scaled dominance threshold for
|
|
154
|
+
extracting spacing-field centers.
|
|
155
|
+
diameter_margin_voxels: Voxel-scaled conservative radius adjustment for
|
|
156
|
+
sphere assignment.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Float32 trabecular number map in 1/mm, zero outside valid domain voxels.
|
|
160
|
+
"""
|
|
161
|
+
bone = np.asarray(bone_mask) > 0
|
|
162
|
+
domain = np.asarray(domain_mask) > 0
|
|
163
|
+
if not bone.any() or not domain.any():
|
|
164
|
+
return np.zeros(domain.shape, dtype=np.float32)
|
|
165
|
+
spacing = tuple(float(value) for value in spacing)
|
|
166
|
+
bone = bone & domain
|
|
167
|
+
bone_distance = ndimage.distance_transform_edt(bone, sampling=spacing)
|
|
168
|
+
material_ridge = _medial_axis(bone_distance, float(material_center_dominance_voxels) * min(spacing))
|
|
169
|
+
inter_axis = domain & ~material_ridge
|
|
170
|
+
spacing_map = hildebrand_thickness_map(
|
|
171
|
+
inter_axis,
|
|
172
|
+
spacing,
|
|
173
|
+
backend=backend,
|
|
174
|
+
center_dominance_mm=float(spacing_center_dominance_voxels) * min(spacing),
|
|
175
|
+
diameter_margin_voxels=diameter_margin_voxels,
|
|
176
|
+
)
|
|
177
|
+
number = np.zeros(domain.shape, dtype=np.float32)
|
|
178
|
+
valid = domain & np.isfinite(spacing_map) & (spacing_map > 0)
|
|
179
|
+
number[valid] = (1.0 / spacing_map[valid]).astype(np.float32, copy=False)
|
|
180
|
+
return number
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def summary(values) -> dict[str, float]:
|
|
184
|
+
"""Summarize positive finite values with mean, percentiles, and range."""
|
|
185
|
+
array = np.asarray(values, dtype=float)
|
|
186
|
+
array = array[np.isfinite(array) & (array > 0)]
|
|
187
|
+
if array.size == 0:
|
|
188
|
+
return {
|
|
189
|
+
"mean": 0.0,
|
|
190
|
+
"median": 0.0,
|
|
191
|
+
"sd": 0.0,
|
|
192
|
+
"p5": 0.0,
|
|
193
|
+
"p25": 0.0,
|
|
194
|
+
"p75": 0.0,
|
|
195
|
+
"p95": 0.0,
|
|
196
|
+
"min": 0.0,
|
|
197
|
+
"max": 0.0,
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
"mean": float(array.mean()),
|
|
201
|
+
"median": float(np.median(array)),
|
|
202
|
+
"sd": float(array.std(ddof=0)),
|
|
203
|
+
"p5": float(np.percentile(array, 5)),
|
|
204
|
+
"p25": float(np.percentile(array, 25)),
|
|
205
|
+
"p75": float(np.percentile(array, 75)),
|
|
206
|
+
"p95": float(np.percentile(array, 95)),
|
|
207
|
+
"min": float(array.min()),
|
|
208
|
+
"max": float(array.max()),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _medial_axis(distance: np.ndarray, dominance_margin: float) -> np.ndarray:
|
|
213
|
+
padded = np.pad(distance, 1, mode="constant", constant_values=0)
|
|
214
|
+
center = padded[1:-1, 1:-1, 1:-1]
|
|
215
|
+
inside = center > 0
|
|
216
|
+
dominated = _dominated_center_mask(padded, center, dominance_margin)
|
|
217
|
+
return inside & ~dominated
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _dominated_center_mask(padded: np.ndarray, center: np.ndarray, dominance_margin: float) -> np.ndarray:
|
|
221
|
+
dominated = np.zeros(center.shape, dtype=bool)
|
|
222
|
+
for dz, dy, dx in _NEIGHBOR_OFFSETS:
|
|
223
|
+
neighbor = padded[
|
|
224
|
+
1 + dz : 1 + dz + center.shape[0],
|
|
225
|
+
1 + dy : 1 + dy + center.shape[1],
|
|
226
|
+
1 + dx : 1 + dx + center.shape[2],
|
|
227
|
+
]
|
|
228
|
+
dominated |= (center + dominance_margin) <= neighbor
|
|
229
|
+
return dominated
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _mask_output(thickness: np.ndarray, binary: np.ndarray) -> np.ndarray:
|
|
233
|
+
thickness = np.asarray(thickness, dtype=np.float32)
|
|
234
|
+
thickness[~binary] = 0
|
|
235
|
+
return thickness
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _sphere_centers(distance: np.ndarray, medial_axis: np.ndarray):
|
|
239
|
+
z, y, x = np.nonzero(medial_axis)
|
|
240
|
+
radius = distance[medial_axis].astype(np.float32, copy=False)
|
|
241
|
+
if radius.size == 0:
|
|
242
|
+
return z, y, x, radius
|
|
243
|
+
order = np.argsort(radius)[::-1]
|
|
244
|
+
return z[order], y[order], x[order], radius[order]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _accumulate_local_diameters_cpu(
|
|
248
|
+
distance: np.ndarray,
|
|
249
|
+
medial_axis: np.ndarray,
|
|
250
|
+
spacing: tuple[float, float, float],
|
|
251
|
+
diameter_margin_voxels: float,
|
|
252
|
+
) -> np.ndarray:
|
|
253
|
+
shape = distance.shape
|
|
254
|
+
thickness = np.zeros(shape, dtype=np.float32)
|
|
255
|
+
seed_z, seed_y, seed_x, seed_radius = _sphere_centers(distance, medial_axis)
|
|
256
|
+
diameter_margin = float(diameter_margin_voxels) * min(spacing)
|
|
257
|
+
inclusion_tolerance = _sphere_inclusion_tolerance(spacing)
|
|
258
|
+
for zc, yc, xc, radius in zip(seed_z, seed_y, seed_x, seed_radius):
|
|
259
|
+
value = np.float32(max(2.0 * (float(radius) - diameter_margin), 0.0))
|
|
260
|
+
if value <= 0:
|
|
261
|
+
continue
|
|
262
|
+
z0, z1, y0, y1, x0, x1 = _sphere_bounds(shape, int(zc), int(yc), int(xc), float(radius), spacing)
|
|
263
|
+
zz, yy, xx = _physical_offsets(z0, z1, y0, y1, x0, x1, int(zc), int(yc), int(xc), spacing)
|
|
264
|
+
in_sphere = (zz * zz + yy * yy + xx * xx) <= (float(radius) * float(radius) + inclusion_tolerance)
|
|
265
|
+
sub = thickness[z0:z1, y0:y1, x0:x1]
|
|
266
|
+
np.maximum(sub, np.where(in_sphere, value, 0).astype(np.float32, copy=False), out=sub)
|
|
267
|
+
return thickness
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _accumulate_local_diameters_mps(
|
|
271
|
+
distance: np.ndarray,
|
|
272
|
+
medial_axis: np.ndarray,
|
|
273
|
+
spacing: tuple[float, float, float],
|
|
274
|
+
diameter_margin_voxels: float,
|
|
275
|
+
) -> np.ndarray:
|
|
276
|
+
from .metal import metal_hildebrand_thickness_map
|
|
277
|
+
|
|
278
|
+
seed_z, seed_y, seed_x, seed_radius = _sphere_centers(distance, medial_axis)
|
|
279
|
+
return metal_hildebrand_thickness_map(
|
|
280
|
+
shape=distance.shape,
|
|
281
|
+
seed_z=seed_z,
|
|
282
|
+
seed_y=seed_y,
|
|
283
|
+
seed_x=seed_x,
|
|
284
|
+
seed_radius=seed_radius,
|
|
285
|
+
spacing=spacing,
|
|
286
|
+
diameter_margin=float(diameter_margin_voxels) * min(spacing),
|
|
287
|
+
inclusion_tolerance=_sphere_inclusion_tolerance(spacing),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _accumulate_local_diameters_opencl(
|
|
292
|
+
distance: np.ndarray,
|
|
293
|
+
medial_axis: np.ndarray,
|
|
294
|
+
spacing: tuple[float, float, float],
|
|
295
|
+
diameter_margin_voxels: float,
|
|
296
|
+
) -> np.ndarray:
|
|
297
|
+
from .opencl import opencl_hildebrand_thickness_map
|
|
298
|
+
|
|
299
|
+
seed_z, seed_y, seed_x, seed_radius = _sphere_centers(distance, medial_axis)
|
|
300
|
+
return opencl_hildebrand_thickness_map(
|
|
301
|
+
shape=distance.shape,
|
|
302
|
+
seed_z=seed_z,
|
|
303
|
+
seed_y=seed_y,
|
|
304
|
+
seed_x=seed_x,
|
|
305
|
+
seed_radius=seed_radius,
|
|
306
|
+
spacing=spacing,
|
|
307
|
+
diameter_margin=float(diameter_margin_voxels) * min(spacing),
|
|
308
|
+
inclusion_tolerance=_sphere_inclusion_tolerance(spacing),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _sphere_bounds(shape, zc: int, yc: int, xc: int, radius: float, spacing: tuple[float, float, float]):
|
|
313
|
+
z_extent, y_extent, x_extent = (int(np.ceil(radius / value)) for value in spacing)
|
|
314
|
+
z0 = max(0, zc - z_extent)
|
|
315
|
+
z1 = min(shape[0], zc + z_extent + 1)
|
|
316
|
+
y0 = max(0, yc - y_extent)
|
|
317
|
+
y1 = min(shape[1], yc + y_extent + 1)
|
|
318
|
+
x0 = max(0, xc - x_extent)
|
|
319
|
+
x1 = min(shape[2], xc + x_extent + 1)
|
|
320
|
+
return z0, z1, y0, y1, x0, x1
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _physical_offsets(
|
|
324
|
+
z0: int,
|
|
325
|
+
z1: int,
|
|
326
|
+
y0: int,
|
|
327
|
+
y1: int,
|
|
328
|
+
x0: int,
|
|
329
|
+
x1: int,
|
|
330
|
+
zc: int,
|
|
331
|
+
yc: int,
|
|
332
|
+
xc: int,
|
|
333
|
+
spacing: tuple[float, float, float],
|
|
334
|
+
):
|
|
335
|
+
z_offsets = (np.arange(z0, z1, dtype=np.float32) - zc) * spacing[0]
|
|
336
|
+
y_offsets = (np.arange(y0, y1, dtype=np.float32) - yc) * spacing[1]
|
|
337
|
+
x_offsets = (np.arange(x0, x1, dtype=np.float32) - xc) * spacing[2]
|
|
338
|
+
return np.meshgrid(z_offsets, y_offsets, x_offsets, indexing="ij")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _sphere_inclusion_tolerance(spacing: tuple[float, float, float]) -> float:
|
|
342
|
+
spacing_min = min(float(value) for value in spacing)
|
|
343
|
+
return max(spacing_min * spacing_min, 1.0) * 1.0e-6
|
|
@@ -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,13 @@
|
|
|
1
|
+
bone_microarchitecture/__init__.py,sha256=zbUtxLJjBVFBXVyFSf4YEjBde9v7bPRHFOmRKq6Tvv4,880
|
|
2
|
+
bone_microarchitecture/geometry.py,sha256=qVqmJTiPDh4z-MTZyrUsFqnR1soOytbubxXpzjDNuHc,1454
|
|
3
|
+
bone_microarchitecture/metal.py,sha256=8d18vickLGhFVLJ6XwTeYxCA7kK-OXJZHDpNq9HgkF8,6410
|
|
4
|
+
bone_microarchitecture/metrics.py,sha256=JM_lHlRdw-a8QPcvgCSPTZWQTW6W01nkSr2gQJ2fbos,3023
|
|
5
|
+
bone_microarchitecture/opencl.py,sha256=kNEQf5Ol_53nw7oLQmVjIBOE5blDLfNAB3mzNkEV0w8,6054
|
|
6
|
+
bone_microarchitecture/pipeline.py,sha256=l24PvYiuTxQS9YcNmqopp_7t4zy3G08c5YXtKgji6jo,8101
|
|
7
|
+
bone_microarchitecture/results.py,sha256=SgBFlViVeTlvktk13uB3gkoS60Inhw2F9Cvv2SssEVw,5605
|
|
8
|
+
bone_microarchitecture/thickness.py,sha256=8EKHjGV5HcDm5q-kCKrqg59zHH-Id-Jzxm6363gH7m8,12858
|
|
9
|
+
bone_microarchitecture-0.1.0.dist-info/licenses/LICENSE,sha256=s4uvriA89nP0_DvvrvWoI7dplf8AtGVGOFp9yDO74IE,1071
|
|
10
|
+
bone_microarchitecture-0.1.0.dist-info/METADATA,sha256=6amWY1OImOmx3UuOj--0Ih9wVdE4Cd842FlEb5WE3Ss,3462
|
|
11
|
+
bone_microarchitecture-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
bone_microarchitecture-0.1.0.dist-info/top_level.txt,sha256=cDyY3x-M0kiZV7vMB5tnj_YrAu3hRYPVT76a7yty0Dg,23
|
|
13
|
+
bone_microarchitecture-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthias Walle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bone_microarchitecture
|