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/__init__.py +21 -0
- flexray_mlx/_kernels.py +98 -0
- flexray_mlx/_random.py +91 -0
- flexray_mlx/_types.py +126 -0
- flexray_mlx/medmlx.py +1208 -0
- flexray_mlx/mlx_model.py +168 -0
- flexray_mlx/py.typed +0 -0
- flexray_mlx/tta.py +248 -0
- flexray_mlx-0.2.1.dist-info/METADATA +69 -0
- flexray_mlx-0.2.1.dist-info/RECORD +14 -0
- flexray_mlx-0.2.1.dist-info/WHEEL +4 -0
- flexray_mlx-0.2.1.dist-info/entry_points.txt +2 -0
- flexray_mlx-0.2.1.dist-info/licenses/LICENSE +202 -0
- flexray_mlx-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
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
|
+
}
|