bone-contouring 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
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.
22
+
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: bone-contouring
3
+ Version: 0.1.0
4
+ Summary: SimpleITK-first bone contour and mask generation for volumetric bone images
5
+ Author: Matthias Walle
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy<3.0,>=1.26
11
+ Requires-Dist: SimpleITK>=2.3
12
+ Provides-Extra: geodesic
13
+ Requires-Dist: hrpqct-geodesic-contour>=0.1.2; extra == "geodesic"
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8; extra == "test"
16
+ Dynamic: license-file
17
+
18
+ # bone-contouring
19
+
20
+ SimpleITK-first bone contouring and mask generation for volumetric bone images.
21
+
22
+ ```python
23
+ from bone_contouring import generate_masks_from_image, resolve_preset
24
+
25
+ masks = generate_masks_from_image(image, resolve_preset(modality="xct1", site="radius"))
26
+ ```
@@ -0,0 +1,9 @@
1
+ # bone-contouring
2
+
3
+ SimpleITK-first bone contouring and mask generation for volumetric bone images.
4
+
5
+ ```python
6
+ from bone_contouring import generate_masks_from_image, resolve_preset
7
+
8
+ masks = generate_masks_from_image(image, resolve_preset(modality="xct1", site="radius"))
9
+ ```
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bone-contouring"
7
+ version = "0.1.0"
8
+ description = "SimpleITK-first bone contour and mask generation for volumetric bone images"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Matthias Walle" }]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ dependencies = [
15
+ "numpy>=1.26,<3.0",
16
+ "SimpleITK>=2.3",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ geodesic = ["hrpqct-geodesic-contour>=0.1.2"]
21
+ test = ["pytest>=8"]
22
+
23
+ [tool.setuptools]
24
+ package-dir = { "" = "src" }
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ """SimpleITK-first contour and bone-mask generation."""
2
+
3
+ from .parameters import (
4
+ ContourParameters,
5
+ InnerContourParameters,
6
+ OuterContourParameters,
7
+ SegmentationParameters,
8
+ )
9
+ from .presets import load_preset, resolve_preset
10
+ from .api import GeneratedMasks, generate_bone_segmentation, generate_masks_from_image
11
+
12
+ __all__ = [
13
+ "ContourParameters",
14
+ "GeneratedMasks",
15
+ "InnerContourParameters",
16
+ "OuterContourParameters",
17
+ "SegmentationParameters",
18
+ "generate_bone_segmentation",
19
+ "generate_masks_from_image",
20
+ "load_preset",
21
+ "resolve_preset",
22
+ ]
@@ -0,0 +1,303 @@
1
+ """Internal x/y/z array algorithms and SimpleITK conversion helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import SimpleITK as sitk
7
+
8
+ from .laplace_hamming import LaplaceHammingParameters, laplace_hamming_binarize_xyz
9
+ from .parameters import InnerContourParameters, OuterContourParameters, SegmentationParameters
10
+
11
+
12
+ def sitk_to_numpy_xyz(image: sitk.Image) -> np.ndarray:
13
+ """Convert a SimpleITK image from z/y/x storage to contiguous x/y/z data."""
14
+ return np.ascontiguousarray(np.transpose(sitk.GetArrayFromImage(image), (2, 1, 0)))
15
+
16
+
17
+ def numpy_xyz_to_sitk_binary(mask_xyz: np.ndarray, reference: sitk.Image) -> sitk.Image:
18
+ """Create a uint8 binary image with geometry copied from ``reference``."""
19
+ image = sitk.GetImageFromArray(np.transpose(np.asarray(mask_xyz, dtype=np.uint8), (2, 1, 0)))
20
+ image.CopyInformation(reference)
21
+ return sitk.Cast(image > 0, sitk.sitkUInt8)
22
+
23
+
24
+ def numpy_xyz_to_sitk_scalar(
25
+ image_xyz: np.ndarray, spacing_xyz: tuple[float, float, float] | None = None
26
+ ) -> sitk.Image:
27
+ """Create a float32 image from x/y/z scalar data."""
28
+ image = sitk.GetImageFromArray(np.transpose(np.asarray(image_xyz, dtype=np.float32), (2, 1, 0)))
29
+ if spacing_xyz is not None:
30
+ image.SetSpacing(tuple(float(value) for value in spacing_xyz))
31
+ return image
32
+
33
+
34
+ def remove_small_components_xyz(mask_xyz: np.ndarray, min_size_voxels: int) -> np.ndarray:
35
+ """Remove 6-connected components smaller than ``min_size_voxels``."""
36
+ mask = np.asarray(mask_xyz, dtype=bool)
37
+ if min_size_voxels <= 0 or not np.any(mask):
38
+ return np.ascontiguousarray(mask)
39
+ image = sitk.GetImageFromArray(np.transpose(mask.astype(np.uint8), (2, 1, 0)))
40
+ labels = sitk.ConnectedComponent(image, False)
41
+ retained = sitk.RelabelComponent(labels, minimumObjectSize=int(min_size_voxels), sortByObjectSize=False)
42
+ return np.ascontiguousarray(np.transpose(sitk.GetArrayFromImage(retained > 0), (2, 1, 0)).astype(bool))
43
+
44
+
45
+ def largest_component_xyz(mask_xyz: np.ndarray) -> np.ndarray:
46
+ """Return the largest 6-connected component, preserving an empty mask."""
47
+ mask = np.asarray(mask_xyz, dtype=bool)
48
+ if not np.any(mask):
49
+ return np.ascontiguousarray(mask)
50
+ image = sitk.GetImageFromArray(np.transpose(mask.astype(np.uint8), (2, 1, 0)))
51
+ labels = sitk.RelabelComponent(sitk.ConnectedComponent(image, False), sortByObjectSize=True)
52
+ return np.ascontiguousarray(np.transpose(sitk.GetArrayFromImage(labels == 1), (2, 1, 0)).astype(bool))
53
+
54
+
55
+ def smooth_xyz(
56
+ image_xyz: np.ndarray,
57
+ *,
58
+ sigma: float,
59
+ spacing_xyz: tuple[float, float, float] | None = None,
60
+ ) -> np.ndarray:
61
+ """Smooth an image with the same voxel-relative sigma convention as Timelapsed."""
62
+ if sigma <= 0:
63
+ return np.asarray(image_xyz, dtype=np.float32).copy()
64
+ image = numpy_xyz_to_sitk_scalar(image_xyz, spacing_xyz)
65
+ spacing = image.GetSpacing()
66
+ smoothed = sitk.SmoothingRecursiveGaussian(image, float(sigma) * min(spacing))
67
+ return sitk_to_numpy_xyz(smoothed)
68
+
69
+
70
+ def adaptive_threshold_xyz(
71
+ density_xyz: np.ndarray,
72
+ *,
73
+ spacing_xyz: tuple[float, float, float] | None = None,
74
+ low_threshold: float = 190.0,
75
+ high_threshold: float = 450.0,
76
+ block_size: int = 13,
77
+ min_size_voxels: int = 64,
78
+ ) -> np.ndarray:
79
+ """Apply Schulte-style combined adaptive thresholding in x/y/z order."""
80
+ density = np.asarray(density_xyz, dtype=np.float32)
81
+ if density.ndim != 3:
82
+ raise ValueError(f"adaptive_threshold_xyz expects a 3D array, got ndim={density.ndim}.")
83
+ if block_size % 2 == 0:
84
+ raise ValueError(f"block_size must be odd, got {block_size}.")
85
+ image = numpy_xyz_to_sitk_scalar(density, spacing_xyz)
86
+ local_mean = sitk_to_numpy_xyz(sitk.BoxMean(image, [block_size // 2] * 3))
87
+ filtered = smooth_xyz(density, sigma=1.0, spacing_xyz=spacing_xyz)
88
+ low_mask = filtered > float(low_threshold)
89
+ result = (filtered * low_mask > local_mean * low_mask) | (filtered > float(high_threshold))
90
+ return remove_small_components_xyz(result, min_size_voxels)
91
+
92
+
93
+ def _laplace_hamming_parameters(params: SegmentationParameters) -> LaplaceHammingParameters:
94
+ return LaplaceHammingParameters(
95
+ low_pass_cutoff=params.laplace_hamming_low_pass_cutoff,
96
+ high_pass_cutoff=params.laplace_hamming_high_pass_cutoff,
97
+ laplace_epsilon=params.laplace_hamming_epsilon,
98
+ hamming_amplitude=params.laplace_hamming_amplitude,
99
+ amplification=params.laplace_hamming_amplification,
100
+ input_offset=params.laplace_hamming_input_offset,
101
+ ipl_float_max=params.laplace_hamming_ipl_float_max,
102
+ int16_max=params.laplace_hamming_int16_max,
103
+ threshold=params.laplace_hamming_threshold,
104
+ min_size_voxels=params.laplace_hamming_min_size_voxels,
105
+ backend=params.laplace_hamming_backend,
106
+ )
107
+
108
+
109
+ def segment_bone_xyz(
110
+ image_xyz: np.ndarray,
111
+ full_mask_xyz: np.ndarray,
112
+ trab_mask_xyz: np.ndarray,
113
+ cort_mask_xyz: np.ndarray,
114
+ parameters: SegmentationParameters,
115
+ *,
116
+ spacing_xyz: tuple[float, float, float] | None = None,
117
+ ) -> np.ndarray:
118
+ """Generate a cleaned bone segmentation constrained to the full mask."""
119
+ full = np.asarray(full_mask_xyz, dtype=bool)
120
+ if not parameters.enabled:
121
+ return np.ascontiguousarray(full)
122
+ method = parameters.method.strip().lower()
123
+ if method in {"global", "seg_gauss"}:
124
+ method = "gauss"
125
+ if method == "gauss":
126
+ filtered = smooth_xyz(image_xyz, sigma=parameters.gaussian_sigma, spacing_xyz=spacing_xyz)
127
+ segmentation = ((filtered >= parameters.trab_threshold) & np.asarray(trab_mask_xyz, dtype=bool)) | (
128
+ (filtered >= parameters.cort_threshold) & np.asarray(cort_mask_xyz, dtype=bool)
129
+ )
130
+ elif method == "adaptive":
131
+ segmentation = adaptive_threshold_xyz(
132
+ image_xyz,
133
+ spacing_xyz=spacing_xyz,
134
+ low_threshold=parameters.adaptive_low_threshold,
135
+ high_threshold=parameters.adaptive_high_threshold,
136
+ block_size=parameters.adaptive_block_size,
137
+ min_size_voxels=parameters.min_size_voxels,
138
+ )
139
+ elif method == "laplace_hamming":
140
+ segmentation = laplace_hamming_binarize_xyz(
141
+ image_xyz,
142
+ full_mask_xyz=full,
143
+ spacing_xyz=spacing_xyz,
144
+ parameters=_laplace_hamming_parameters(parameters),
145
+ )
146
+ else:
147
+ raise ValueError(f"Unsupported segmentation method: {parameters.method!r}.")
148
+ segmentation = remove_small_components_xyz(segmentation & full, parameters.min_size_voxels)
149
+ if method != "laplace_hamming" and parameters.keep_largest_component:
150
+ segmentation = largest_component_xyz(segmentation)
151
+ return np.ascontiguousarray(segmentation, dtype=bool)
152
+
153
+
154
+ def contour_support_xyz(
155
+ image_xyz: np.ndarray,
156
+ parameters: SegmentationParameters,
157
+ *,
158
+ spacing_xyz: tuple[float, float, float] | None = None,
159
+ full_mask_xyz: np.ndarray | None = None,
160
+ role: str = "outer",
161
+ ) -> np.ndarray | None:
162
+ """Create a temporary contour-support mask from the selected segmentation method."""
163
+ if not parameters.enabled:
164
+ return None
165
+ method = parameters.method.strip().lower()
166
+ if method in {"global", "seg_gauss"}:
167
+ method = "gauss"
168
+ if full_mask_xyz is None:
169
+ full_mask = np.ones(np.asarray(image_xyz).shape, dtype=bool)
170
+ else:
171
+ full_mask = np.asarray(full_mask_xyz, dtype=bool)
172
+ if method == "gauss":
173
+ filtered = smooth_xyz(image_xyz, sigma=parameters.gaussian_sigma, spacing_xyz=spacing_xyz)
174
+ threshold = parameters.trab_threshold if role == "outer" else parameters.cort_threshold
175
+ support = filtered >= float(threshold)
176
+ elif method == "adaptive":
177
+ support = adaptive_threshold_xyz(
178
+ image_xyz,
179
+ spacing_xyz=spacing_xyz,
180
+ low_threshold=parameters.adaptive_low_threshold,
181
+ high_threshold=parameters.adaptive_high_threshold,
182
+ block_size=parameters.adaptive_block_size,
183
+ min_size_voxels=parameters.min_size_voxels,
184
+ )
185
+ elif method == "laplace_hamming":
186
+ support = laplace_hamming_binarize_xyz(
187
+ image_xyz,
188
+ full_mask_xyz=full_mask,
189
+ spacing_xyz=spacing_xyz,
190
+ parameters=_laplace_hamming_parameters(parameters),
191
+ )
192
+ else:
193
+ return None
194
+ return np.ascontiguousarray(np.asarray(support, dtype=bool) & full_mask)
195
+
196
+
197
+ def _apply_xy_morphology(mask_xyz: np.ndarray, radius: int, operation: str) -> np.ndarray:
198
+ """Apply a 2D binary morphology operation independently to every stack slice."""
199
+ mask = np.asarray(mask_xyz, dtype=bool)
200
+ if radius <= 0:
201
+ return np.ascontiguousarray(mask)
202
+ output = np.zeros_like(mask)
203
+ for z_index in range(mask.shape[2]):
204
+ slice_image = sitk.GetImageFromArray(mask[:, :, z_index].T.astype(np.uint8))
205
+ if operation == "close":
206
+ processed = sitk.BinaryMorphologicalClosing(slice_image, [radius, radius])
207
+ elif operation == "open":
208
+ processed = sitk.BinaryMorphologicalOpening(slice_image, [radius, radius])
209
+ elif operation == "erode":
210
+ processed = sitk.BinaryErode(slice_image, [radius, radius])
211
+ else: # pragma: no cover - private caller supplies fixed operations
212
+ raise ValueError(f"Unsupported morphology operation: {operation}.")
213
+ output[:, :, z_index] = sitk.GetArrayFromImage(processed).T > 0
214
+ return output
215
+
216
+
217
+ def fill_holes_xy(mask_xyz: np.ndarray) -> np.ndarray:
218
+ """Fill holes in each axial slice, including holes at terminal slices."""
219
+ mask = np.asarray(mask_xyz, dtype=bool)
220
+ output = np.zeros_like(mask)
221
+ for z_index in range(mask.shape[2]):
222
+ slice_image = sitk.GetImageFromArray(mask[:, :, z_index].T.astype(np.uint8))
223
+ output[:, :, z_index] = sitk.GetArrayFromImage(sitk.BinaryFillhole(slice_image)).T > 0
224
+ return output
225
+
226
+
227
+ def outer_contour_xyz(
228
+ density_xyz: np.ndarray,
229
+ parameters: OuterContourParameters,
230
+ *,
231
+ spacing_xyz: tuple[float, float, float] | None = None,
232
+ support_mask_xyz: np.ndarray | None = None,
233
+ ) -> np.ndarray:
234
+ """Create a component-cleaned, hole-filled periosteal mask in x/y/z order."""
235
+ density = np.asarray(density_xyz, dtype=np.float32)
236
+ if density.ndim != 3:
237
+ raise ValueError(f"outer_contour_xyz expects a 3D array, got ndim={density.ndim}.")
238
+ if support_mask_xyz is None and not np.any(density != 0):
239
+ return np.zeros_like(density, dtype=bool)
240
+ if support_mask_xyz is not None:
241
+ thresholded = np.asarray(support_mask_xyz, dtype=bool)
242
+ if thresholded.shape != density.shape:
243
+ raise ValueError("support_mask_xyz shape must match density_xyz shape.")
244
+ elif parameters.use_adaptive_threshold:
245
+ thresholded = adaptive_threshold_xyz(density, spacing_xyz=spacing_xyz, min_size_voxels=0)
246
+ else:
247
+ thresholded = smooth_xyz(density, sigma=parameters.gaussian_sigma, spacing_xyz=spacing_xyz) >= float(
248
+ parameters.periosteal_threshold
249
+ )
250
+ if support_mask_xyz is None:
251
+ thresholded &= density != 0
252
+ thresholded = largest_component_xyz(thresholded)
253
+ thresholded = _apply_xy_morphology(thresholded, parameters.periosteal_kernel_size, "close")
254
+ thresholded = _apply_xy_morphology(thresholded, parameters.periosteal_open_radius, "open")
255
+ if parameters.fill_holes:
256
+ thresholded = fill_holes_xy(thresholded)
257
+ return np.ascontiguousarray(thresholded, dtype=bool)
258
+
259
+
260
+ def _site_trabecular_close_radius(site: str) -> int:
261
+ return 15 if site.strip().lower() == "radius" else 25
262
+
263
+
264
+ def inner_contour_xyz(
265
+ density_xyz: np.ndarray,
266
+ full_mask_xyz: np.ndarray,
267
+ parameters: InnerContourParameters,
268
+ *,
269
+ spacing_xyz: tuple[float, float, float] | None = None,
270
+ support_mask_xyz: np.ndarray | None = None,
271
+ ) -> tuple[np.ndarray, np.ndarray]:
272
+ """Derive trabecular and cortical masks within a full periosteal mask."""
273
+ density = np.asarray(density_xyz, dtype=np.float32)
274
+ full = np.asarray(full_mask_xyz, dtype=bool)
275
+ if density.shape != full.shape:
276
+ raise ValueError("density_xyz and full_mask_xyz must have matching shapes.")
277
+ if not np.any(full):
278
+ empty = np.zeros_like(full)
279
+ return empty, empty
280
+ if support_mask_xyz is not None:
281
+ cortical = np.asarray(support_mask_xyz, dtype=bool)
282
+ if cortical.shape != full.shape:
283
+ raise ValueError("support_mask_xyz shape must match density_xyz shape.")
284
+ elif parameters.use_adaptive_threshold:
285
+ cortical = adaptive_threshold_xyz(density, spacing_xyz=spacing_xyz, min_size_voxels=0)
286
+ else:
287
+ cortical = smooth_xyz(density, sigma=parameters.gaussian_sigma, spacing_xyz=spacing_xyz) >= float(
288
+ parameters.endosteal_threshold
289
+ )
290
+ cortical &= full
291
+ if parameters.peel >= min(full.shape[:2]):
292
+ inner_support = np.zeros_like(full)
293
+ else:
294
+ inner_support = _apply_xy_morphology(full, parameters.peel, "erode")
295
+ trabecular = largest_component_xyz(inner_support & ~cortical)
296
+ close_radius = parameters.trabecular_close_radius
297
+ if close_radius is None:
298
+ close_radius = _site_trabecular_close_radius(parameters.site)
299
+ if close_radius > 0 and np.any(trabecular):
300
+ trabecular = _apply_xy_morphology(trabecular, close_radius, "close")
301
+ trabecular &= inner_support
302
+ cortical_mask = full & ~trabecular
303
+ return np.ascontiguousarray(trabecular), np.ascontiguousarray(cortical_mask)
@@ -0,0 +1,218 @@
1
+ """SimpleITK-first public contour and mask-generation API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import SimpleITK as sitk
10
+
11
+ from ._arrays import (
12
+ contour_support_xyz,
13
+ inner_contour_xyz,
14
+ numpy_xyz_to_sitk_binary,
15
+ outer_contour_xyz,
16
+ segment_bone_xyz,
17
+ sitk_to_numpy_xyz,
18
+ )
19
+ from .parameters import ContourParameters
20
+ from .presets import resolve_preset
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class GeneratedMasks:
25
+ """Geometry-preserving masks generated from one input image."""
26
+
27
+ seg: sitk.Image
28
+ full: sitk.Image
29
+ trab: sitk.Image
30
+ cort: sitk.Image
31
+ mask_provenance: dict[str, str]
32
+ metadata: dict[str, Any]
33
+
34
+
35
+ def _validate_image(image: sitk.Image, label: str) -> None:
36
+ if not isinstance(image, sitk.Image):
37
+ raise TypeError(f"{label} must be a SimpleITK.Image.")
38
+ if image.GetDimension() != 3:
39
+ raise ValueError(f"{label} must be three-dimensional, got {image.GetDimension()}D.")
40
+
41
+
42
+ def _geodesic_outer_contour(
43
+ density_xyz: np.ndarray,
44
+ parameters: ContourParameters,
45
+ spacing_xyz: tuple[float, float, float],
46
+ ) -> tuple[np.ndarray, dict[str, Any]]:
47
+ try:
48
+ from hrpqct_geodesic_contour import contour
49
+ except ImportError as exc:
50
+ raise RuntimeError(
51
+ "Geodesic outer contouring requires the optional 'hrpqct-geodesic-contour' dependency."
52
+ ) from exc
53
+ full_mask, support_masks = contour(
54
+ density_xyz,
55
+ voxel_size_mm=spacing_xyz,
56
+ bone_threshold=parameters.outer.geodesic_bone_threshold,
57
+ fill_holes=parameters.outer.geodesic_fill_holes,
58
+ )
59
+ full = np.asarray(full_mask, dtype=bool)
60
+ if full.shape != density_xyz.shape:
61
+ raise ValueError("Geodesic contour output shape must match the input image shape.")
62
+ return full, {"support_mask_count": len(support_masks)}
63
+
64
+
65
+ def _valid_partition(full: np.ndarray, trab: np.ndarray, cort: np.ndarray) -> tuple[bool, str | None]:
66
+ if not np.any(full):
67
+ return True, None
68
+ if not np.any(trab):
69
+ return False, "empty_trabecular_mask"
70
+ if np.any(trab & cort) or np.any((trab | cort) & ~full):
71
+ return False, "invalid_compartment_partition"
72
+ if float(trab.sum()) / float(full.sum()) < 0.01:
73
+ return False, "implausibly_small_trabecular_fraction"
74
+ return True, None
75
+
76
+
77
+ def generate_masks_from_image(
78
+ image: sitk.Image,
79
+ parameters: ContourParameters | None = None,
80
+ *,
81
+ segmentation_image: sitk.Image | None = None,
82
+ ) -> GeneratedMasks:
83
+ """Generate `seg`, `full`, `trab`, and `cort` masks from a 3D SimpleITK image."""
84
+ _validate_image(image, "image")
85
+ params = parameters or resolve_preset()
86
+ density_xyz = sitk_to_numpy_xyz(image)
87
+ spacing_xyz = tuple(float(value) for value in image.GetSpacing())
88
+ segmentation_source = image if segmentation_image is None else segmentation_image
89
+ _validate_image(segmentation_source, "segmentation_image")
90
+ segmentation_xyz = sitk_to_numpy_xyz(segmentation_source)
91
+ if segmentation_xyz.shape != density_xyz.shape:
92
+ raise ValueError("segmentation_image size must match image size.")
93
+ aligned_support_enabled = bool(params.segmentation.use_segmentation_aligned_contour_support)
94
+ segmentation_method = params.segmentation.method.strip().lower()
95
+ if segmentation_method in {"global", "seg_gauss"}:
96
+ segmentation_method = "gauss"
97
+ reusable_segmentation_support = None
98
+
99
+ outer_method = params.outer.contour_method.strip().lower()
100
+ outer_metadata: dict[str, Any] = {}
101
+ if outer_method == "standard":
102
+ outer_support = None
103
+ if aligned_support_enabled:
104
+ outer_support = contour_support_xyz(
105
+ segmentation_xyz,
106
+ params.segmentation,
107
+ spacing_xyz=spacing_xyz,
108
+ role="outer",
109
+ )
110
+ if segmentation_method == "laplace_hamming":
111
+ reusable_segmentation_support = outer_support
112
+ full_xyz = outer_contour_xyz(
113
+ density_xyz,
114
+ params.outer,
115
+ spacing_xyz=spacing_xyz,
116
+ support_mask_xyz=outer_support,
117
+ )
118
+ outer_metadata = {
119
+ "support": "segmentation_aligned" if outer_support is not None else "image_threshold",
120
+ "outer_method": segmentation_method if outer_support is not None else None,
121
+ }
122
+ elif outer_method == "geodesic":
123
+ full_xyz, outer_metadata = _geodesic_outer_contour(density_xyz, params, spacing_xyz)
124
+ else:
125
+ raise ValueError(f"Unsupported outer contour method: {params.outer.contour_method!r}.")
126
+
127
+ inner_method = params.inner.contour_method.strip().lower()
128
+ fallback = {"applied": False, "reason": None}
129
+ if inner_method == "none":
130
+ trab_xyz = full_xyz.copy()
131
+ cort_xyz = np.zeros_like(full_xyz)
132
+ elif inner_method == "standard":
133
+ inner_support = None
134
+ if aligned_support_enabled:
135
+ if segmentation_method == "laplace_hamming" and reusable_segmentation_support is not None:
136
+ inner_support = np.asarray(reusable_segmentation_support, dtype=bool) & full_xyz
137
+ else:
138
+ inner_support = contour_support_xyz(
139
+ segmentation_xyz,
140
+ params.segmentation,
141
+ spacing_xyz=spacing_xyz,
142
+ full_mask_xyz=full_xyz,
143
+ role="inner",
144
+ )
145
+ trab_xyz, cort_xyz = inner_contour_xyz(
146
+ density_xyz,
147
+ full_xyz,
148
+ params.inner,
149
+ spacing_xyz=spacing_xyz,
150
+ support_mask_xyz=inner_support,
151
+ )
152
+ valid_partition, reason = _valid_partition(full_xyz, trab_xyz, cort_xyz)
153
+ if not valid_partition:
154
+ trab_xyz = full_xyz.copy()
155
+ cort_xyz = np.zeros_like(full_xyz)
156
+ fallback = {"applied": True, "reason": reason}
157
+ else:
158
+ raise ValueError(f"Unsupported inner contour method: {params.inner.contour_method!r}.")
159
+
160
+ if segmentation_method == "laplace_hamming" and reusable_segmentation_support is not None:
161
+ seg_xyz = np.asarray(reusable_segmentation_support, dtype=bool) & full_xyz
162
+ else:
163
+ seg_xyz = segment_bone_xyz(
164
+ segmentation_xyz,
165
+ full_xyz,
166
+ trab_xyz,
167
+ cort_xyz,
168
+ params.segmentation,
169
+ spacing_xyz=spacing_xyz,
170
+ )
171
+ metadata = {
172
+ "modality": params.modality,
173
+ "site": params.site,
174
+ "segmentation_method": params.segmentation.method,
175
+ "periosteal_contour_method": outer_method,
176
+ "endosteal_contour_method": inner_method,
177
+ "endosteal_fallback": fallback,
178
+ "outer_contour": outer_metadata,
179
+ "contour_support": outer_metadata,
180
+ }
181
+ return GeneratedMasks(
182
+ seg=numpy_xyz_to_sitk_binary(seg_xyz, image),
183
+ full=numpy_xyz_to_sitk_binary(full_xyz, image),
184
+ trab=numpy_xyz_to_sitk_binary(trab_xyz, image),
185
+ cort=numpy_xyz_to_sitk_binary(cort_xyz, image),
186
+ mask_provenance={"seg": "generated", "full": "generated", "trab": "generated", "cort": "generated"},
187
+ metadata=metadata,
188
+ )
189
+
190
+
191
+ def generate_bone_segmentation(
192
+ image: sitk.Image,
193
+ parameters: ContourParameters | None = None,
194
+ *,
195
+ full_mask: sitk.Image | None = None,
196
+ trab_mask: sitk.Image | None = None,
197
+ cort_mask: sitk.Image | None = None,
198
+ ) -> sitk.Image:
199
+ """Generate a binary bone segmentation, optionally within supplied masks."""
200
+ _validate_image(image, "image")
201
+ params = parameters or resolve_preset()
202
+ if full_mask is None and trab_mask is None and cort_mask is None:
203
+ return generate_masks_from_image(image, params).seg
204
+ if full_mask is None or trab_mask is None or cort_mask is None:
205
+ raise ValueError("full_mask, trab_mask, and cort_mask must be supplied together.")
206
+ for label, mask in (("full_mask", full_mask), ("trab_mask", trab_mask), ("cort_mask", cort_mask)):
207
+ _validate_image(mask, label)
208
+ if mask.GetSize() != image.GetSize():
209
+ raise ValueError(f"{label} size must match image size.")
210
+ segmentation_xyz = segment_bone_xyz(
211
+ sitk_to_numpy_xyz(image),
212
+ sitk_to_numpy_xyz(full_mask) > 0,
213
+ sitk_to_numpy_xyz(trab_mask) > 0,
214
+ sitk_to_numpy_xyz(cort_mask) > 0,
215
+ params.segmentation,
216
+ spacing_xyz=tuple(float(value) for value in image.GetSpacing()),
217
+ )
218
+ return numpy_xyz_to_sitk_binary(segmentation_xyz, image)
@@ -0,0 +1,123 @@
1
+ """Laplace-Hamming HR-pQCT segmentation on x/y/z NumPy arrays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ import SimpleITK as sitk
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class LaplaceHammingParameters:
13
+ """Parameters for the IPL-style Laplace-Hamming binarization."""
14
+
15
+ low_pass_cutoff: float = 0.3
16
+ high_pass_cutoff: float = 0.0
17
+ laplace_epsilon: float = 0.45
18
+ hamming_amplitude: float = 1.0
19
+ amplification: float = 1.0
20
+ input_offset: float = 0.0
21
+ ipl_float_max: float = 200000.0
22
+ int16_max: float = 32767.0
23
+ threshold: float = 15564.0
24
+ min_size_voxels: int = 70
25
+ backend: str = "cpu"
26
+
27
+
28
+ def _remove_small_components_6(binary: np.ndarray, min_size_voxels: int) -> np.ndarray:
29
+ if min_size_voxels <= 0 or not np.any(binary):
30
+ return np.ascontiguousarray(binary, dtype=bool)
31
+ image = sitk.GetImageFromArray(np.transpose(np.asarray(binary, dtype=np.uint8), (2, 1, 0)))
32
+ labels = sitk.ConnectedComponent(image, False)
33
+ retained = sitk.RelabelComponent(labels, minimumObjectSize=int(min_size_voxels), sortByObjectSize=False)
34
+ array = sitk.GetArrayFromImage(retained > 0)
35
+ return np.ascontiguousarray(np.transpose(array.astype(bool), (2, 1, 0)))
36
+
37
+
38
+ def _mirror_pad_to_power_of_two(array: np.ndarray) -> tuple[np.ndarray, tuple[slice, slice, slice]]:
39
+ pad_widths: list[tuple[int, int]] = []
40
+ slices: list[slice] = []
41
+ for size in array.shape:
42
+ target = 1 if size <= 1 else 2 ** int(np.ceil(np.log2(size)))
43
+ total = target - size
44
+ lower = total // 2
45
+ pad_widths.append((lower, total - lower))
46
+ slices.append(slice(lower, lower + size))
47
+ return np.pad(array, pad_widths, mode="reflect"), tuple(slices) # type: ignore[return-value]
48
+
49
+
50
+ def laplace_hamming_filter_xyz(
51
+ image_xyz: np.ndarray,
52
+ *,
53
+ spacing_xyz: tuple[float, float, float] | None = None,
54
+ parameters: LaplaceHammingParameters | None = None,
55
+ ) -> np.ndarray:
56
+ """Apply the Laplace-Hamming frequency-domain filter to an x/y/z image."""
57
+ p = parameters or LaplaceHammingParameters()
58
+ backend = p.backend.strip().lower()
59
+ if backend not in {"cpu", "auto"}:
60
+ raise RuntimeError("Only the CPU Laplace-Hamming backend is available in bone-contouring.")
61
+
62
+ pixels = np.asarray(image_xyz, dtype=np.float64) + float(p.input_offset)
63
+ if pixels.ndim != 3:
64
+ raise ValueError(f"Laplace-Hamming expects a 3D array, got ndim={pixels.ndim}.")
65
+ spacing = np.asarray(spacing_xyz or (0.0607, 0.0607, 0.0607), dtype=np.float64)
66
+ if spacing.shape != (3,) or np.any(spacing <= 0):
67
+ raise ValueError("spacing_xyz must contain three positive values.")
68
+ nyquist_min = 1.0 / (2.0 * float(np.min(spacing)))
69
+ low_pass_frequency = float(p.low_pass_cutoff) * 2.0 * nyquist_min
70
+ high_pass_frequency = float(p.high_pass_cutoff) * 2.0 * nyquist_min
71
+ if low_pass_frequency <= 0:
72
+ raise ValueError("Laplace-Hamming low_pass_cutoff must be positive.")
73
+
74
+ axes = [np.fft.fftfreq(size, d=float(spacing[index])) for index, size in enumerate(pixels.shape)]
75
+ kx, ky, kz = np.meshgrid(*axes, indexing="ij")
76
+ frequency_squared = kx * kx + ky * ky + kz * kz
77
+ frequency = np.sqrt(frequency_squared)
78
+ in_band = (frequency < low_pass_frequency) & (frequency >= high_pass_frequency)
79
+ half_amplitude = float(p.hamming_amplitude) * 0.5
80
+ window = np.where(
81
+ in_band,
82
+ (1.0 - half_amplitude) + half_amplitude * np.cos(np.pi * frequency / low_pass_frequency),
83
+ 0.0,
84
+ )
85
+ kernel = (
86
+ float(p.amplification)
87
+ * ((2.0 * np.pi) ** 2)
88
+ * ((1.0 - float(p.laplace_epsilon)) + float(p.laplace_epsilon) * frequency_squared)
89
+ * window
90
+ )
91
+ return np.real(np.fft.ifftn(np.fft.fftn(pixels) * kernel))
92
+
93
+
94
+ def laplace_hamming_binarize_xyz(
95
+ image_xyz: np.ndarray,
96
+ *,
97
+ full_mask_xyz: np.ndarray | None = None,
98
+ spacing_xyz: tuple[float, float, float] | None = None,
99
+ parameters: LaplaceHammingParameters | None = None,
100
+ ) -> np.ndarray:
101
+ """Return a component-cleaned Laplace-Hamming bone mask in x/y/z order."""
102
+ p = parameters or LaplaceHammingParameters()
103
+ original = np.asarray(image_xyz)
104
+ if original.ndim != 3:
105
+ raise ValueError(f"Laplace-Hamming expects a 3D array, got ndim={original.ndim}.")
106
+ extended = np.pad(original, ((1, 1), (1, 1), (1, 1)), mode="edge")
107
+ padded, original_slices = _mirror_pad_to_power_of_two(extended)
108
+ filtered = laplace_hamming_filter_xyz(padded, spacing_xyz=spacing_xyz, parameters=p)
109
+ scaled = np.rint(
110
+ np.clip(
111
+ filtered * (float(p.int16_max) / float(p.ipl_float_max)),
112
+ -float(p.int16_max),
113
+ float(p.int16_max),
114
+ )
115
+ ).astype(np.int16)
116
+ binary = (scaled >= float(p.threshold)) & (scaled <= float(p.int16_max))
117
+ binary = binary[original_slices][1:-1, 1:-1, 1:-1]
118
+ if full_mask_xyz is not None:
119
+ full_mask = np.asarray(full_mask_xyz, dtype=bool)
120
+ if full_mask.shape != binary.shape:
121
+ raise ValueError("full_mask_xyz shape must match image_xyz shape.")
122
+ binary &= full_mask
123
+ return _remove_small_components_6(binary, int(p.min_size_voxels))
@@ -0,0 +1,73 @@
1
+ """Stable configuration types for contour and mask generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class OuterContourParameters:
10
+ """Controls periosteal (full-mask) contour generation."""
11
+
12
+ contour_method: str = "standard"
13
+ periosteal_threshold: float = 300.0
14
+ periosteal_kernel_size: int = 5
15
+ periosteal_open_radius: int = 2
16
+ gaussian_sigma: float = 1.5
17
+ use_adaptive_threshold: bool = True
18
+ fill_holes: bool = True
19
+ geodesic_bone_threshold: float = 250.0
20
+ geodesic_fill_holes: bool = True
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class InnerContourParameters:
25
+ """Controls endosteal contour generation and compartment partitioning."""
26
+
27
+ contour_method: str = "standard"
28
+ site: str = "radius"
29
+ endosteal_threshold: float = 500.0
30
+ endosteal_kernel_size: int = 3
31
+ gaussian_sigma: float = 1.5
32
+ use_adaptive_threshold: bool = False
33
+ peel: int = 3
34
+ trabecular_close_radius: int | None = None
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class SegmentationParameters:
39
+ """Controls final bone segmentation within the full mask."""
40
+
41
+ enabled: bool = True
42
+ method: str = "gauss"
43
+ gaussian_sigma: float = 0.8
44
+ trab_threshold: float = 320.0
45
+ cort_threshold: float = 450.0
46
+ adaptive_low_threshold: float = 190.0
47
+ adaptive_high_threshold: float = 450.0
48
+ adaptive_block_size: int = 13
49
+ min_size_voxels: int = 64
50
+ keep_largest_component: bool = True
51
+ laplace_hamming_low_pass_cutoff: float = 0.3
52
+ laplace_hamming_high_pass_cutoff: float = 0.0
53
+ laplace_hamming_threshold: float = 15564.0
54
+ laplace_hamming_epsilon: float = 0.45
55
+ laplace_hamming_amplitude: float = 1.0
56
+ laplace_hamming_amplification: float = 1.0
57
+ laplace_hamming_input_offset: float = 0.0
58
+ laplace_hamming_ipl_float_max: float = 200000.0
59
+ laplace_hamming_int16_max: float = 32767.0
60
+ laplace_hamming_min_size_voxels: int = 70
61
+ laplace_hamming_backend: str = "cpu"
62
+ use_segmentation_aligned_contour_support: bool = False
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class ContourParameters:
67
+ """Complete configuration for full, compartment, and bone masks."""
68
+
69
+ modality: str = "xct1"
70
+ site: str = "radius"
71
+ outer: OuterContourParameters = field(default_factory=OuterContourParameters)
72
+ inner: InnerContourParameters = field(default_factory=InnerContourParameters)
73
+ segmentation: SegmentationParameters = field(default_factory=SegmentationParameters)
@@ -0,0 +1,71 @@
1
+ """Composable presets for supported bone contouring choices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .parameters import ContourParameters
6
+
7
+ _MODALITIES = {"xct1", "xct2"}
8
+ _SITES = {"radius", "tibia", "knee"}
9
+ _SEGMENTATION_METHODS = {"laplace_hamming", "gauss", "adaptive"}
10
+ _OUTER_CONTOUR_METHODS = {"standard", "geodesic"}
11
+ _INNER_CONTOUR_METHODS = {"standard", "none"}
12
+
13
+
14
+ def _choice(value: str, allowed: set[str], label: str) -> str:
15
+ normalized = value.strip().lower()
16
+ if normalized not in allowed:
17
+ choices = ", ".join(sorted(allowed))
18
+ raise ValueError(f"Unsupported {label} {value!r}; choose one of: {choices}.")
19
+ return normalized
20
+
21
+
22
+ def resolve_preset(
23
+ *,
24
+ modality: str = "xct1",
25
+ site: str = "radius",
26
+ segmentation: str = "laplace_hamming",
27
+ outer_contour: str = "standard",
28
+ inner_contour: str = "standard",
29
+ ) -> ContourParameters:
30
+ """Compose a fresh parameter object from supported method dimensions."""
31
+ modality = _choice(modality, _MODALITIES, "modality")
32
+ site = _choice(site, _SITES, "site")
33
+ segmentation = _choice(segmentation, _SEGMENTATION_METHODS, "segmentation")
34
+ outer_contour = _choice(outer_contour, _OUTER_CONTOUR_METHODS, "outer contour")
35
+ inner_contour = _choice(inner_contour, _INNER_CONTOUR_METHODS, "inner contour")
36
+
37
+ params = ContourParameters(modality=modality, site=site)
38
+ params.inner.site = site
39
+ params.segmentation.method = segmentation
40
+ params.segmentation.use_segmentation_aligned_contour_support = True
41
+ params.outer.contour_method = outer_contour
42
+ params.outer.use_adaptive_threshold = False
43
+ params.inner.contour_method = inner_contour
44
+ params.inner.use_adaptive_threshold = False
45
+
46
+ if modality == "xct1":
47
+ params.outer.periosteal_kernel_size = 12
48
+ params.outer.periosteal_open_radius = 1
49
+ if site in {"radius", "tibia"} and segmentation == "laplace_hamming":
50
+ params.segmentation.laplace_hamming_threshold = 15000.0
51
+ else:
52
+ params.outer.periosteal_kernel_size = 5
53
+ params.outer.periosteal_open_radius = 2
54
+ return params
55
+
56
+
57
+ def load_preset(name: str) -> ContourParameters:
58
+ """Load a preset encoded as ``modality-site-segmentation-outer-inner``."""
59
+ parts = name.strip().lower().split("-")
60
+ if len(parts) != 5:
61
+ raise ValueError(
62
+ "Preset names use 'modality-site-segmentation-outer-inner', for example "
63
+ "'xct1-radius-laplace_hamming-standard-standard'."
64
+ )
65
+ return resolve_preset(
66
+ modality=parts[0],
67
+ site=parts[1],
68
+ segmentation=parts[2],
69
+ outer_contour=parts[3],
70
+ inner_contour=parts[4],
71
+ )
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: bone-contouring
3
+ Version: 0.1.0
4
+ Summary: SimpleITK-first bone contour and mask generation for volumetric bone images
5
+ Author: Matthias Walle
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy<3.0,>=1.26
11
+ Requires-Dist: SimpleITK>=2.3
12
+ Provides-Extra: geodesic
13
+ Requires-Dist: hrpqct-geodesic-contour>=0.1.2; extra == "geodesic"
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8; extra == "test"
16
+ Dynamic: license-file
17
+
18
+ # bone-contouring
19
+
20
+ SimpleITK-first bone contouring and mask generation for volumetric bone images.
21
+
22
+ ```python
23
+ from bone_contouring import generate_masks_from_image, resolve_preset
24
+
25
+ masks = generate_masks_from_image(image, resolve_preset(modality="xct1", site="radius"))
26
+ ```
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/bone_contouring/__init__.py
5
+ src/bone_contouring/_arrays.py
6
+ src/bone_contouring/api.py
7
+ src/bone_contouring/laplace_hamming.py
8
+ src/bone_contouring/parameters.py
9
+ src/bone_contouring/presets.py
10
+ src/bone_contouring.egg-info/PKG-INFO
11
+ src/bone_contouring.egg-info/SOURCES.txt
12
+ src/bone_contouring.egg-info/dependency_links.txt
13
+ src/bone_contouring.egg-info/requires.txt
14
+ src/bone_contouring.egg-info/top_level.txt
15
+ tests/test_api.py
16
+ tests/test_presets.py
17
+ tests/test_segmentation.py
@@ -0,0 +1,8 @@
1
+ numpy<3.0,>=1.26
2
+ SimpleITK>=2.3
3
+
4
+ [geodesic]
5
+ hrpqct-geodesic-contour>=0.1.2
6
+
7
+ [test]
8
+ pytest>=8
@@ -0,0 +1 @@
1
+ bone_contouring
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import types
5
+
6
+ import numpy as np
7
+ import SimpleITK as sitk
8
+
9
+ from bone_contouring import ContourParameters, generate_bone_segmentation, generate_masks_from_image
10
+ from bone_contouring._arrays import sitk_to_numpy_xyz
11
+
12
+
13
+ def _image_from_xyz(values: np.ndarray) -> sitk.Image:
14
+ image = sitk.GetImageFromArray(np.transpose(values.astype(np.float32), (2, 1, 0)))
15
+ image.SetSpacing((0.061, 0.062, 0.063))
16
+ image.SetOrigin((1.0, 2.0, 3.0))
17
+ image.SetDirection((1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0))
18
+ return image
19
+
20
+
21
+ def _ring_image() -> sitk.Image:
22
+ shape = (33, 33, 7)
23
+ x, y, _z = np.indices(shape)
24
+ radius = np.sqrt((x - 16) ** 2 + (y - 16) ** 2)
25
+ values = np.zeros(shape, dtype=np.float32)
26
+ values[(radius >= 8) & (radius <= 11)] = 900.0
27
+ return _image_from_xyz(values)
28
+
29
+
30
+ def _standard_outer_parameters() -> ContourParameters:
31
+ params = ContourParameters()
32
+ params.outer.use_adaptive_threshold = False
33
+ params.outer.periosteal_threshold = 300.0
34
+ params.outer.periosteal_kernel_size = 1
35
+ params.outer.periosteal_open_radius = 0
36
+ params.segmentation.method = "gauss"
37
+ params.segmentation.gaussian_sigma = 0.0
38
+ params.segmentation.trab_threshold = 500.0
39
+ params.segmentation.cort_threshold = 500.0
40
+ params.segmentation.min_size_voxels = 0
41
+ return params
42
+
43
+
44
+ def test_generate_masks_fills_full_mask_holes_and_preserves_geometry() -> None:
45
+ """A periosteal ring must become a solid geometry-preserving full mask."""
46
+ image = _ring_image()
47
+ params = _standard_outer_parameters()
48
+ params.inner.contour_method = "none"
49
+
50
+ masks = generate_masks_from_image(image, params)
51
+
52
+ full = sitk_to_numpy_xyz(masks.full) > 0
53
+ assert full[16, 16, 3]
54
+ for mask in (masks.seg, masks.full, masks.trab, masks.cort):
55
+ assert mask.GetSpacing() == image.GetSpacing()
56
+ assert mask.GetOrigin() == image.GetOrigin()
57
+ assert mask.GetDirection() == image.GetDirection()
58
+
59
+
60
+ def test_standard_outer_contour_can_use_gaussian_segmentation_support() -> None:
61
+ """Aligned contour support should use the Gaussian bone-support threshold, not adaptive support."""
62
+ values = np.zeros((21, 21, 3), dtype=np.float32)
63
+ values[5:16, 5:16, :] = 310.0
64
+ image = _image_from_xyz(values)
65
+ params = ContourParameters()
66
+ params.outer.contour_method = "standard"
67
+ params.outer.use_adaptive_threshold = True
68
+ params.outer.periosteal_threshold = 900.0
69
+ params.outer.periosteal_kernel_size = 0
70
+ params.outer.periosteal_open_radius = 0
71
+ params.inner.contour_method = "none"
72
+ params.segmentation.method = "gauss"
73
+ params.segmentation.gaussian_sigma = 0.0
74
+ params.segmentation.trab_threshold = 300.0
75
+ params.segmentation.use_segmentation_aligned_contour_support = True
76
+
77
+ masks = generate_masks_from_image(image, params)
78
+
79
+ full = sitk_to_numpy_xyz(masks.full) > 0
80
+ assert full[10, 10, 1]
81
+ assert masks.metadata["contour_support"]["outer_method"] == "gauss"
82
+
83
+
84
+ def test_standard_outer_contour_can_use_laplace_hamming_segmentation_source(monkeypatch) -> None:
85
+ """Laplace-Hamming support should be computed from the supplied native segmentation image."""
86
+ from bone_contouring import _arrays
87
+
88
+ density = np.zeros((11, 11, 3), dtype=np.float32)
89
+ native = np.zeros_like(density)
90
+ native[3:8, 3:8, :] = 20000.0
91
+ image = _image_from_xyz(density)
92
+ segmentation_image = _image_from_xyz(native)
93
+ seen = {}
94
+
95
+ def fake_lh(image_xyz, *, full_mask_xyz, spacing_xyz, parameters):
96
+ seen["max"] = float(np.max(image_xyz))
97
+ return image_xyz > 10000
98
+
99
+ monkeypatch.setattr(_arrays, "laplace_hamming_binarize_xyz", fake_lh)
100
+ params = ContourParameters()
101
+ params.outer.contour_method = "standard"
102
+ params.outer.periosteal_kernel_size = 0
103
+ params.outer.periosteal_open_radius = 0
104
+ params.inner.contour_method = "none"
105
+ params.segmentation.method = "laplace_hamming"
106
+ params.segmentation.use_segmentation_aligned_contour_support = True
107
+
108
+ masks = generate_masks_from_image(image, params, segmentation_image=segmentation_image)
109
+
110
+ full = sitk_to_numpy_xyz(masks.full) > 0
111
+ assert seen["max"] == 20000.0
112
+ assert full[5, 5, 1]
113
+ assert masks.metadata["contour_support"]["outer_method"] == "laplace_hamming"
114
+
115
+
116
+ def test_laplace_hamming_aligned_support_is_reused_for_final_segmentation(monkeypatch) -> None:
117
+ """When LH support drives contours and segmentation, the expensive filter should run once."""
118
+ from bone_contouring import _arrays
119
+
120
+ density = np.zeros((11, 11, 5), dtype=np.float32)
121
+ native = np.zeros_like(density)
122
+ native[3:8, 3:8, :] = 20000.0
123
+ image = _image_from_xyz(density)
124
+ segmentation_image = _image_from_xyz(native)
125
+ calls = {"count": 0}
126
+
127
+ def fake_lh(image_xyz, *, full_mask_xyz, spacing_xyz, parameters):
128
+ calls["count"] += 1
129
+ return image_xyz > 10000
130
+
131
+ monkeypatch.setattr(_arrays, "laplace_hamming_binarize_xyz", fake_lh)
132
+ params = ContourParameters()
133
+ params.outer.contour_method = "standard"
134
+ params.outer.periosteal_kernel_size = 0
135
+ params.outer.periosteal_open_radius = 0
136
+ params.inner.contour_method = "none"
137
+ params.segmentation.method = "laplace_hamming"
138
+ params.segmentation.use_segmentation_aligned_contour_support = True
139
+
140
+ masks = generate_masks_from_image(image, params, segmentation_image=segmentation_image)
141
+
142
+ assert calls["count"] == 1
143
+ assert np.array_equal(sitk_to_numpy_xyz(masks.seg) > 0, sitk_to_numpy_xyz(masks.full) > 0)
144
+
145
+
146
+ def test_none_inner_contour_assigns_full_mask_to_trabecular_compartment() -> None:
147
+ """The explicit `none` choice must not synthesize a cortical compartment."""
148
+ params = _standard_outer_parameters()
149
+ params.inner.contour_method = "none"
150
+
151
+ masks = generate_masks_from_image(_ring_image(), params)
152
+
153
+ assert np.array_equal(sitk_to_numpy_xyz(masks.trab), sitk_to_numpy_xyz(masks.full))
154
+ assert not np.any(sitk_to_numpy_xyz(masks.cort))
155
+ assert masks.metadata["endosteal_contour_method"] == "none"
156
+
157
+
158
+ def test_implausible_endosteal_result_uses_recorded_full_mask_fallback() -> None:
159
+ """An empty trabecular partition must not escape as an unannotated result."""
160
+ params = _standard_outer_parameters()
161
+ params.inner.contour_method = "standard"
162
+ params.inner.peel = 100
163
+
164
+ masks = generate_masks_from_image(_ring_image(), params)
165
+
166
+ assert np.array_equal(sitk_to_numpy_xyz(masks.trab), sitk_to_numpy_xyz(masks.full))
167
+ assert not np.any(sitk_to_numpy_xyz(masks.cort))
168
+ assert masks.metadata["endosteal_fallback"]["applied"] is True
169
+ assert masks.metadata["endosteal_fallback"]["reason"] == "empty_trabecular_mask"
170
+
171
+
172
+ def test_generate_bone_segmentation_returns_a_geometry_preserving_mask() -> None:
173
+ """The standalone segmentation entry point must retain the input image geometry."""
174
+ values = np.zeros((9, 9, 5), dtype=np.float32)
175
+ values[2:6, 2:6, 1:4] = 800.0
176
+ image = _image_from_xyz(values)
177
+ params = _standard_outer_parameters()
178
+
179
+ segmentation = generate_bone_segmentation(image, params)
180
+
181
+ assert sitk_to_numpy_xyz(segmentation)[3, 3, 2] == 1
182
+ assert segmentation.GetSpacing() == image.GetSpacing()
183
+ assert segmentation.GetOrigin() == image.GetOrigin()
184
+
185
+
186
+ def test_geodesic_outer_contour_uses_optional_adapter_output(monkeypatch) -> None:
187
+ """The geodesic choice must use the optional package result as the full mask."""
188
+ image = _ring_image()
189
+ expected = np.zeros((33, 33, 7), dtype=bool)
190
+ expected[8:25, 8:25, :] = True
191
+
192
+ def contour(_density: np.ndarray, **_kwargs: object) -> tuple[np.ndarray, list[np.ndarray]]:
193
+ return expected, [expected]
194
+
195
+ monkeypatch.setitem(sys.modules, "hrpqct_geodesic_contour", types.SimpleNamespace(contour=contour))
196
+ params = _standard_outer_parameters()
197
+ params.outer.contour_method = "geodesic"
198
+ params.inner.contour_method = "none"
199
+
200
+ masks = generate_masks_from_image(image, params)
201
+
202
+ assert np.array_equal(sitk_to_numpy_xyz(masks.full) > 0, expected)
203
+ assert masks.metadata["periosteal_contour_method"] == "geodesic"
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from bone_contouring import (
4
+ ContourParameters,
5
+ InnerContourParameters,
6
+ OuterContourParameters,
7
+ SegmentationParameters,
8
+ load_preset,
9
+ resolve_preset,
10
+ )
11
+
12
+
13
+ def test_root_api_exports_parameter_types_and_preset_helpers() -> None:
14
+ """A missing root export would make the advertised stable API unusable."""
15
+ assert ContourParameters is not None
16
+ assert SegmentationParameters is not None
17
+ assert OuterContourParameters is not None
18
+ assert InnerContourParameters is not None
19
+ assert callable(load_preset)
20
+ assert callable(resolve_preset)
21
+
22
+
23
+ def test_resolve_preset_composes_each_requested_dimension() -> None:
24
+ """Independent preset dimensions must not silently override one another."""
25
+ params = resolve_preset(
26
+ modality="xct1",
27
+ site="radius",
28
+ segmentation="laplace_hamming",
29
+ outer_contour="geodesic",
30
+ inner_contour="none",
31
+ )
32
+
33
+ assert params.modality == "xct1"
34
+ assert params.site == "radius"
35
+ assert params.segmentation.method == "laplace_hamming"
36
+ assert params.outer.contour_method == "geodesic"
37
+ assert params.inner.contour_method == "none"
38
+ assert params.inner.site == "radius"
39
+ assert params.segmentation.use_segmentation_aligned_contour_support is True
40
+
41
+
42
+ def test_xct1_preset_uses_standard_periosteal_contour_defaults() -> None:
43
+ """XtremeCT I full-mask contouring uses the scanner-specific kernel/open defaults."""
44
+ params = resolve_preset(
45
+ modality="xct1",
46
+ site="radius",
47
+ segmentation="laplace_hamming",
48
+ outer_contour="standard",
49
+ inner_contour="standard",
50
+ )
51
+
52
+ assert params.outer.periosteal_threshold == 300.0
53
+ assert params.outer.periosteal_kernel_size == 12
54
+ assert params.outer.periosteal_open_radius == 1
55
+ assert params.outer.use_adaptive_threshold is False
56
+ assert params.segmentation.laplace_hamming_threshold == 15000.0
57
+
58
+
59
+ def test_resolved_presets_are_independent_instances() -> None:
60
+ """A caller's parameter edit must not contaminate later preset resolution."""
61
+ first = load_preset("xct2-tibia-gauss-standard-standard")
62
+ first.outer.periosteal_threshold = 999.0
63
+ second = load_preset("xct2-tibia-gauss-standard-standard")
64
+
65
+ assert first.segmentation.method == "gauss"
66
+ assert second.outer.periosteal_threshold != 999.0
67
+
68
+
69
+ def test_xct1_knee_keeps_default_laplace_hamming_threshold() -> None:
70
+ """The more inclusive XCT1 LH threshold is scoped to radius/tibia."""
71
+ params = resolve_preset(
72
+ modality="xct1",
73
+ site="knee",
74
+ segmentation="laplace_hamming",
75
+ outer_contour="standard",
76
+ inner_contour="standard",
77
+ )
78
+
79
+ assert params.segmentation.laplace_hamming_threshold == 15564.0
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from bone_contouring import SegmentationParameters
7
+ from bone_contouring._arrays import adaptive_threshold_xyz, segment_bone_xyz
8
+ from bone_contouring.laplace_hamming import LaplaceHammingParameters, laplace_hamming_binarize_xyz
9
+
10
+
11
+ def test_gaussian_segmentation_cleans_small_components_and_stays_in_full_mask() -> None:
12
+ """A segmentation regression must not keep isolated noise or escape `full`."""
13
+ image = np.zeros((9, 9, 9), dtype=np.float32)
14
+ image[2:5, 2:5, 2:5] = 800.0
15
+ image[7, 7, 7] = 800.0
16
+ full = np.zeros_like(image, dtype=bool)
17
+ full[1:6, 1:6, 1:6] = True
18
+ params = SegmentationParameters(
19
+ method="gauss",
20
+ gaussian_sigma=0.0,
21
+ trab_threshold=500.0,
22
+ cort_threshold=500.0,
23
+ min_size_voxels=4,
24
+ )
25
+
26
+ result = segment_bone_xyz(image, full, full, full, params, spacing_xyz=(1.0, 1.0, 1.0))
27
+
28
+ assert result[2:5, 2:5, 2:5].all()
29
+ assert not result[7, 7, 7]
30
+ assert not np.any(result & ~full)
31
+
32
+
33
+ def test_laplace_hamming_binarization_respects_full_mask_and_component_limit() -> None:
34
+ """Laplace-Hamming output must be constrained even when bright voxels exist outside full."""
35
+ image = np.zeros((8, 8, 8), dtype=np.float32)
36
+ image[2:4, 2:4, 2:4] = 900.0
37
+ image[6, 6, 6] = 900.0
38
+ full = np.zeros_like(image, dtype=bool)
39
+ full[1:5, 1:5, 1:5] = True
40
+ params = LaplaceHammingParameters(
41
+ low_pass_cutoff=1.0,
42
+ laplace_epsilon=0.0,
43
+ hamming_amplitude=0.0,
44
+ ipl_float_max=10000.0,
45
+ int16_max=10000.0,
46
+ threshold=500.0,
47
+ min_size_voxels=2,
48
+ )
49
+
50
+ result = laplace_hamming_binarize_xyz(
51
+ image,
52
+ full_mask_xyz=full,
53
+ spacing_xyz=(1.0, 1.0, 1.0),
54
+ parameters=params,
55
+ )
56
+
57
+ assert result[2:4, 2:4, 2:4].all()
58
+ assert not result[6, 6, 6]
59
+
60
+
61
+ def test_adaptive_threshold_rejects_even_window_sizes() -> None:
62
+ """An even local window has no center voxel and must fail explicitly."""
63
+ with pytest.raises(ValueError, match="odd"):
64
+ adaptive_threshold_xyz(
65
+ np.zeros((5, 5, 5), dtype=np.float32),
66
+ block_size=4,
67
+ )