flexray-mlx 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flexray-mlx
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Native MLX implementation of FleXray
5
+ Project-URL: Homepage, https://huggingface.co/MedMLX/flexray-mlx
5
6
  Author: Joseph Sandoval, Alfredo Sandoval Jr.
6
7
  License-Expression: Apache-2.0 AND MIT AND BSD-3-Clause
7
8
  License-File: LICENSE
8
9
  License-File: THIRD_PARTY_NOTICES.md
9
10
  Classifier: Operating System :: MacOS :: MacOS X
10
11
  Requires-Python: <3.15,>=3.13
11
- Requires-Dist: medmlx-core==0.2.4
12
+ Requires-Dist: medmlx-core==0.2.5
12
13
  Requires-Dist: mlx<1,>=0.32.4
13
14
  Requires-Dist: numpy<3,>=2.0
14
15
  Requires-Dist: pillow<14,>=13
@@ -25,11 +26,7 @@ Description-Content-Type: text/markdown
25
26
 
26
27
  Segment anatomical structures in radiographs with FleXray and native MLX.
27
28
 
28
- [Docs](docs/technical.md) · [MedMLX](https://github.com/MedMLX)
29
-
30
- ![One chest radiograph → separate lung, heart and individual rib masks.](https://raw.githubusercontent.com/MedMLX/flexray-mlx/main/docs/showcase.png)
31
-
32
- *One chest radiograph → separate lung, heart and individual rib masks.*
29
+ [Model weights](https://huggingface.co/MedMLX/flexray-mlx)
33
30
 
34
31
  | Input | Output |
35
32
  | :--- | :--- |
@@ -42,28 +39,27 @@ Segment anatomical structures in radiographs with FleXray and native MLX.
42
39
  ```bash
43
40
  uv venv --python 3.13
44
41
  source .venv/bin/activate
45
- repo="https://github.com/MedMLX/flexray-mlx"
46
- uv pip install \
47
- "flexray-mlx @ git+$repo" \
48
- huggingface-hub
42
+ uv pip install "flexray-mlx>=0.2.3"
49
43
  ```
50
44
 
51
- Download the released weights and run:
52
-
53
45
  ```python
54
- from huggingface_hub import snapshot_download
55
46
  from flexray_mlx.medmlx import run
56
47
 
57
- weights = snapshot_download("VictorButoi/flexray", local_dir="flexray", ignore_patterns=["*.onnx"])
58
-
59
- result = run(image_path="chest.png", weights_path=weights, output_dir="out")
48
+ result = run(image_path="chest.png", output_dir="out")
60
49
  print(result["outputs"]["mask"])
61
50
  ```
62
51
 
63
- Use `label_name` to select a structure and `ensemble=True` to average all five members.
52
+ Omitting `weights_path` downloads `MedMLX/flexray-mlx` once and reuses its local cache.
53
+ An explicit `weights_path` selects your own model folder, containing `ensemble.json`
54
+ and `members/flux0375/{config.yml,label_schema.json,model.safetensors,preprocessing.json}`.
55
+ Use `label_name` to select a structure. Automatic setup prepares the flagship
56
+ (`ensemble=False`); `ensemble=True` requires all five members in an explicit folder.
57
+
58
+ Downloaded commits are returned in `details.weights_revision`; explicit local
59
+ weights report `null` unless the folder carries a MedMLX download revision.
64
60
 
65
61
  ## License
66
62
 
67
- [Apache-2.0](LICENSE). See [third-party notices](THIRD_PARTY_NOTICES.md). Model weights retain their publisher terms. FleXray weights are noncommercial (CC-BY-NC-4.0).
63
+ Apache-2.0; license and third-party notices are included in the distribution. Model weights retain their publisher terms. FleXray weights are noncommercial (CC-BY-NC-4.0).
68
64
 
69
65
  For research and education. Outputs require human review.
@@ -0,0 +1,41 @@
1
+ # flexray-mlx
2
+
3
+ Segment anatomical structures in radiographs with FleXray and native MLX.
4
+
5
+ [Model weights](https://huggingface.co/MedMLX/flexray-mlx)
6
+
7
+ | Input | Output |
8
+ | :--- | :--- |
9
+ | Raster image or single-frame DICOM | Structure masks and probabilities |
10
+
11
+ **Apple Silicon · Metal · Python 3.13 / 3.14** · [uv](https://docs.astral.sh/uv/)
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ uv venv --python 3.13
17
+ source .venv/bin/activate
18
+ uv pip install "flexray-mlx>=0.2.3"
19
+ ```
20
+
21
+ ```python
22
+ from flexray_mlx.medmlx import run
23
+
24
+ result = run(image_path="chest.png", output_dir="out")
25
+ print(result["outputs"]["mask"])
26
+ ```
27
+
28
+ Omitting `weights_path` downloads `MedMLX/flexray-mlx` once and reuses its local cache.
29
+ An explicit `weights_path` selects your own model folder, containing `ensemble.json`
30
+ and `members/flux0375/{config.yml,label_schema.json,model.safetensors,preprocessing.json}`.
31
+ Use `label_name` to select a structure. Automatic setup prepares the flagship
32
+ (`ensemble=False`); `ensemble=True` requires all five members in an explicit folder.
33
+
34
+ Downloaded commits are returned in `details.weights_revision`; explicit local
35
+ weights report `null` unless the folder carries a MedMLX download revision.
36
+
37
+ ## License
38
+
39
+ Apache-2.0; license and third-party notices are included in the distribution. Model weights retain their publisher terms. FleXray weights are noncommercial (CC-BY-NC-4.0).
40
+
41
+ For research and education. Outputs require human review.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flexray-mlx"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Native MLX implementation of FleXray"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -13,7 +13,7 @@ license = "Apache-2.0 AND MIT AND BSD-3-Clause"
13
13
  license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
14
14
  authors = [{ name = "Joseph Sandoval" }, { name = "Alfredo Sandoval Jr." }]
15
15
  dependencies = [
16
- "medmlx-core==0.2.4",
16
+ "medmlx-core==0.2.5",
17
17
  "mlx>=0.32.4,<1",
18
18
  "numpy>=2.0,<3",
19
19
  "pillow>=13,<14",
@@ -22,6 +22,9 @@ dependencies = [
22
22
  "safetensors>=0.6,<1",
23
23
  ]
24
24
 
25
+ [project.urls]
26
+ Homepage = "https://huggingface.co/MedMLX/flexray-mlx"
27
+
25
28
  [project.entry-points."medmlx.models"]
26
29
  flexray = "flexray_mlx.medmlx:MODEL"
27
30
 
@@ -56,7 +59,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
56
59
 
57
60
  [tool.pyright]
58
61
  include = ["scripts","src","tests"]
59
- extraPaths = ["src", "tests"]
62
+ extraPaths = ["src", "tests", "scripts"]
60
63
  pythonVersion = "3.13"
61
64
  typeCheckingMode = "strict"
62
65
  enableTypeIgnoreComments = false
@@ -66,11 +69,8 @@ reportUnnecessaryTypeIgnoreComment = "error"
66
69
  ban-relative-imports = "all"
67
70
 
68
71
  [tool.ruff.lint.flake8-tidy-imports.banned-api]
69
- "typing.Any" = {msg = "Use a precise type and parse untyped values at the boundary."}
70
- "typing_extensions.Any" = {msg = "Use a precise type and parse untyped values at the boundary."}
71
-
72
- [tool.uv.sources]
73
- medmlx-core = { git = "https://github.com/MedMLX/medmlx.git", rev = "f98e0924f50228e2b9509474947b9672054b3217", subdirectory = "packages/core" }
72
+ "typing.Any" = { msg = "Use a precise type at the boundary." }
73
+ "typing_extensions.Any" = { msg = "Use a precise type at the boundary." }
74
74
 
75
75
  [tool.hatch.build.targets.sdist]
76
76
  only-include = [
@@ -9,11 +9,12 @@ if TYPE_CHECKING:
9
9
 
10
10
  __all__ = ["FleXrayMlxModel"]
11
11
 
12
- __version__ = "0.2.2"
12
+ __version__ = "0.2.3"
13
13
 
14
14
 
15
15
  def __getattr__(name: str) -> object:
16
16
  # MLX loads with the network, so importing the medmlx declaration stays light.
17
+ """Load the native model lazily when its public name is requested."""
17
18
  if name == "FleXrayMlxModel":
18
19
  from flexray_mlx.mlx_model import FleXrayMlxModel
19
20
 
@@ -48,6 +48,7 @@ out[i] = ((1.0f - fy) * (1.0f - fx)) * a
48
48
 
49
49
  @lru_cache(maxsize=1)
50
50
  def _resize_concat_kernel(mx: NativeRuntime) -> MetalKernel:
51
+ """Compile and cache the Metal kernel for corner-aligned resize and concatenation."""
51
52
  return cast(
52
53
  MetalKernel,
53
54
  mx.fast.metal_kernel(
@@ -31,9 +31,11 @@ class TorchRandom:
31
31
  self._next_normal: float | None = None
32
32
 
33
33
  def _words(self, count: int) -> UIntArray:
34
+ """Draw raw 32-bit words from the isolated MT19937 stream."""
34
35
  return self._engine.randint(0, 2**32, size=count, dtype=np.uint32)
35
36
 
36
37
  def _units(self, count: int) -> FloatArray:
38
+ """Convert raw words to Torch-compatible float32 unit uniforms."""
37
39
  return (self._words(count) & np.uint32(0xFFFFFF)).astype(np.float32) * np.float32(2**-24)
38
40
 
39
41
  def uniform(self, low: float = 0.0, high: float = 1.0) -> float:
@@ -49,11 +51,13 @@ class TorchRandom:
49
51
  return self.uniform() < np.float32(probability)
50
52
 
51
53
  def _double_unit(self) -> float:
54
+ """Combine two words into a 53-bit unit uniform."""
52
55
  words = self._words(2).astype(np.uint64)
53
56
  value = (int(words[0]) << 32) | int(words[1])
54
57
  return (value & (2**53 - 1)) * 2**-53
55
58
 
56
59
  def _short_normal(self) -> float:
60
+ """One double-precision Box-Muller normal; the pair's sine half is cached."""
57
61
  if self._next_normal is not None:
58
62
  value, self._next_normal = self._next_normal, None
59
63
  return value
@@ -64,6 +68,7 @@ class TorchRandom:
64
68
 
65
69
  @staticmethod
66
70
  def _normal_blocks(uniforms: FloatArray) -> FloatArray:
71
+ """Convert groups of 16 uniforms to Torch-compatible normal draws."""
67
72
  blocks = uniforms.reshape(-1, 16)
68
73
  radius = np.sqrt(np.float32(-2.0) * np.log(np.float32(1.0) - blocks[:, :8]))
69
74
  # The scalar CPU kernel multiplies by double pi before rounding theta.
@@ -2,16 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Sequence
6
- from os import PathLike
7
- from typing import TYPE_CHECKING, Literal, Protocol, TypedDict
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Literal, NotRequired, Protocol, TypedDict
8
7
 
9
8
  import numpy as np
10
- from medmlx_core.typing import FastRuntime
9
+ from medmlx_core.typing import MlxRuntime
11
10
  from numpy.typing import NDArray
12
11
 
13
12
  if TYPE_CHECKING:
14
- from mlx.core import Dtype, array
13
+ from mlx.core import StreamOrDevice, array
15
14
 
16
15
  type HostArray = NDArray[np.generic]
17
16
  type RealArray = NDArray[np.bool_ | np.integer | np.floating]
@@ -24,52 +23,69 @@ type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValu
24
23
  type JsonObject = dict[str, JsonValue]
25
24
 
26
25
 
27
- class NativeRuntime(Protocol):
28
- fast: FastRuntime
29
- float32: Dtype
30
-
31
- # Constructor inputs here are the NumPy arrays prepared by this network.
32
- def array(self, value: FloatArray) -> array: ...
33
- def eval(self, *args: array) -> None: ...
34
- def conv2d(self, input: array, weight: array, /, *, padding: int) -> array: ...
35
- def mean(self, a: array, /, *, axis: Sequence[int], keepdims: bool) -> array: ...
36
- def square(self, a: array, /) -> array: ...
37
- def rsqrt(self, a: array, /) -> array: ...
38
- def maximum(self, a: array, b: array, /) -> array: ...
39
- def concatenate(self, arrays: list[array], *, axis: int) -> array: ...
26
+ class NativeRuntime(MlxRuntime, Protocol):
27
+ def square(self, x: array, /, *, stream: StreamOrDevice = None) -> array:
28
+ """Square an MLX array elementwise."""
29
+ ...
40
30
 
41
31
 
42
32
  class NativeOperation(Protocol):
43
- def __call__(self, x: array) -> array: ...
33
+ def __call__(self, x: array) -> array:
34
+ """Apply a native operation to an MLX array."""
35
+ ...
44
36
 
45
37
 
46
38
  class NeuralRuntime(Protocol):
47
- def MaxPool2d(self, *, kernel_size: int, stride: int) -> NativeOperation: ...
48
- def Upsample(self, *, scale_factor: int, mode: str, align_corners: bool) -> NativeOperation: ...
39
+ def MaxPool2d(
40
+ self, kernel_size: int | tuple[int, int], stride: int | tuple[int, int] | None = None,
41
+ padding: int | tuple[int, int] | None = 0,
42
+ ) -> NativeOperation:
43
+ """Construct a two-dimensional MLX max-pooling layer."""
44
+ ...
49
45
 
50
46
 
51
47
  class TensorReader(Protocol):
52
- def __enter__(self) -> TensorReader: ...
53
- def __exit__(self, *args: object) -> None: ...
54
- def keys(self) -> list[str]: ...
55
- def get_tensor(self, name: str) -> HostArray: ...
48
+ def __enter__(self) -> TensorReader:
49
+ """Open the tensor-reader context."""
50
+ ...
51
+ def __exit__(self, *args: object) -> None:
52
+ """Close the tensor-reader context."""
53
+ ...
54
+ def keys(self) -> list[str]:
55
+ """List the named tensors in the checkpoint."""
56
+ ...
57
+ def get_tensor(self, name: str) -> HostArray:
58
+ """Read one named tensor as a host array."""
59
+ ...
56
60
 
57
61
 
58
62
  class SafetensorsRuntime(Protocol):
59
- def safe_open(self, filename: PathLike[str], *, framework: Literal["np"]) -> TensorReader: ...
63
+ def safe_open(self, filename: str | Path, framework: Literal["np"]) -> TensorReader:
64
+ """Open named safetensors as NumPy arrays."""
65
+ ...
60
66
 
61
67
 
62
68
  class DicomDataset(Protocol):
63
69
  pixel_array: RealArray
64
70
 
65
- def __contains__(self, name: str) -> bool: ...
71
+ def __contains__(self, name: str) -> bool:
72
+ """Return whether the dataset contains a named DICOM element."""
73
+ ...
66
74
 
67
75
 
68
76
  class DicomRuntime(Protocol):
69
- def dcmread(self, fp: str, *, force: bool) -> DicomDataset: ...
77
+ def dcmread(self, fp: str, *, force: bool = False) -> DicomDataset:
78
+ """Read one DICOM dataset from a file path."""
79
+ ...
70
80
 
71
81
 
72
82
  class Normalization(TypedDict):
83
+ """Normalization settings parsed from ``preprocessing.json``.
84
+
85
+ ``percentiles`` applies only to ``percentile_minmax``; ``mean``/``std`` are the
86
+ legacy scalar form and exclude the per-sample schemes.
87
+ """
88
+
73
89
  scheme: str
74
90
  percentiles: tuple[float, float] | None
75
91
  eps: float
@@ -103,6 +119,7 @@ class RunDetails(TypedDict):
103
119
  in model-grid pixels with exclusive bottom/right.
104
120
  """
105
121
 
122
+ weights_revision: NotRequired[str | None]
106
123
  selected_label: str
107
124
  selected_channel: int
108
125
  threshold: float
@@ -14,6 +14,7 @@ import math
14
14
  import warnings
15
15
  from collections.abc import Mapping, Sequence
16
16
  from dataclasses import dataclass, replace
17
+ from itertools import combinations
17
18
  from pathlib import Path
18
19
  from typing import TYPE_CHECKING, SupportsFloat, SupportsIndex, SupportsInt, cast
19
20
 
@@ -24,6 +25,7 @@ from medmlx_core.errors import (
24
25
  MissingDependencyError,
25
26
  ModelExecutionError,
26
27
  )
28
+ from medmlx_core.weights import weights_dir, weights_revision
27
29
  from PIL import Image, ImageOps, UnidentifiedImageError
28
30
 
29
31
  from flexray_mlx._types import FloatArray, JsonValue, RealArray, RunResult
@@ -91,12 +93,14 @@ class AdmittedBundle:
91
93
 
92
94
 
93
95
  def _missing(path: Path) -> AssetNotReadyError:
96
+ """Construct the standard missing-model-asset error for a path."""
94
97
  return AssetNotReadyError(
95
98
  f"Missing FleXray asset: {path}", reason=f"missing {path}", hint=_WEIGHTS_HINT
96
99
  )
97
100
 
98
101
 
99
102
  def _read_json(path: Path) -> JsonValue:
103
+ """Read a JSON model asset and classify missing or malformed files."""
100
104
  if not path.is_file():
101
105
  raise _missing(path)
102
106
  try:
@@ -114,7 +118,8 @@ def admit_bundle(root: Path, *, ensemble: bool) -> AdmittedBundle:
114
118
 
115
119
  Only the members that will run are inspected: the flagship alone, or all five
116
120
  when ``ensemble`` is true. Tensor contents are not read here; the network
117
- checks them when it loads.
121
+ loads them and validates tensor use during inference. These checks do not
122
+ establish the identity or origin of the bundle or checkpoint.
118
123
 
119
124
  Raises:
120
125
  AssetNotReadyError: A file is missing or unreadable, the ensemble layout
@@ -181,6 +186,7 @@ def admit_bundle(root: Path, *, ensemble: bool) -> AdmittedBundle:
181
186
 
182
187
 
183
188
  def _pydicom() -> DicomRuntime:
189
+ """Import the DICOM reader or raise an actionable dependency error."""
184
190
  try:
185
191
  import pydicom
186
192
  except ImportError as error:
@@ -210,9 +216,11 @@ def is_dicom_path(path: str | Path) -> bool:
210
216
  def load_dicom_grayscale(path: str | Path) -> FloatArray:
211
217
  """Read one single-frame DICOM into a 2D float32 image min-max scaled to [0, 1].
212
218
 
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].
219
+ Pixels are rescaled with ``RescaleSlope``/``RescaleIntercept`` when present
220
+ (a false-valued slope is treated as 1), RGB(A) is reduced to luma, and
221
+ MONOCHROME1 is inverted. No VOI LUT or windowing is applied, so the full
222
+ stored intensity range maps to [0, 1]. Declared multiple frames are rejected;
223
+ without a frame count a 3D array with last dimension 3 or 4 is treated as color.
216
224
 
217
225
  Raises:
218
226
  ValueError: The file has no pixel data or is not a single-frame 2D image.
@@ -221,6 +229,8 @@ def load_dicom_grayscale(path: str | Path) -> FloatArray:
221
229
  dataset = _pydicom().dcmread(str(path), force=True)
222
230
  if "PixelData" not in dataset:
223
231
  raise ValueError(f"DICOM file has no pixel data: {path}.")
232
+ if int(getattr(dataset, "NumberOfFrames", 1) or 1) > 1:
233
+ raise ValueError(f"Only single-frame 2D DICOM images are supported: {path}.")
224
234
  pixels: RealArray = np.asarray(dataset.pixel_array)
225
235
  if pixels.ndim == 3 and pixels.shape[-1] in {3, 4}:
226
236
  rgb = pixels[..., :3].astype(np.float64, copy=False)
@@ -339,6 +349,7 @@ class ImagePreprocessing:
339
349
  eps: float = DEFAULT_NORMALIZATION_EPS
340
350
 
341
351
  def __post_init__(self) -> None:
352
+ """Validate image geometry, scaling, and normalization settings."""
342
353
  _validate_image_size(self.image_size)
343
354
  if self.color_mode != "grayscale":
344
355
  raise ValueError(
@@ -391,10 +402,11 @@ class ImagePreprocessing:
391
402
  def content_box(self, height: int, width: int) -> tuple[int, int, int, int]:
392
403
  """``(top, left, bottom, right)`` of the input footprint on the prepared canvas.
393
404
 
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.
405
+ Coordinates are model-grid pixels with exclusive ``bottom`` and ``right``.
406
+ The box approximates the resized source footprint, not a guarantee that
407
+ outside pixels are zero: bilinear interpolation can mix the boundary and
408
+ normalization can change padding values. ``height`` and ``width`` are
409
+ the source image size in pixels.
398
410
 
399
411
  Raises:
400
412
  ValueError: ``height`` or ``width`` is not positive.
@@ -418,8 +430,10 @@ class ImagePreprocessing:
418
430
  def prepare(self, path: str | Path) -> FloatArray:
419
431
  """Load one image or DICOM file as a normalized float32 ``1x1xHxW`` array.
420
432
 
421
- ``H`` and ``W`` are ``image_size``. Non-finite pixels become 0 with a
422
- ``RuntimeWarning``; a constant image also warns.
433
+ ``H`` and ``W`` are ``image_size``. Non-finite values that reach the
434
+ preprocessing check become 0 with a ``RuntimeWarning``; a constant image
435
+ also warns. DICOM min-max scaling occurs first and may turn non-finite
436
+ decoded values into a constant zero image before that check.
423
437
  """
424
438
 
425
439
  image = _load_image_file(path)
@@ -428,11 +442,13 @@ class ImagePreprocessing:
428
442
  return self._prepare_array(image)
429
443
 
430
444
  def _prepare_pillow(self, image: Image.Image) -> FloatArray:
445
+ """Convert a raster image to grayscale and prepare it for inference."""
431
446
  array = _grayscale_pixels(image)
432
447
  return self._prepare_grayscale_array(array, source_dtype=array.dtype)
433
448
 
434
449
  def _prepare_array(self, array: FloatArray) -> FloatArray:
435
450
  # File inputs reach this only as decoded 2D DICOM pixels.
451
+ """Validate decoded DICOM pixels and prepare the grayscale array."""
436
452
  np_array = np.asarray(array)
437
453
  if np_array.size == 0:
438
454
  raise ValueError("Image inputs must be non-empty.")
@@ -443,6 +459,7 @@ class ImagePreprocessing:
443
459
  def _prepare_grayscale_array(
444
460
  self, array: RealArray, *, source_dtype: np.dtype[np.generic]
445
461
  ) -> FloatArray:
462
+ """Scale, pad, resize, and normalize one grayscale image."""
446
463
  if array.size == 0:
447
464
  raise ValueError("Image inputs must be non-empty.")
448
465
  if self.scale == "zero_one":
@@ -495,6 +512,7 @@ class ImagePreprocessing:
495
512
 
496
513
 
497
514
  def _load_image_file(path: str | Path) -> Image.Image | FloatArray:
515
+ """Load DICOM pixels or a detached Pillow raster image."""
498
516
  if is_dicom_path(path):
499
517
  return load_dicom_grayscale(path)
500
518
  with Image.open(path) as opened:
@@ -544,6 +562,7 @@ def _standardize_mean(batch: FloatArray) -> FloatArray:
544
562
 
545
563
 
546
564
  def _validate_image_size(size: Sequence[SupportsInt]) -> tuple[int, int]:
565
+ """Require positive height and width and return them as integers."""
547
566
  if len(size) != 2:
548
567
  raise ValueError(f"Image size must be an H W pair, got {tuple(size)}.")
549
568
  height, width = int(size[0]), int(size[1])
@@ -575,6 +594,12 @@ def _scale_numpy_integer_range(
575
594
 
576
595
 
577
596
  def _normalization_values(data: Mapping[str, object]) -> Normalization:
597
+ """Canonicalize ``preprocessing.json`` normalization fields.
598
+
599
+ Accepts the current ``normalization: {scheme, percentiles, eps}`` mapping (with
600
+ top-level ``mean``/``std``) and the legacy forms that carry only scalar
601
+ ``mean``/``std``, either top-level or inside ``normalization``.
602
+ """
578
603
  normalization = data.get("normalization")
579
604
  if isinstance(normalization, Mapping):
580
605
  normalization = cast(Mapping[str, object], normalization)
@@ -633,6 +658,7 @@ def _validate_normalization(
633
658
  mean: float | None,
634
659
  std: float | None,
635
660
  ) -> None:
661
+ """Reject unsupported, incompatible, or non-finite normalization settings."""
636
662
  if not isinstance(scheme, str) or scheme not in _NORMALIZATION_SCHEMES:
637
663
  raise ValueError(
638
664
  "preprocessing normalization scheme must be one of "
@@ -656,6 +682,7 @@ def _validate_normalization(
656
682
 
657
683
 
658
684
  def _validate_percentiles(values: object) -> tuple[float, float]:
685
+ """Require two finite increasing percentiles in the range zero to 100."""
659
686
  if isinstance(values, str | bytes) or not isinstance(values, Sequence):
660
687
  raise TypeError("preprocessing percentiles must be a two-value sequence.")
661
688
  values = cast(Sequence[str | SupportsFloat | SupportsIndex], values)
@@ -675,6 +702,7 @@ def _validate_percentiles(values: object) -> tuple[float, float]:
675
702
 
676
703
 
677
704
  def _validate_eps(value: object) -> float:
705
+ """Require and return a positive finite normalization stabilizer."""
678
706
  if isinstance(value, bool) or not isinstance(value, int | float):
679
707
  raise TypeError("preprocessing normalization eps must be a number.")
680
708
  eps = float(value)
@@ -938,6 +966,7 @@ def model_forward(model: Forward, bundle: Path) -> Forward:
938
966
  """Keep deferred tensor-key/architecture errors classified as incompatible assets."""
939
967
 
940
968
  def forward(images: FloatArray) -> FloatArray:
969
+ """Run a member and classify deferred checkpoint errors as asset errors."""
941
970
  try:
942
971
  return model(images)
943
972
  except (KeyError, ValueError) as error:
@@ -975,6 +1004,7 @@ def _predict(
975
1004
 
976
1005
 
977
1006
  def _input_errors() -> tuple[type[BaseException], ...]:
1007
+ """Exceptions meaning an unreadable input; pydicom's parse error only if installed."""
978
1008
  errors: tuple[type[BaseException], ...] = (
979
1009
  EOFError,
980
1010
  OSError,
@@ -989,6 +1019,7 @@ def _input_errors() -> tuple[type[BaseException], ...]:
989
1019
 
990
1020
 
991
1021
  def _require_bool(name: str, value: object) -> bool:
1022
+ """Require a boolean runner argument or raise an input error."""
992
1023
  if not isinstance(value, bool):
993
1024
  raise InvalidInputError(f"FleXray {name} must be true or false, got {value!r}")
994
1025
  return value
@@ -997,7 +1028,7 @@ def _require_bool(name: str, value: object) -> bool:
997
1028
  def run(
998
1029
  *,
999
1030
  image_path: str | Path,
1000
- weights_path: str | Path,
1031
+ weights_path: str | Path | None = None,
1001
1032
  output_dir: str | Path,
1002
1033
  label_name: str = "lungs",
1003
1034
  ensemble: bool = False,
@@ -1009,40 +1040,51 @@ def run(
1009
1040
  ) -> RunResult:
1010
1041
  """Segment one conventional 2D radiograph with the released FleXray network.
1011
1042
 
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
1043
+ The model natively predicts on a 256x256 grid after the bundle's preprocessing
1044
+ (normally zero-padding the image to a square). All 61 independent sigmoid
1045
+ channels (background plus 60 structures) are saved on that model grid. The
1046
+ selected structure's binary
1015
1047
  mask is thresholded on the model grid, cropped, and resized with nearest neighbor,
1016
1048
  so it keeps the source image's pixel dimensions.
1017
1049
 
1018
1050
  Args:
1019
1051
  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
1052
+ or a single-frame 2D DICOM file. DICOM detection uses ``.dcm`` or
1053
+ ``.dicom`` (case-insensitive), or ``DICM`` at byte offset 128 for a
1054
+ file without a suffix.
1055
+ weights_path: Optional model folder. Omit to download ``MedMLX/flexray-mlx``.
1056
+ The flagship snapshot at revision
1057
+ ``722d8341570e8b9e1196cf50e57892da84974fcc``, containing
1058
+ ``ensemble.json`` and the ``members/flux0375/`` bundle
1023
1059
  (``config.yml``, ``model.safetensors``, ``label_schema.json``,
1024
1060
  ``preprocessing.json``).
1025
1061
  output_dir: Directory for the outputs; created if missing.
1026
1062
  label_name: Structure to threshold into the mask, e.g. ``lungs``,
1027
1063
  ``heart``, ``rib_5`` or ``background`` (any name in the label schema).
1028
1064
  ensemble: Average the probabilities of all five released members
1029
- instead of running only the ``flux0375`` flagship.
1065
+ instead of running only the ``flux0375`` flagship (default False).
1066
+ Requires all five member bundles: ``flux0375``, ``flux000``,
1067
+ ``flux025``, ``flux050``, ``flux075``. The published MedMLX snapshot
1068
+ contains only the flagship; use the full ``VictorButoi/flexray``
1069
+ snapshot at ``6b63ddf09cfdff99aea494e3738cf243a4e22cdb`` for an ensemble.
1030
1070
  threshold: Probability cutoff in [0, 1] for the selected structure; pixels
1031
1071
  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.
1072
+ tta_samples: Non-negative integer total passes per member; 0 or 1 means
1073
+ one plain pass.
1074
+ tta_seed: Integer in ``[-2**63, 2**64 - 1]`` for the isolated stream that
1075
+ reproduces Torch CPU augmentation draws; None is unseeded, as in fxr.
1035
1076
  normalization_scheme: Override the bundle's normalization with ``none``,
1036
1077
  ``minmax``, ``percentile_minmax`` or ``standardize``. None keeps the
1037
1078
  bundle contract. Standardize uses sample std and adds bundle epsilon.
1038
1079
  overwrite: Replace existing outputs instead of refusing. Output paths
1039
- must remain separate from the input radiograph and its file aliases.
1080
+ must remain separate from each other, the input radiograph and the
1081
+ documented model bundle files, including their file aliases.
1040
1082
 
1041
1083
  Returns:
1042
1084
  ``outputs`` maps ``mask`` to ``source_image_label.png`` (uint8 0/1 on the
1043
1085
  source pixel grid), ``display_source`` to ``source_image.png`` (the 8-bit
1044
1086
  grayscale source), ``probabilities`` to ``probabilities.npy`` (float32
1045
- ``61x256x256`` on the padded model grid) and ``prediction`` to
1087
+ ``61x256x256`` on the model grid) and ``prediction`` to
1046
1088
  ``prediction.json``. ``details`` holds the selected label and channel,
1047
1089
  threshold, members, TTA settings, normalization scheme, source size as
1048
1090
  ``[width, height]``, the content box as ``[top, left, bottom, right]`` in
@@ -1051,9 +1093,10 @@ def run(
1051
1093
  Raises:
1052
1094
  InvalidInputError: Missing or unreadable input, a non-2D DICOM, an
1053
1095
  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.
1096
+ normalization settings, outputs that alias an input, model asset or
1097
+ each other, or existing outputs without ``overwrite``.
1098
+ AssetNotReadyError: Missing or unreadable assets, an incompatible bundle
1099
+ layout or network, or inconsistent member metadata.
1057
1100
  MissingDependencyError: MLX or pydicom is unavailable.
1058
1101
  ModelExecutionError: Inference failed, or the network returned
1059
1102
  non-finite or wrongly shaped probabilities. Raised before any
@@ -1095,6 +1138,26 @@ def run(
1095
1138
  for path in destinations
1096
1139
  ):
1097
1140
  raise InvalidInputError("FleXray outputs must be separate from the input radiograph")
1141
+ weights_root = (
1142
+ weights_dir("flexray-mlx") if weights_path is None else Path(weights_path).expanduser()
1143
+ )
1144
+ model_assets = (
1145
+ weights_root / "ensemble.json",
1146
+ *(weights_root / "members" / member / name for member in MEMBERS for name in _BUNDLE_FILES),
1147
+ )
1148
+ if any(
1149
+ path.resolve() == asset.resolve()
1150
+ or (path.exists() and asset.exists() and path.samefile(asset))
1151
+ for path in destinations
1152
+ for asset in model_assets
1153
+ ):
1154
+ raise InvalidInputError("FleXray outputs must be separate from each model asset")
1155
+ if any(
1156
+ first.resolve() == second.resolve()
1157
+ or (first.exists() and second.exists() and first.samefile(second))
1158
+ for first, second in combinations(destinations, 2)
1159
+ ):
1160
+ raise InvalidInputError("FleXray outputs must not refer to the same file")
1098
1161
  existing = [path.as_posix() for path in destinations if path.exists()]
1099
1162
  if existing and not overwrite:
1100
1163
  raise InvalidInputError(
@@ -1102,7 +1165,7 @@ def run(
1102
1165
  "pass overwrite=True to replace them"
1103
1166
  )
1104
1167
 
1105
- admitted = admit_bundle(Path(weights_path).resolve(), ensemble=ensemble)
1168
+ admitted = admit_bundle(weights_root, ensemble=ensemble)
1106
1169
  labels = admitted.labels
1107
1170
  if label_name not in labels:
1108
1171
  raise InvalidInputError(f"Unknown FleXray anatomy label: {label_name}")
@@ -1190,6 +1253,7 @@ def run(
1190
1253
  "prediction": prediction_path.as_posix(),
1191
1254
  },
1192
1255
  "details": {
1256
+ "weights_revision": weights_revision(weights_root),
1193
1257
  "selected_label": label_name,
1194
1258
  "selected_channel": channel,
1195
1259
  "threshold": threshold,
@@ -1,7 +1,8 @@
1
1
  """Native MLX execution of the pinned FleXray v1.0.2 U-Net checkpoint.
2
2
 
3
3
  The released network is fixed to seven encoder stages and six decoder stages.
4
- PyTorch is used by the source preprocessing and TTA code, never for this forward pass.
4
+ Upstream FleXray uses PyTorch for preprocessing and test-time augmentation; this
5
+ port implements those steps separately and uses MLX for the network forward pass.
5
6
  """
6
7
 
7
8
  from __future__ import annotations
@@ -95,15 +96,18 @@ class FleXrayMlxModel:
95
96
  self._pool = nn.MaxPool2d(kernel_size=2, stride=2)
96
97
 
97
98
  def _weight(self, key: str) -> array:
99
+ """Record and return a named checkpoint tensor."""
98
100
  self._consumed.add(key)
99
101
  return self.weights[key]
100
102
 
101
103
  def _conv(self, x: array, prefix: str, *, padding: int) -> array:
104
+ """Apply a learned convolution and bias using the named prefix."""
102
105
  weight = self._weight(f"{prefix}.weight")
103
106
  bias = self._weight(f"{prefix}.bias")
104
107
  return mx.conv2d(x, weight, padding=padding) + bias
105
108
 
106
109
  def _norm(self, x: array, prefix: str) -> array:
110
+ """Apply per-instance spatial normalization with learned scale and bias."""
107
111
  gamma = self._weight(f"{prefix}.weight")
108
112
  beta = self._weight(f"{prefix}.bias")
109
113
  # Instance norm on NHWC: per sample and channel over H and W. eps=1e-5 is
@@ -113,6 +117,7 @@ class FleXrayMlxModel:
113
117
  return (x - mean) * mx.rsqrt(variance + 1e-5) * gamma + beta
114
118
 
115
119
  def _block(self, x: array, prefix: str) -> array:
120
+ """Three conv, LeakyReLU, instance-norm steps plus a residual shortcut."""
116
121
  residual = x
117
122
  out = x
118
123
  for index in range(3):
@@ -29,6 +29,7 @@ class TtaSettings:
29
29
  seed: int | None = None
30
30
 
31
31
  def __post_init__(self) -> None:
32
+ """Reject invalid augmentation counts and Torch-compatible seed values."""
32
33
  if (
33
34
  isinstance(self.samples, bool)
34
35
  or not isinstance(cast(object, self.samples), int)
@@ -69,8 +70,10 @@ def predict_with_tta(
69
70
  float32 ``(B, C, H, W)`` images to float32 ``(B, L, H, W)`` logits.
70
71
  images: Prepared float32 ``(B, C, H, W)`` batch with 1 or 3 channels,
71
72
  already normalized.
72
- tta_samples: Total passes per member; 0 or 1 means one plain pass.
73
- seed: Seed for the isolated Torch-CPU-compatible stream; None is unseeded.
73
+ tta_samples: Non-negative integer total passes per member; 0 or 1 means
74
+ one plain pass.
75
+ seed: Integer in ``[-2**63, 2**64 - 1]`` for the isolated Torch-CPU-compatible
76
+ stream; None is unseeded.
74
77
  normalizer: Applied to each augmented view (not the plain input) so views
75
78
  are normalized as the plain input was.
76
79
 
@@ -124,7 +127,8 @@ def augment_sample(image: FloatArray, random: TorchRandom) -> tuple[FloatArray,
124
127
 
125
128
  ``image`` is one prepared ``(C, H, W)`` float32 sample. Random draws are
126
129
  consumed in fxr's order. The CLAHE, gamma, contrast and sharpness steps clip
127
- to [0, 1]; invert and noise do not.
130
+ to [0, 1]; invert and noise do not. Fractional gamma is applied before its
131
+ clip, so negative values can produce NaN; clipping does not remove that NaN.
128
132
  Returns the view and whether it was flipped (so logits can be unflipped).
129
133
  """
130
134
 
@@ -200,11 +204,13 @@ def clahe(image: FloatArray, *, clip_limit: float, grid_size: tuple[int, int]) -
200
204
  interpolates bilinearly between the four nearest tile centres. ``grid_size``
201
205
  is ``(rows, cols)`` of tiles, capped at the image size; tiles are rounded up
202
206
  to even sizes and the image is padded to fit (reflect, or edge when the
203
- padding reaches the image size in either direction). Returns float32 ``(C, H, W)`` in [0, 1].
207
+ padding reaches the image size in either direction). Supply two integer tile
208
+ counts; the function does not check their types. Returns float32 ``(C, H, W)`` in [0, 1].
204
209
 
205
210
  Raises:
206
211
  ValueError: ``image`` is not a non-empty 3D array, ``clip_limit`` is not
207
- positive and finite, or ``grid_size`` is not two positive integers.
212
+ positive and finite, or ``grid_size`` has the wrong length, a boolean
213
+ value, or a nonpositive value.
208
214
  """
209
215
 
210
216
  if image.ndim != 3 or min(image.shape) < 1:
@@ -245,4 +251,7 @@ def clahe(image: FloatArray, *, clip_limit: float, grid_size: tuple[int, int]) -
245
251
  wy, wx = wy[None, :, None], wx[None, None]
246
252
  top = v00 * (np.float32(1) - wx) + v01 * wx
247
253
  bottom = v10 * (np.float32(1) - wx) + v11 * wx
248
- return ((top * (np.float32(1) - wy) + bottom * wy) / np.float32(255))[:, :height, :width]
254
+ return cast(
255
+ FloatArray,
256
+ ((top * (np.float32(1) - wy) + bottom * wy) / np.float32(255))[:, :height, :width],
257
+ )
@@ -1,46 +0,0 @@
1
- # flexray-mlx
2
-
3
- Segment anatomical structures in radiographs with FleXray and native MLX.
4
-
5
- [Docs](docs/technical.md) · [MedMLX](https://github.com/MedMLX)
6
-
7
- ![One chest radiograph → separate lung, heart and individual rib masks.](https://raw.githubusercontent.com/MedMLX/flexray-mlx/main/docs/showcase.png)
8
-
9
- *One chest radiograph → separate lung, heart and individual rib masks.*
10
-
11
- | Input | Output |
12
- | :--- | :--- |
13
- | Raster image or single-frame DICOM | Structure masks and probabilities |
14
-
15
- **Apple Silicon · Metal · Python 3.13 / 3.14** · [uv](https://docs.astral.sh/uv/)
16
-
17
- ## Quick start
18
-
19
- ```bash
20
- uv venv --python 3.13
21
- source .venv/bin/activate
22
- repo="https://github.com/MedMLX/flexray-mlx"
23
- uv pip install \
24
- "flexray-mlx @ git+$repo" \
25
- huggingface-hub
26
- ```
27
-
28
- Download the released weights and run:
29
-
30
- ```python
31
- from huggingface_hub import snapshot_download
32
- from flexray_mlx.medmlx import run
33
-
34
- weights = snapshot_download("VictorButoi/flexray", local_dir="flexray", ignore_patterns=["*.onnx"])
35
-
36
- result = run(image_path="chest.png", weights_path=weights, output_dir="out")
37
- print(result["outputs"]["mask"])
38
- ```
39
-
40
- Use `label_name` to select a structure and `ensemble=True` to average all five members.
41
-
42
- ## License
43
-
44
- [Apache-2.0](LICENSE). See [third-party notices](THIRD_PARTY_NOTICES.md). Model weights retain their publisher terms. FleXray weights are noncommercial (CC-BY-NC-4.0).
45
-
46
- For research and education. Outputs require human review.
File without changes
File without changes