flexray-mlx 0.2.1__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.

Potentially problematic release.


This version of flexray-mlx might be problematic. Click here for more details.

flexray_mlx/medmlx.py ADDED
@@ -0,0 +1,1208 @@
1
+ """MedMLX runner for pinned FleXray v1.0.2 radiograph segmentation.
2
+
3
+ The reference is upstream fxr at bf6d01426fd3ae012aff9253ec9c9952c58bc960
4
+ (MIT): UNet, file preprocessing, DICOM decoding, multilabel probabilities and
5
+ TTA. NumPy/Pillow implement the inference helpers without Torch at runtime.
6
+ The runner exports upstream model-grid probabilities and a selected source-grid
7
+ mask, with nearest-neighbor restoration of upstream's thresholded decisions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import math
14
+ import warnings
15
+ from collections.abc import Mapping, Sequence
16
+ from dataclasses import dataclass, replace
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, SupportsFloat, SupportsIndex, SupportsInt, cast
19
+
20
+ import numpy as np
21
+ from medmlx_core.errors import (
22
+ AssetNotReadyError,
23
+ InvalidInputError,
24
+ MissingDependencyError,
25
+ ModelExecutionError,
26
+ )
27
+ from PIL import Image, ImageOps, UnidentifiedImageError
28
+
29
+ from flexray_mlx._types import FloatArray, JsonValue, RealArray, RunResult
30
+ from flexray_mlx.tta import Forward, TtaSettings
31
+
32
+ if TYPE_CHECKING:
33
+ from flexray_mlx._types import DicomRuntime, EnsembleManifest, Normalization
34
+
35
+ MODEL = {
36
+ "id": "flexray",
37
+ "label": "FleXray",
38
+ "task": "segmentation",
39
+ "run": "flexray_mlx.medmlx:run",
40
+ "summary": (
41
+ "Anatomy segmentation of one 2D radiograph: 61 independent sigmoid "
42
+ "channels, one selected mask restored to the source pixel grid."
43
+ ),
44
+ }
45
+
46
+ MEMBERS = ("flux0375", "flux000", "flux025", "flux050", "flux075")
47
+ MODEL_GRID = (256, 256)
48
+ CHANNELS = 61
49
+ PREDICTION_SCHEMA = "flexray_mlx.prediction.v2"
50
+ DISPLAY_NAME = "source_image.png"
51
+ MASK_NAME = "source_image_label.png"
52
+ PROBABILITIES_NAME = "probabilities.npy"
53
+ PREDICTION_NAME = "prediction.json"
54
+ _BUNDLE_FILES = (
55
+ "config.yml",
56
+ "label_schema.json",
57
+ "model.safetensors",
58
+ "preprocessing.json",
59
+ )
60
+ _WEIGHTS_HINT = (
61
+ "Pass weights_path pointing to the FleXray release snapshot directory "
62
+ "(huggingface.co/VictorButoi/flexray, revision 6b63ddf09cfdff99aea494e3738cf243a4e22cdb) "
63
+ "holding ensemble.json and members/<name>/{config.yml,model.safetensors,"
64
+ "label_schema.json,preprocessing.json}."
65
+ )
66
+
67
+ DEFAULT_IMAGE_SIZE = (256, 256)
68
+ DEFAULT_NORMALIZATION_EPS = 1.0e-8
69
+ DEFAULT_NORMALIZATION_PERCENTILES = (0.5, 99.5)
70
+ _NORMALIZATION_SCHEMES = frozenset({"none", "minmax", "percentile_minmax", "standardize"})
71
+ DICOM_SUFFIXES = frozenset({".dcm", ".dicom"})
72
+ _DICOM_PREAMBLE_LENGTH = 128
73
+ _DICOM_MAGIC = b"DICM"
74
+
75
+
76
+ # --- Released bundle admission ------------------------------------------------------
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class AdmittedBundle:
81
+ """Admitted ensemble members and their shared label names.
82
+
83
+ Attributes:
84
+ bundles: Member directories under ``<root>/members``: the flagship only, or
85
+ all five in ``MEMBERS`` order.
86
+ labels: The 61 label names; a label's position is its probability channel.
87
+ """
88
+
89
+ bundles: tuple[Path, ...]
90
+ labels: tuple[str, ...]
91
+
92
+
93
+ def _missing(path: Path) -> AssetNotReadyError:
94
+ return AssetNotReadyError(
95
+ f"Missing FleXray asset: {path}", reason=f"missing {path}", hint=_WEIGHTS_HINT
96
+ )
97
+
98
+
99
+ def _read_json(path: Path) -> JsonValue:
100
+ if not path.is_file():
101
+ raise _missing(path)
102
+ try:
103
+ return cast(JsonValue, json.loads(path.read_text(encoding="utf-8")))
104
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
105
+ raise AssetNotReadyError(
106
+ f"Unreadable FleXray asset {path}: {error}",
107
+ reason=f"unreadable {path}",
108
+ hint=_WEIGHTS_HINT,
109
+ ) from error
110
+
111
+
112
+ def admit_bundle(root: Path, *, ensemble: bool) -> AdmittedBundle:
113
+ """Check the pinned ensemble layout, labels and shared preprocessing.
114
+
115
+ Only the members that will run are inspected: the flagship alone, or all five
116
+ when ``ensemble`` is true. Tensor contents are not read here; the network
117
+ checks them when it loads.
118
+
119
+ Raises:
120
+ AssetNotReadyError: A file is missing or unreadable, the ensemble layout
121
+ differs from the pinned release, members disagree on labels or
122
+ preprocessing, or the label list is not 61 names starting with
123
+ ``background``.
124
+ """
125
+
126
+ if not root.is_dir():
127
+ raise _missing(root)
128
+ manifest = cast("EnsembleManifest", _read_json(root / "ensemble.json"))
129
+ try:
130
+ listed = tuple(Path(str(item["subfolder"])).name for item in manifest["members"])
131
+ flagship = manifest.get("flagship")
132
+ except (AttributeError, KeyError, TypeError) as error:
133
+ raise AssetNotReadyError(
134
+ "FleXray ensemble.json is malformed",
135
+ reason="malformed ensemble.json",
136
+ hint=_WEIGHTS_HINT,
137
+ ) from error
138
+ if flagship != "members/flux0375" or listed != MEMBERS:
139
+ raise AssetNotReadyError(
140
+ "FleXray ensemble layout differs from the pinned release",
141
+ reason="unpinned ensemble layout",
142
+ hint=_WEIGHTS_HINT,
143
+ )
144
+ members = MEMBERS if ensemble else MEMBERS[:1]
145
+ bundles = tuple(root / "members" / name for name in members)
146
+ reference_schema: JsonValue = None
147
+ reference_preprocessing: JsonValue = None
148
+ for bundle in bundles:
149
+ for name in _BUNDLE_FILES:
150
+ if not (bundle / name).is_file():
151
+ raise _missing(bundle / name)
152
+ schema = _read_json(bundle / "label_schema.json")
153
+ preprocessing = _read_json(bundle / "preprocessing.json")
154
+ if reference_schema is None:
155
+ reference_schema, reference_preprocessing = schema, preprocessing
156
+ elif schema != reference_schema or preprocessing != reference_preprocessing:
157
+ raise AssetNotReadyError(
158
+ "FleXray ensemble members disagree on labels or preprocessing",
159
+ reason="inconsistent ensemble members",
160
+ hint=_WEIGHTS_HINT,
161
+ )
162
+ assert reference_schema is not None
163
+ raw_names = reference_schema.get("label_names") if isinstance(reference_schema, dict) else None
164
+ if not isinstance(raw_names, list):
165
+ raise AssetNotReadyError(
166
+ "FleXray released label schema has no label list",
167
+ reason="malformed label_schema.json",
168
+ hint=_WEIGHTS_HINT,
169
+ )
170
+ labels = tuple(str(name) for name in raw_names)
171
+ if len(labels) != CHANNELS or labels[0] != "background":
172
+ raise AssetNotReadyError(
173
+ "FleXray released label schema is invalid",
174
+ reason="invalid label_schema.json",
175
+ hint=_WEIGHTS_HINT,
176
+ )
177
+ return AdmittedBundle(bundles=bundles, labels=labels)
178
+
179
+
180
+ # --- DICOM input (upstream fxr/inference/dicom.py) ------------------------------------
181
+
182
+
183
+ def _pydicom() -> DicomRuntime:
184
+ try:
185
+ import pydicom
186
+ except ImportError as error:
187
+ raise MissingDependencyError(
188
+ "Reading DICOM radiographs requires pydicom",
189
+ hint="Install pydicom (a flexray-mlx dependency) in this environment.",
190
+ ) from error
191
+ return cast("DicomRuntime", pydicom)
192
+
193
+
194
+ def is_dicom_path(path: str | Path) -> bool:
195
+ """Return whether a path is a DICOM file by suffix or, without a suffix, DICM magic."""
196
+
197
+ candidate = Path(path)
198
+ if candidate.suffix.lower() in DICOM_SUFFIXES:
199
+ return True
200
+ if candidate.suffix or not candidate.is_file():
201
+ return False
202
+ try:
203
+ with candidate.open("rb") as handle:
204
+ handle.seek(_DICOM_PREAMBLE_LENGTH)
205
+ return handle.read(len(_DICOM_MAGIC)) == _DICOM_MAGIC
206
+ except OSError:
207
+ return False
208
+
209
+
210
+ def load_dicom_grayscale(path: str | Path) -> FloatArray:
211
+ """Read one single-frame DICOM into a 2D float32 image min-max scaled to [0, 1].
212
+
213
+ Pixels are rescaled with ``RescaleSlope``/``RescaleIntercept`` when present,
214
+ RGB(A) is reduced to luma, and MONOCHROME1 is inverted. No VOI LUT or
215
+ windowing is applied, so the full stored intensity range maps to [0, 1].
216
+
217
+ Raises:
218
+ ValueError: The file has no pixel data or is not a single-frame 2D image.
219
+ """
220
+
221
+ dataset = _pydicom().dcmread(str(path), force=True)
222
+ if "PixelData" not in dataset:
223
+ raise ValueError(f"DICOM file has no pixel data: {path}.")
224
+ pixels: RealArray = np.asarray(dataset.pixel_array)
225
+ if pixels.ndim == 3 and pixels.shape[-1] in {3, 4}:
226
+ rgb = pixels[..., :3].astype(np.float64, copy=False)
227
+ pixels = rgb[..., 0] * 0.299 + rgb[..., 1] * 0.587 + rgb[..., 2] * 0.114
228
+ if pixels.ndim != 2:
229
+ raise ValueError(
230
+ "Only single-frame 2D DICOM images are supported; got pixel shape "
231
+ f"{pixels.shape} for {path}."
232
+ )
233
+ values = pixels.astype(np.float64, copy=False)
234
+ slope = float(getattr(dataset, "RescaleSlope", 1.0) or 1.0)
235
+ intercept = float(getattr(dataset, "RescaleIntercept", 0.0) or 0.0)
236
+ values = values * slope + intercept
237
+ if str(getattr(dataset, "PhotometricInterpretation", "")).upper() == "MONOCHROME1":
238
+ values = values.max() - values
239
+ return scale_to_unit_range(values)
240
+
241
+
242
+ def scale_to_unit_range(values: RealArray) -> FloatArray:
243
+ """Min-max scale one image into [0, 1] float32; a constant image becomes zeros."""
244
+
245
+ values = np.asarray(values)
246
+ if np.issubdtype(values.dtype, np.integer):
247
+ low, high = int(values.min()), int(values.max())
248
+ origin = max(low, 0) + min(high, 0)
249
+ values = values - np.asarray(origin, dtype=values.dtype)
250
+ values = np.asarray(values, dtype=np.float64)
251
+ low = float(values.min())
252
+ high = float(values.max())
253
+ values = (values - low) / (high - low) if high > low else np.zeros_like(values)
254
+ return values.astype(np.float32)
255
+
256
+
257
+ # --- Source-grid display ------------------------------------------------------------
258
+
259
+
260
+ def display_image(path: Path) -> Image.Image:
261
+ """Return the source radiograph as an 8-bit grayscale image on its own pixel grid.
262
+
263
+ This is the image the exported mask overlays; it is not the model input.
264
+ Integer files of 16 bits or fewer are scaled by their dtype range, wider
265
+ integers and floats outside [0, 1] by min-max. DICOM is min-max scaled on load.
266
+
267
+ Raises:
268
+ InvalidInputError: The image is not 2D after grayscale conversion.
269
+ """
270
+
271
+ image = _load_image_file(path)
272
+ array = _grayscale_pixels(image) if isinstance(image, Image.Image) else image
273
+ values = _scale_numpy_integer_range(array, source_dtype=array.dtype)
274
+ if values.ndim != 2:
275
+ raise InvalidInputError("FleXray requires a 2D radiograph")
276
+ values = _check_pixel_values(values[None, None])[0, 0]
277
+ # Float files retain their intensity units in fxr; display those via its minmax rule.
278
+ if np.issubdtype(array.dtype, np.floating) and (values.min() < 0 or values.max() > 1):
279
+ values = scale_to_unit_range(values)
280
+ return Image.fromarray(np.rint(values * 255).astype(np.uint8))
281
+
282
+
283
+ def _grayscale_pixels(image: Image.Image) -> RealArray:
284
+ """Convert a Pillow image to a 2D grayscale array as upstream does.
285
+
286
+ Applies the EXIF orientation and composites transparency over black. Modes
287
+ ``1``, ``L``, ``I``, ``I;16`` and ``F`` keep their values; other modes go
288
+ through Pillow's ``convert("L")``.
289
+ """
290
+ image = ImageOps.exif_transpose(image)
291
+ if (
292
+ image.mode in {"RGBA", "LA", "PA"}
293
+ or "transparency" in image.info
294
+ or (image.palette is not None and image.palette.mode == "RGBA")
295
+ ):
296
+ canvas = Image.new("RGBA", image.size, (0, 0, 0, 255))
297
+ image = Image.alpha_composite(canvas, image.convert("RGBA"))
298
+ grayscale = image if image.mode in {"1", "L", "I", "I;16", "F"} else image.convert("L")
299
+ return np.array(grayscale, copy=True)
300
+
301
+
302
+ # --- Preprocessing (upstream fxr/inference/preprocessing.py) --------------------------
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class ImagePreprocessing:
307
+ """The released ``preprocessing.json`` contract for file inputs.
308
+
309
+ Mirrors upstream ``ImagePreprocessing`` with Torch tensors replaced by
310
+ float32 NumPy arrays shaped ``1x1xHxW``. ``prepare`` scales the file to float
311
+ intensities, zero-pads to a square (if ``pad_to_square``), resizes with
312
+ Pillow bilinear interpolation, then normalizes.
313
+
314
+ Attributes:
315
+ image_size: Model grid as ``(height, width)`` in pixels.
316
+ color_mode: Only ``"grayscale"`` is supported.
317
+ pad_to_square: Center-pad the shorter side with zeros before resizing.
318
+ scale: ``"zero_one"`` scales integer files of 16 bits or fewer by their
319
+ dtype range and wider integers by min-max, and passes floats through;
320
+ ``"none"`` casts everything to float32.
321
+ mean: Optional scalar subtracted after ``normalization_scheme``.
322
+ std: Optional positive scalar divisor applied after ``mean``.
323
+ normalization_scheme: ``"none"``, ``"minmax"``, ``"percentile_minmax"``
324
+ or ``"standardize"``, applied per sample.
325
+ percentiles: ``(lower, upper)`` in percent; required for, and only valid
326
+ with, ``"percentile_minmax"``.
327
+ eps: Positive stabilizer for the per-sample schemes: a floor on the value
328
+ range for the min-max schemes, added to the std for ``"standardize"``.
329
+ """
330
+
331
+ image_size: tuple[int, int] = DEFAULT_IMAGE_SIZE
332
+ color_mode: str = "grayscale"
333
+ pad_to_square: bool = True
334
+ scale: str = "zero_one"
335
+ mean: float | None = None
336
+ std: float | None = None
337
+ normalization_scheme: str = "none"
338
+ percentiles: tuple[float, float] | None = None
339
+ eps: float = DEFAULT_NORMALIZATION_EPS
340
+
341
+ def __post_init__(self) -> None:
342
+ _validate_image_size(self.image_size)
343
+ if self.color_mode != "grayscale":
344
+ raise ValueError(
345
+ "FleXray inference currently supports only grayscale preprocessing; "
346
+ f"got color_mode={self.color_mode!r}."
347
+ )
348
+ if self.scale not in {"zero_one", "none"}:
349
+ raise ValueError(
350
+ f"preprocessing scale must be 'zero_one' or 'none', got {self.scale!r}."
351
+ )
352
+ _validate_normalization(
353
+ scheme=self.normalization_scheme,
354
+ percentiles=self.percentiles,
355
+ eps=self.eps,
356
+ mean=self.mean,
357
+ std=self.std,
358
+ )
359
+
360
+ @classmethod
361
+ def from_metadata(cls, metadata: Mapping[str, object] | None) -> ImagePreprocessing:
362
+ """Build preprocessing from ``preprocessing.json`` metadata."""
363
+
364
+ data = dict(metadata or {})
365
+ size_value = cast(
366
+ Sequence[SupportsInt], data.get("image_size", data.get("size", DEFAULT_IMAGE_SIZE))
367
+ )
368
+ image_size = _validate_image_size(size_value)
369
+ color_mode = str(data.get("color_mode", "grayscale")).lower()
370
+ if color_mode != "grayscale":
371
+ raise ValueError(
372
+ "FleXray inference currently supports only grayscale preprocessing; "
373
+ f"got color_mode={color_mode!r}."
374
+ )
375
+ scale = str(data.get("scale", "zero_one")).lower()
376
+ if scale not in {"zero_one", "none"}:
377
+ raise ValueError(f"preprocessing scale must be 'zero_one' or 'none', got {scale!r}.")
378
+ normalization = _normalization_values(data)
379
+ return cls(
380
+ image_size=image_size,
381
+ color_mode=color_mode,
382
+ pad_to_square=bool(data.get("pad_to_square", True)),
383
+ scale=scale,
384
+ mean=normalization["mean"],
385
+ std=normalization["std"],
386
+ normalization_scheme=normalization["scheme"],
387
+ percentiles=normalization["percentiles"],
388
+ eps=normalization["eps"],
389
+ )
390
+
391
+ def content_box(self, height: int, width: int) -> tuple[int, int, int, int]:
392
+ """``(top, left, bottom, right)`` of the input footprint on the prepared canvas.
393
+
394
+ Coordinates are model-grid pixels with exclusive ``bottom`` and ``right``,
395
+ so ``[top:bottom, left:right]`` crops the part of the model grid that
396
+ holds image content rather than zero padding. ``height`` and ``width``
397
+ are the source image size in pixels.
398
+
399
+ Raises:
400
+ ValueError: ``height`` or ``width`` is not positive.
401
+ """
402
+
403
+ if height <= 0 or width <= 0:
404
+ raise ValueError("Input height and width must be positive.")
405
+ side = max(height, width)
406
+ canvas_height = side if self.pad_to_square else height
407
+ canvas_width = side if self.pad_to_square else width
408
+ top = (canvas_height - height) // 2
409
+ left = (canvas_width - width) // 2
410
+ scale_y = self.image_size[0] / canvas_height
411
+ scale_x = self.image_size[1] / canvas_width
412
+ out_top = min(round(top * scale_y), self.image_size[0] - 1)
413
+ out_left = min(round(left * scale_x), self.image_size[1] - 1)
414
+ bottom = max(out_top + 1, round((top + height) * scale_y))
415
+ right = max(out_left + 1, round((left + width) * scale_x))
416
+ return out_top, out_left, bottom, right
417
+
418
+ def prepare(self, path: str | Path) -> FloatArray:
419
+ """Load one image or DICOM file as a normalized float32 ``1x1xHxW`` array.
420
+
421
+ ``H`` and ``W`` are ``image_size``. Non-finite pixels become 0 with a
422
+ ``RuntimeWarning``; a constant image also warns.
423
+ """
424
+
425
+ image = _load_image_file(path)
426
+ if isinstance(image, Image.Image):
427
+ return self._prepare_pillow(image)
428
+ return self._prepare_array(image)
429
+
430
+ def _prepare_pillow(self, image: Image.Image) -> FloatArray:
431
+ array = _grayscale_pixels(image)
432
+ return self._prepare_grayscale_array(array, source_dtype=array.dtype)
433
+
434
+ def _prepare_array(self, array: FloatArray) -> FloatArray:
435
+ # File inputs reach this only as decoded 2D DICOM pixels.
436
+ np_array = np.asarray(array)
437
+ if np_array.size == 0:
438
+ raise ValueError("Image inputs must be non-empty.")
439
+ if np_array.ndim != 2:
440
+ raise ValueError(f"DICOM image inputs must have shape HxW; got {np_array.shape}.")
441
+ return self._prepare_grayscale_array(np_array, source_dtype=np_array.dtype)
442
+
443
+ def _prepare_grayscale_array(
444
+ self, array: RealArray, *, source_dtype: np.dtype[np.generic]
445
+ ) -> FloatArray:
446
+ if array.size == 0:
447
+ raise ValueError("Image inputs must be non-empty.")
448
+ if self.scale == "zero_one":
449
+ values = _scale_numpy_integer_range(array, source_dtype=source_dtype)
450
+ else:
451
+ values = np.asarray(array, dtype=np.float32)
452
+ values = _check_pixel_values(np.array(values, copy=True)[None, None])[0, 0]
453
+ prepared = Image.fromarray(values)
454
+ if self.pad_to_square:
455
+ prepared = _pad_to_square(prepared)
456
+ height, width = self.image_size
457
+ resized = prepared.resize((width, height), resample=Image.Resampling.BILINEAR)
458
+ return self.normalize(np.array(resized, dtype=np.float32, copy=True)[None, None])
459
+
460
+ def normalize(self, batch: FloatArray) -> FloatArray:
461
+ """Apply the configured per-sample or scalar normalization to ``BxCxHxW``.
462
+
463
+ Each sample is normalized over all of its channels and pixels. The
464
+ per-sample schemes and the scalar ``mean``/``std`` are mutually exclusive
465
+ (enforced at construction), so at most one of them takes effect.
466
+ """
467
+
468
+ normalized = batch
469
+ if self.normalization_scheme == "standardize":
470
+ dims = tuple(range(1, normalized.ndim))
471
+ mean = _standardize_mean(normalized)
472
+ # Torch defaults to correction=1 and accumulates CPU variance in double.
473
+ std = normalized.std(axis=dims, keepdims=True, dtype=np.float64, ddof=1).astype(
474
+ np.float32
475
+ )
476
+ normalized = (normalized - mean) / (std + np.float32(self.eps))
477
+ elif self.normalization_scheme == "minmax":
478
+ batch_size = normalized.shape[0]
479
+ flat = normalized.reshape(batch_size, -1)
480
+ view_shape = (batch_size,) + (1,) * (normalized.ndim - 1)
481
+ lower = flat.min(axis=1).reshape(view_shape)
482
+ upper = flat.max(axis=1).reshape(view_shape)
483
+ normalized = (normalized - lower) / np.maximum(upper - lower, np.float32(self.eps))
484
+ elif self.normalization_scheme == "percentile_minmax":
485
+ normalized = _percentile_minmax(
486
+ normalized,
487
+ percentiles=self.percentiles or DEFAULT_NORMALIZATION_PERCENTILES,
488
+ eps=self.eps,
489
+ )
490
+ if self.mean is not None:
491
+ normalized = normalized - np.float32(self.mean)
492
+ if self.std is not None:
493
+ normalized = normalized / np.float32(self.std)
494
+ return np.ascontiguousarray(normalized, dtype=np.float32)
495
+
496
+
497
+ def _load_image_file(path: str | Path) -> Image.Image | FloatArray:
498
+ if is_dicom_path(path):
499
+ return load_dicom_grayscale(path)
500
+ with Image.open(path) as opened:
501
+ opened.load()
502
+ return opened.copy()
503
+
504
+
505
+ def _standardize_mean(batch: FloatArray) -> FloatArray:
506
+ """Torch CPU cascade sum with four float32 lanes, then division by sample size.
507
+
508
+ Mirrors SumKernel.cpp's four ILP accumulators and four cascade levels. The
509
+ fixed lane order avoids large-offset cancellation differences from NumPy's
510
+ pairwise mean. All samples and chunks are accumulated together in NumPy.
511
+ PyTorch's BSD notice is in root THIRD_PARTY_NOTICES.md.
512
+ """
513
+
514
+ flat = np.ascontiguousarray(batch).reshape(batch.shape[0], -1)
515
+ count = flat.shape[1]
516
+ lanes = 4 if count >= 4 else 1
517
+ vectors = count // lanes
518
+ groups = vectors // 4
519
+ values = flat[:, : groups * 4 * lanes].reshape(len(flat), groups, 4, lanes)
520
+ step = 1 << max(4, (max(groups, 1) - 1).bit_length() // 4)
521
+ accumulators: list[FloatArray] = []
522
+ for level in range(4):
523
+ complete = values.shape[1] // step if level < 3 else 0
524
+ tail = values[:, complete * step :]
525
+ accumulator = np.zeros((len(flat), 4, lanes), dtype=np.float32)
526
+ if tail.shape[1]:
527
+ accumulator = cast(FloatArray, np.add.accumulate(tail, axis=1))[:, -1]
528
+ accumulators.append(accumulator)
529
+ values = values[:, : complete * step].reshape(len(flat), complete, step, 4, lanes)
530
+ values = cast(FloatArray, np.add.accumulate(values, axis=2))[:, :, -1]
531
+ partial = accumulators[0]
532
+ for accumulator in accumulators[1:]:
533
+ partial = partial + accumulator
534
+ for index in range(groups * 4, vectors):
535
+ partial[:, 0] += flat[:, index * lanes : (index + 1) * lanes]
536
+ for index in range(1, 4):
537
+ partial[:, 0] += partial[:, index]
538
+ total = np.zeros(len(flat), dtype=np.float32)
539
+ for index in range(vectors * lanes, count):
540
+ total += flat[:, index]
541
+ for index in range(lanes):
542
+ total += partial[:, 0, index]
543
+ return (total / np.float32(count)).reshape((len(flat),) + (1,) * (batch.ndim - 1))
544
+
545
+
546
+ def _validate_image_size(size: Sequence[SupportsInt]) -> tuple[int, int]:
547
+ if len(size) != 2:
548
+ raise ValueError(f"Image size must be an H W pair, got {tuple(size)}.")
549
+ height, width = int(size[0]), int(size[1])
550
+ if height <= 0 or width <= 0:
551
+ raise ValueError(f"Image size dimensions must be positive, got {(height, width)}.")
552
+ return height, width
553
+
554
+
555
+ def _scale_numpy_integer_range(
556
+ array: RealArray, *, source_dtype: np.dtype[np.generic]
557
+ ) -> FloatArray:
558
+ """Scale an array to float32 by its source dtype (the ``zero_one`` scale mode).
559
+
560
+ Unsigned integers of at most 16 bits divide by the dtype maximum; signed
561
+ ones map the dtype range onto [0, 1]. Wider integers use data min-max.
562
+ Booleans and floats are cast to float32 unchanged.
563
+ """
564
+
565
+ dtype = np.dtype(source_dtype)
566
+ if np.issubdtype(dtype, np.bool_) or not np.issubdtype(dtype, np.integer):
567
+ return np.asarray(array, dtype=np.float32)
568
+ if dtype.itemsize > 2:
569
+ return scale_to_unit_range(array)
570
+ values = np.asarray(array, dtype=np.float32)
571
+ limits = np.iinfo(cast(np.dtype[np.integer], dtype))
572
+ if limits.min >= 0:
573
+ return values / float(limits.max)
574
+ return (values - float(limits.min)) / float(limits.max - limits.min)
575
+
576
+
577
+ def _normalization_values(data: Mapping[str, object]) -> Normalization:
578
+ normalization = data.get("normalization")
579
+ if isinstance(normalization, Mapping):
580
+ normalization = cast(Mapping[str, object], normalization)
581
+ if "scheme" in normalization:
582
+ unknown = sorted(set(normalization).difference({"scheme", "percentiles", "eps"}))
583
+ if unknown:
584
+ raise ValueError(f"Unsupported preprocessing normalization keys: {unknown}.")
585
+ scheme = normalization.get("scheme")
586
+ percentiles = normalization.get("percentiles")
587
+ eps = normalization.get("eps", DEFAULT_NORMALIZATION_EPS)
588
+ mean = data.get("mean")
589
+ std = data.get("std")
590
+ else:
591
+ scheme = "none"
592
+ percentiles = None
593
+ eps = DEFAULT_NORMALIZATION_EPS
594
+ mean = normalization.get("mean", data.get("mean"))
595
+ std = normalization.get("std", data.get("std"))
596
+ elif normalization is None:
597
+ scheme = "none"
598
+ percentiles = None
599
+ eps = DEFAULT_NORMALIZATION_EPS
600
+ mean = data.get("mean")
601
+ std = data.get("std")
602
+ else:
603
+ raise TypeError("preprocessing normalization must be a mapping.")
604
+ canonical_mean = (
605
+ None if mean is None else float(cast(str | SupportsFloat | SupportsIndex, mean))
606
+ )
607
+ canonical_std = None if std is None else float(cast(str | SupportsFloat | SupportsIndex, std))
608
+ canonical_percentiles = (
609
+ None if percentiles is None else _validate_percentiles(cast(Sequence[float], percentiles))
610
+ )
611
+ canonical_eps = _validate_eps(eps)
612
+ _validate_normalization(
613
+ scheme=scheme,
614
+ percentiles=canonical_percentiles,
615
+ eps=canonical_eps,
616
+ mean=canonical_mean,
617
+ std=canonical_std,
618
+ )
619
+ return {
620
+ "scheme": cast(str, scheme),
621
+ "percentiles": canonical_percentiles,
622
+ "eps": canonical_eps,
623
+ "mean": canonical_mean,
624
+ "std": canonical_std,
625
+ }
626
+
627
+
628
+ def _validate_normalization(
629
+ *,
630
+ scheme: object,
631
+ percentiles: Sequence[float] | None,
632
+ eps: object,
633
+ mean: float | None,
634
+ std: float | None,
635
+ ) -> None:
636
+ if not isinstance(scheme, str) or scheme not in _NORMALIZATION_SCHEMES:
637
+ raise ValueError(
638
+ "preprocessing normalization scheme must be one of "
639
+ f"{sorted(_NORMALIZATION_SCHEMES)}; got {scheme!r}."
640
+ )
641
+ _validate_eps(eps)
642
+ if scheme == "percentile_minmax":
643
+ if percentiles is None:
644
+ raise ValueError("percentile_minmax preprocessing requires two percentiles.")
645
+ _validate_percentiles(percentiles)
646
+ elif percentiles is not None:
647
+ raise ValueError("preprocessing percentiles are valid only for percentile_minmax.")
648
+ if scheme != "none" and (mean is not None or std is not None):
649
+ raise ValueError(
650
+ "Per-sample preprocessing normalization cannot be combined with legacy scalar mean/std."
651
+ )
652
+ if mean is not None and not math.isfinite(mean):
653
+ raise ValueError("preprocessing mean must be finite.")
654
+ if std is not None and (not math.isfinite(std) or std <= 0.0):
655
+ raise ValueError("preprocessing std must be a positive finite number.")
656
+
657
+
658
+ def _validate_percentiles(values: object) -> tuple[float, float]:
659
+ if isinstance(values, str | bytes) or not isinstance(values, Sequence):
660
+ raise TypeError("preprocessing percentiles must be a two-value sequence.")
661
+ values = cast(Sequence[str | SupportsFloat | SupportsIndex], values)
662
+ if len(values) != 2:
663
+ raise ValueError("preprocessing percentiles must contain two values.")
664
+ if any(isinstance(value, bool) for value in values):
665
+ raise TypeError("preprocessing percentiles must contain two numbers.")
666
+ try:
667
+ lower, upper = float(values[0]), float(values[1])
668
+ except (TypeError, ValueError) as exc:
669
+ raise TypeError("preprocessing percentiles must contain two numbers.") from exc
670
+ if not math.isfinite(lower) or not math.isfinite(upper):
671
+ raise ValueError("preprocessing percentiles must be finite.")
672
+ if not 0.0 <= lower < upper <= 100.0:
673
+ raise ValueError("preprocessing percentiles must satisfy 0 <= lower < upper <= 100.")
674
+ return lower, upper
675
+
676
+
677
+ def _validate_eps(value: object) -> float:
678
+ if isinstance(value, bool) or not isinstance(value, int | float):
679
+ raise TypeError("preprocessing normalization eps must be a number.")
680
+ eps = float(value)
681
+ if not math.isfinite(eps) or eps <= 0.0:
682
+ raise ValueError("preprocessing normalization eps must be a positive finite number.")
683
+ return eps
684
+
685
+
686
+ def _percentile_minmax(
687
+ batch: FloatArray, *, percentiles: tuple[float, float], eps: float
688
+ ) -> FloatArray:
689
+ """Per-sample percentile clipping and scaling with ``torch.quantile`` semantics."""
690
+
691
+ batch_size = batch.shape[0]
692
+ flat = batch.reshape(batch_size, -1)
693
+ fractions = np.asarray([percentiles[0] / 100.0, percentiles[1] / 100.0], dtype=np.float32)
694
+ bounds = torch_quantile_linear(flat, fractions)
695
+ view_shape = (batch_size,) + (1,) * (batch.ndim - 1)
696
+ lower = bounds[0].reshape(view_shape)
697
+ upper = bounds[1].reshape(view_shape)
698
+ clipped = np.minimum(np.maximum(batch, lower), upper)
699
+ return (clipped - lower) / np.maximum(upper - lower, np.float32(eps))
700
+
701
+
702
+ def torch_quantile_linear(rows: FloatArray, fractions: FloatArray) -> FloatArray:
703
+ """Reproduce ``torch.quantile(rows, fractions, dim=1)`` for finite float32 rows.
704
+
705
+ Torch forms the ranks in float32 and interpolates with ``lerp``, whose CPU
706
+ kernel compiles to fused multiply-adds; this follows that arithmetic so the
707
+ result matches Torch's float32 CPU output on the recorded test inputs.
708
+ ``rows`` is ``(samples, values)`` and ``fractions`` are quantile levels in
709
+ [0, 1]. Returns ``(len(fractions), samples)``.
710
+ """
711
+
712
+ ordered = np.sort(rows, axis=1)
713
+ ranks = fractions.astype(np.float32) * np.float32(ordered.shape[1] - 1)
714
+ below = ranks.astype(np.int64)
715
+ weights = ranks - below.astype(np.float32)
716
+ above = np.ceil(ranks).astype(np.int64)
717
+ start = ordered[:, below]
718
+ end = ordered[:, above]
719
+ weight = np.broadcast_to(weights, start.shape)
720
+ small = np.abs(weight) < 0.5
721
+ diff = end - start
722
+ interpolated = np.where(
723
+ small,
724
+ fma32(weight, diff, start),
725
+ fma32(-diff, np.float32(1.0) - weight, end),
726
+ )
727
+ return np.ascontiguousarray(interpolated.T)
728
+
729
+
730
+ def _check_pixel_values(batch: FloatArray) -> FloatArray:
731
+ """Replace non-finite pixels with 0 and warn; also warn on a constant sample."""
732
+
733
+ finite = np.isfinite(batch)
734
+ if not bool(finite.all()):
735
+ warnings.warn(
736
+ f"Image contains {int((~finite).sum())} non-finite pixel values; "
737
+ "they were replaced with 0.",
738
+ RuntimeWarning,
739
+ stacklevel=2,
740
+ )
741
+ batch = np.nan_to_num(batch, nan=0.0, posinf=0.0, neginf=0.0)
742
+ flat = batch.reshape(batch.shape[0], -1)
743
+ if bool((flat.max(axis=1) == flat.min(axis=1)).any()):
744
+ warnings.warn(
745
+ "Image has no contrast (every pixel is identical).", RuntimeWarning, stacklevel=2
746
+ )
747
+ return batch
748
+
749
+
750
+ def _pad_to_square(image: Image.Image) -> Image.Image:
751
+ """Center the image on a zero-filled square; an odd remainder goes right/bottom."""
752
+
753
+ width, height = image.size
754
+ side = max(width, height)
755
+ if width == height:
756
+ return image
757
+ pad_left = (side - width) // 2
758
+ pad_right = side - width - pad_left
759
+ pad_top = (side - height) // 2
760
+ pad_bottom = side - height - pad_top
761
+ return ImageOps.expand(image, border=(pad_left, pad_top, pad_right, pad_bottom), fill=0)
762
+
763
+
764
+ # --- Probabilities (upstream probabilities.py and tta.py, Torch CPU arithmetic) ------
765
+
766
+
767
+ def fma32(
768
+ a: RealArray | np.floating | float,
769
+ b: RealArray | np.floating | float,
770
+ c: RealArray | np.floating | float,
771
+ ) -> FloatArray:
772
+ """Correctly rounded float32 ``a * b + c`` (one rounding), elementwise.
773
+
774
+ The float64 product of two float32 values is exact. The float64 sum can
775
+ round once more; when it lands exactly on a float32 rounding midpoint, the
776
+ TwoSum error term decides the direction, so the result equals a hardware FMA.
777
+ """
778
+
779
+ product = np.asarray(a, dtype=np.float32).astype(np.float64) * np.asarray(
780
+ b, dtype=np.float32
781
+ ).astype(np.float64)
782
+ addend = np.asarray(c, dtype=np.float32).astype(np.float64)
783
+ total = product + addend
784
+ virtual = total - product
785
+ error = (product - (total - virtual)) + (addend - virtual)
786
+ bits = np.asarray(total, dtype=np.float64).view(np.uint64)
787
+ midpoint = (bits & np.uint64(0x1FFFFFFF)) == np.uint64(0x10000000)
788
+ nudged = np.where(
789
+ midpoint & (error != 0.0), np.nextafter(total, np.copysign(np.inf, error)), total
790
+ )
791
+ return np.asarray(nudged).astype(np.float32)
792
+
793
+
794
+ _R_LN2 = np.float32(1.442695040888963407359924681001892137426645954152985934135449406931)
795
+ _L2U = np.float32(0.693145751953125)
796
+ _L2L = np.float32(1.428606765330187045e-06)
797
+ _EXP_COEFFICIENTS = tuple(
798
+ np.float32(value)
799
+ for value in (
800
+ 0.000198527617612853646278381,
801
+ 0.00139304355252534151077271,
802
+ 0.00833336077630519866943359,
803
+ 0.0416664853692054748535156,
804
+ 0.166666671633720397949219,
805
+ 0.5,
806
+ )
807
+ )
808
+
809
+
810
+ def sleef_expf(d: FloatArray) -> FloatArray:
811
+ """SLEEF ``expf`` (1.0-ULP, FMA) as used by Torch's vectorized CPU ``sigmoid``."""
812
+
813
+ d = np.asarray(d, dtype=np.float32)
814
+ with np.errstate(invalid="ignore", over="ignore"):
815
+ q = np.rint(d * _R_LN2).astype(np.int32)
816
+ qf = q.astype(np.float32)
817
+ s = fma32(qf, -_L2U, d)
818
+ s = fma32(qf, -_L2L, s)
819
+ u = np.full_like(d, _EXP_COEFFICIENTS[0])
820
+ for coefficient in _EXP_COEFFICIENTS[1:]:
821
+ u = fma32(u, s, coefficient)
822
+ u = np.float32(1.0) + fma32(s * s, u, s)
823
+ half = q >> 1
824
+ u = u * np.ldexp(np.float32(1.0), half).astype(np.float32)
825
+ u = u * np.ldexp(np.float32(1.0), q - half).astype(np.float32)
826
+ u = np.where(d < np.float32(-104.0), np.float32(0.0), u)
827
+ u = np.where(np.float32(100.0) < d, np.float32(np.inf), u)
828
+ return u.astype(np.float32)
829
+
830
+
831
+ def multilabel_probabilities(logits: FloatArray) -> FloatArray:
832
+ """``torch.sigmoid`` on float32 CPU logits: ``1 / (exp(0 - x) + 1)``."""
833
+
834
+ logits = np.asarray(logits, dtype=np.float32)
835
+ values = logits.reshape(-1)
836
+ probabilities = np.empty(logits.shape, dtype=np.float32)
837
+ output = probabilities.reshape(-1)
838
+ # Bound the FP64 FMA-emulation temporaries while retaining each FP32 rounding.
839
+ block_size = 16384
840
+ for start in range(0, values.size, block_size):
841
+ stop = start + block_size
842
+ exponent = sleef_expf(np.float32(0.0) - values[start:stop])
843
+ output[start:stop] = np.float32(1.0) / (exponent + np.float32(1.0))
844
+ return probabilities
845
+
846
+
847
+ def ensemble_probabilities(member_logits: Sequence[FloatArray]) -> FloatArray:
848
+ """Upstream ``_predict_with_tta`` with one pass: member probabilities, then their mean.
849
+
850
+ ``member_logits`` holds one float32 array per member, all the same shape
851
+ (here ``(1, 61, 256, 256)``). Returns the float32 mean of the per-member
852
+ sigmoid probabilities, in the same shape.
853
+
854
+ Raises:
855
+ ValueError: ``member_logits`` is empty.
856
+ """
857
+
858
+ if not member_logits:
859
+ raise ValueError("FleXray requires at least one ensemble member")
860
+ probabilities = multilabel_probabilities(member_logits[0])
861
+ if len(member_logits) == 1:
862
+ return probabilities
863
+ probability_sum = probabilities
864
+ for logits in member_logits[1:]:
865
+ probability_sum = probability_sum + multilabel_probabilities(logits)
866
+ return (probability_sum / np.float32(len(member_logits))).astype(np.float32)
867
+
868
+
869
+ # --- Source-grid mask export --------------------------------------------------------
870
+
871
+
872
+ def restore_mask(
873
+ probabilities: FloatArray,
874
+ *,
875
+ channel: int,
876
+ content_box: tuple[int, int, int, int],
877
+ source_size: tuple[int, int],
878
+ threshold: float,
879
+ ) -> Image.Image:
880
+ """Threshold on the model grid, then crop and restore the binary decisions.
881
+
882
+ Args:
883
+ probabilities: ``(61, 256, 256)`` float32 sigmoid probabilities on the
884
+ model grid.
885
+ channel: Index of the selected label in ``probabilities``.
886
+ content_box: ``(top, left, bottom, right)`` model-grid pixels, exclusive
887
+ bottom/right, from ``ImagePreprocessing.content_box``.
888
+ source_size: Source image ``(width, height)`` in pixels (Pillow order).
889
+ threshold: Pixels with probability ``>= threshold`` are foreground.
890
+
891
+ Returns:
892
+ A mode ``L`` image of size ``source_size`` holding 0/1 values. Thresholding
893
+ happens before the nearest-neighbor resize so that no new decision is
894
+ created by interpolating probabilities.
895
+ """
896
+
897
+ top, left, bottom, right = content_box
898
+ selected = (probabilities[channel, top:bottom, left:right] >= threshold).astype(np.uint8)
899
+ return Image.fromarray(selected).resize(source_size, resample=Image.Resampling.NEAREST)
900
+
901
+
902
+ # --- Runner ---------------------------------------------------------------------------
903
+
904
+
905
+ def _load_models(bundles: Sequence[Path]) -> tuple[Forward, ...]:
906
+ """Admit MLX once and load each member once, to share every augmented view."""
907
+ from medmlx_core.runtime import import_mlx
908
+ from safetensors import SafetensorError
909
+ from yaml import YAMLError
910
+
911
+ import_mlx()
912
+ from flexray_mlx.mlx_model import FleXrayMlxModel
913
+
914
+ models: list[Forward] = []
915
+ for bundle in bundles:
916
+ try:
917
+ model = FleXrayMlxModel(bundle)
918
+ except (
919
+ OSError,
920
+ KeyError,
921
+ TypeError,
922
+ AttributeError,
923
+ ValueError,
924
+ RuntimeError,
925
+ SafetensorError,
926
+ YAMLError,
927
+ ) as error:
928
+ raise AssetNotReadyError(
929
+ f"FleXray weights in {bundle} do not match the released network: {error}",
930
+ reason=f"incompatible {bundle}",
931
+ hint=_WEIGHTS_HINT,
932
+ ) from error
933
+ models.append(model_forward(model, bundle))
934
+ return tuple(models)
935
+
936
+
937
+ def model_forward(model: Forward, bundle: Path) -> Forward:
938
+ """Keep deferred tensor-key/architecture errors classified as incompatible assets."""
939
+
940
+ def forward(images: FloatArray) -> FloatArray:
941
+ try:
942
+ return model(images)
943
+ except (KeyError, ValueError) as error:
944
+ raise AssetNotReadyError(
945
+ f"FleXray weights in {bundle} do not match the released network: {error}",
946
+ reason=f"incompatible {bundle}",
947
+ hint=_WEIGHTS_HINT,
948
+ ) from error
949
+
950
+ return forward
951
+
952
+
953
+ def _predict(
954
+ bundles: Sequence[Path],
955
+ prepared: FloatArray,
956
+ *,
957
+ preprocessing: ImagePreprocessing,
958
+ settings: TtaSettings,
959
+ ) -> FloatArray:
960
+ """Run every member's MLX U-Net on every view; return ``61x256x256``."""
961
+ from flexray_mlx.tta import predict_with_tta
962
+
963
+ models = _load_models(bundles)
964
+ try:
965
+ _, probabilities = predict_with_tta(
966
+ models,
967
+ prepared,
968
+ tta_samples=settings.samples,
969
+ seed=settings.seed,
970
+ normalizer=preprocessing.normalize,
971
+ )
972
+ except (ValueError, RuntimeError) as error:
973
+ raise ModelExecutionError(f"FleXray inference failed: {error}") from error
974
+ return probabilities[0]
975
+
976
+
977
+ def _input_errors() -> tuple[type[BaseException], ...]:
978
+ errors: tuple[type[BaseException], ...] = (
979
+ EOFError,
980
+ OSError,
981
+ UnidentifiedImageError,
982
+ ValueError,
983
+ )
984
+ try:
985
+ from pydicom.errors import InvalidDicomError
986
+ except ImportError:
987
+ return errors
988
+ return (*errors, InvalidDicomError)
989
+
990
+
991
+ def _require_bool(name: str, value: object) -> bool:
992
+ if not isinstance(value, bool):
993
+ raise InvalidInputError(f"FleXray {name} must be true or false, got {value!r}")
994
+ return value
995
+
996
+
997
+ def run(
998
+ *,
999
+ image_path: str | Path,
1000
+ weights_path: str | Path,
1001
+ output_dir: str | Path,
1002
+ label_name: str = "lungs",
1003
+ ensemble: bool = False,
1004
+ threshold: float = 0.5,
1005
+ tta_samples: int = 1,
1006
+ tta_seed: int | None = None,
1007
+ normalization_scheme: str | None = None,
1008
+ overwrite: bool = False,
1009
+ ) -> RunResult:
1010
+ """Segment one conventional 2D radiograph with the released FleXray network.
1011
+
1012
+ The model natively predicts on a 256x256 grid after zero-padding the image
1013
+ to a square. All 61 independent sigmoid channels (background plus 60
1014
+ structures) are saved on that padded grid. The selected structure's binary
1015
+ mask is thresholded on the model grid, cropped, and resized with nearest neighbor,
1016
+ so it keeps the source image's pixel dimensions.
1017
+
1018
+ Args:
1019
+ image_path: One radiograph: a raster image Pillow reads (PNG, TIFF, ...)
1020
+ or a single-frame 2D DICOM file.
1021
+ weights_path: The FleXray release snapshot directory with
1022
+ ``ensemble.json`` and ``members/<name>/`` bundles
1023
+ (``config.yml``, ``model.safetensors``, ``label_schema.json``,
1024
+ ``preprocessing.json``).
1025
+ output_dir: Directory for the outputs; created if missing.
1026
+ label_name: Structure to threshold into the mask, e.g. ``lungs``,
1027
+ ``heart``, ``rib_5`` or ``background`` (any name in the label schema).
1028
+ ensemble: Average the probabilities of all five released members
1029
+ instead of running only the ``flux0375`` flagship.
1030
+ threshold: Probability cutoff in [0, 1] for the selected structure; pixels
1031
+ at or above it are foreground.
1032
+ tta_samples: Total passes per member; 0 or 1 means one plain pass.
1033
+ tta_seed: Integer seed for the isolated stream that reproduces Torch CPU
1034
+ augmentation draws; None draws fresh views, as in fxr.
1035
+ normalization_scheme: Override the bundle's normalization with ``none``,
1036
+ ``minmax``, ``percentile_minmax`` or ``standardize``. None keeps the
1037
+ bundle contract. Standardize uses sample std and adds bundle epsilon.
1038
+ overwrite: Replace existing outputs instead of refusing. Output paths
1039
+ must remain separate from the input radiograph and its file aliases.
1040
+
1041
+ Returns:
1042
+ ``outputs`` maps ``mask`` to ``source_image_label.png`` (uint8 0/1 on the
1043
+ source pixel grid), ``display_source`` to ``source_image.png`` (the 8-bit
1044
+ grayscale source), ``probabilities`` to ``probabilities.npy`` (float32
1045
+ ``61x256x256`` on the padded model grid) and ``prediction`` to
1046
+ ``prediction.json``. ``details`` holds the selected label and channel,
1047
+ threshold, members, TTA settings, normalization scheme, source size as
1048
+ ``[width, height]``, the content box as ``[top, left, bottom, right]`` in
1049
+ model-grid pixels (exclusive bottom/right) and the mask pixel count.
1050
+
1051
+ Raises:
1052
+ InvalidInputError: Missing or unreadable input, a non-2D DICOM, an
1053
+ unknown label, an out-of-range threshold, invalid TTA or
1054
+ normalization settings, outputs that alias the input, or existing
1055
+ outputs without ``overwrite``.
1056
+ AssetNotReadyError: Missing, unpinned, corrupt or incompatible weights.
1057
+ MissingDependencyError: MLX or pydicom is unavailable.
1058
+ ModelExecutionError: Inference failed, or the network returned
1059
+ non-finite or wrongly shaped probabilities. Raised before any
1060
+ output file is written.
1061
+ """
1062
+
1063
+ source_path = Path(image_path)
1064
+ if not source_path.is_file():
1065
+ raise InvalidInputError(f"Missing FleXray input radiograph: {source_path}")
1066
+ source_path = source_path.resolve()
1067
+ if not isinstance(cast(object, label_name), str):
1068
+ raise InvalidInputError(f"FleXray label_name must be a string, got {label_name!r}")
1069
+ ensemble = _require_bool("ensemble", ensemble)
1070
+ overwrite = _require_bool("overwrite", overwrite)
1071
+ if (
1072
+ isinstance(threshold, bool)
1073
+ or not isinstance(cast(object, threshold), int | float)
1074
+ or not 0.0 <= threshold <= 1.0
1075
+ ):
1076
+ raise InvalidInputError("FleXray threshold must be between 0 and 1")
1077
+ threshold = float(threshold)
1078
+ try:
1079
+ settings = TtaSettings(tta_samples, tta_seed)
1080
+ except ValueError as error:
1081
+ raise InvalidInputError(str(error)) from error
1082
+ if normalization_scheme is not None and (
1083
+ not isinstance(cast(object, normalization_scheme), str)
1084
+ or normalization_scheme not in _NORMALIZATION_SCHEMES
1085
+ ):
1086
+ raise InvalidInputError(
1087
+ f"normalization_scheme must be one of {sorted(_NORMALIZATION_SCHEMES)}, or None"
1088
+ )
1089
+ output = Path(output_dir).resolve()
1090
+ names = (DISPLAY_NAME, MASK_NAME, PROBABILITIES_NAME, PREDICTION_NAME)
1091
+ destinations = tuple(output / name for name in names)
1092
+ # resolve() catches symlink aliases and samefile() catches hard links.
1093
+ if any(
1094
+ path.resolve() == source_path or (path.exists() and path.samefile(source_path))
1095
+ for path in destinations
1096
+ ):
1097
+ raise InvalidInputError("FleXray outputs must be separate from the input radiograph")
1098
+ existing = [path.as_posix() for path in destinations if path.exists()]
1099
+ if existing and not overwrite:
1100
+ raise InvalidInputError(
1101
+ f"FleXray outputs already exist: {', '.join(existing)}; "
1102
+ "pass overwrite=True to replace them"
1103
+ )
1104
+
1105
+ admitted = admit_bundle(Path(weights_path).resolve(), ensemble=ensemble)
1106
+ labels = admitted.labels
1107
+ if label_name not in labels:
1108
+ raise InvalidInputError(f"Unknown FleXray anatomy label: {label_name}")
1109
+ try:
1110
+ source = display_image(source_path)
1111
+ except InvalidInputError:
1112
+ # InvalidInputError is a ValueError; do not re-wrap it as unreadable input.
1113
+ raise
1114
+ except _input_errors() as error:
1115
+ raise InvalidInputError(f"Unreadable FleXray radiograph {source_path}: {error}") from error
1116
+ metadata = _read_json(admitted.bundles[0] / "preprocessing.json")
1117
+ try:
1118
+ preprocessing = ImagePreprocessing.from_metadata(cast(Mapping[str, object], metadata))
1119
+ except (TypeError, ValueError) as error:
1120
+ raise AssetNotReadyError(
1121
+ f"Unsupported FleXray preprocessing.json: {error}",
1122
+ reason="unsupported preprocessing.json",
1123
+ hint=_WEIGHTS_HINT,
1124
+ ) from error
1125
+ if normalization_scheme is not None:
1126
+ preprocessing = replace(
1127
+ preprocessing,
1128
+ normalization_scheme=normalization_scheme,
1129
+ mean=None,
1130
+ std=None,
1131
+ percentiles=(
1132
+ preprocessing.percentiles or DEFAULT_NORMALIZATION_PERCENTILES
1133
+ if normalization_scheme == "percentile_minmax"
1134
+ else None
1135
+ ),
1136
+ )
1137
+ try:
1138
+ prepared = preprocessing.prepare(source_path)
1139
+ except _input_errors() as error:
1140
+ raise InvalidInputError(f"Unreadable FleXray radiograph {source_path}: {error}") from error
1141
+ probabilities = _predict(
1142
+ admitted.bundles, prepared, preprocessing=preprocessing, settings=settings
1143
+ )
1144
+ if probabilities.shape != (CHANNELS, *MODEL_GRID) or not np.isfinite(probabilities).all():
1145
+ raise ModelExecutionError("FleXray returned invalid full-channel probabilities")
1146
+ channel = labels.index(label_name)
1147
+ box = preprocessing.content_box(source.height, source.width)
1148
+ mask = restore_mask(
1149
+ probabilities,
1150
+ channel=channel,
1151
+ content_box=box,
1152
+ source_size=source.size,
1153
+ threshold=threshold,
1154
+ )
1155
+
1156
+ output.mkdir(parents=True, exist_ok=True)
1157
+ display_path = output / DISPLAY_NAME
1158
+ mask_path = output / MASK_NAME
1159
+ probabilities_path = output / PROBABILITIES_NAME
1160
+ prediction_path = output / PREDICTION_NAME
1161
+ source.save(display_path)
1162
+ mask.save(mask_path)
1163
+ np.save(probabilities_path, probabilities, allow_pickle=False)
1164
+ members = [bundle.name for bundle in admitted.bundles]
1165
+ record = {
1166
+ "schema_version": PREDICTION_SCHEMA,
1167
+ "source_path": source_path.as_posix(),
1168
+ "source_size": [source.width, source.height],
1169
+ "model_grid": list(MODEL_GRID),
1170
+ "content_box_tlbr": list(box),
1171
+ "label_names": list(labels),
1172
+ "selected_label": label_name,
1173
+ "selected_channel": channel,
1174
+ "threshold": threshold,
1175
+ "tta_samples": settings.resolved_samples,
1176
+ "tta_seed": settings.seed,
1177
+ "normalization_scheme": preprocessing.normalization_scheme,
1178
+ "ensemble": ensemble,
1179
+ "members": members,
1180
+ "backend": "mlx",
1181
+ "probability_mode": "multilabel",
1182
+ "overlay_is_source_grid": True,
1183
+ }
1184
+ prediction_path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
1185
+ return {
1186
+ "outputs": {
1187
+ "mask": mask_path.as_posix(),
1188
+ "display_source": display_path.as_posix(),
1189
+ "probabilities": probabilities_path.as_posix(),
1190
+ "prediction": prediction_path.as_posix(),
1191
+ },
1192
+ "details": {
1193
+ "selected_label": label_name,
1194
+ "selected_channel": channel,
1195
+ "threshold": threshold,
1196
+ "ensemble": ensemble,
1197
+ "members": members,
1198
+ "tta_samples": settings.resolved_samples,
1199
+ "tta_seed": settings.seed,
1200
+ "normalization_scheme": preprocessing.normalization_scheme,
1201
+ "source_size": [source.width, source.height],
1202
+ "model_grid": list(MODEL_GRID),
1203
+ "content_box_tlbr": list(box),
1204
+ "mask_pixels": int(np.count_nonzero(np.asarray(mask))),
1205
+ "probability_mode": "multilabel",
1206
+ "backend": "mlx",
1207
+ },
1208
+ }