flexray-mlx 0.2.2__tar.gz → 0.2.4__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.
- flexray_mlx-0.2.4/PKG-INFO +67 -0
- flexray_mlx-0.2.4/README.md +43 -0
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/pyproject.toml +8 -8
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/__init__.py +2 -1
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/_kernels.py +1 -0
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/_random.py +5 -0
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/_types.py +45 -28
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/medmlx.py +91 -27
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/mlx_model.py +6 -1
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/tta.py +15 -6
- flexray_mlx-0.2.2/PKG-INFO +0 -69
- flexray_mlx-0.2.2/README.md +0 -46
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/.gitignore +0 -0
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/LICENSE +0 -0
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/THIRD_PARTY_NOTICES.md +0 -0
- {flexray_mlx-0.2.2 → flexray_mlx-0.2.4}/src/flexray_mlx/py.typed +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flexray-mlx
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Native MLX implementation of FleXray
|
|
5
|
+
Project-URL: Homepage, https://huggingface.co/MedMLX/flexray-mlx
|
|
6
|
+
Author: Joseph Sandoval, Alfredo Sandoval Jr.
|
|
7
|
+
License-Expression: Apache-2.0 AND MIT AND BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: THIRD_PARTY_NOTICES.md
|
|
10
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
11
|
+
Requires-Python: <3.15,>=3.13
|
|
12
|
+
Requires-Dist: medmlx-core==0.2.5
|
|
13
|
+
Requires-Dist: mlx<1,>=0.32.4
|
|
14
|
+
Requires-Dist: numpy<3,>=2.0
|
|
15
|
+
Requires-Dist: pillow<14,>=13
|
|
16
|
+
Requires-Dist: pydicom<4,>=3
|
|
17
|
+
Requires-Dist: pyyaml<7,>=6
|
|
18
|
+
Requires-Dist: safetensors<1,>=0.6
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pyright==1.1.414; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest<10,>=8.4; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff<1,>=0.13; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# flexray-mlx
|
|
26
|
+
|
|
27
|
+
Segment anatomical structures in radiographs with FleXray and native MLX.
|
|
28
|
+
|
|
29
|
+
[Docs](https://github.com/MedMLX/flexray-mlx/blob/main/docs/technical.md) · [Model weights](https://huggingface.co/MedMLX/flexray-mlx) · [MedMLX](https://github.com/MedMLX)
|
|
30
|
+
|
|
31
|
+
| Input | Output |
|
|
32
|
+
| :--- | :--- |
|
|
33
|
+
| Raster image or single-frame DICOM | Structure masks and probabilities |
|
|
34
|
+
|
|
35
|
+
**Apple Silicon · Metal · Python 3.13 / 3.14** · [uv](https://docs.astral.sh/uv/)
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv venv --python 3.13
|
|
41
|
+
source .venv/bin/activate
|
|
42
|
+
uv pip install "flexray-mlx>=0.2.3"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Run
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from flexray_mlx.medmlx import run
|
|
49
|
+
|
|
50
|
+
result = run(image_path="chest.png", output_dir="out")
|
|
51
|
+
print(result["outputs"]["mask"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Omitting `weights_path` downloads `MedMLX/flexray-mlx` once and reuses its local cache.
|
|
55
|
+
An explicit `weights_path` selects your own model folder, containing `ensemble.json`
|
|
56
|
+
and `members/flux0375/{config.yml,label_schema.json,model.safetensors,preprocessing.json}`.
|
|
57
|
+
Use `label_name` to select a structure. Automatic setup prepares the flagship
|
|
58
|
+
(`ensemble=False`); `ensemble=True` requires all five members in an explicit folder.
|
|
59
|
+
|
|
60
|
+
Downloaded commits are returned in `details.weights_revision`; explicit local
|
|
61
|
+
weights report `null` unless the folder carries a MedMLX download revision.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
[Apache-2.0](https://github.com/MedMLX/flexray-mlx/blob/main/LICENSE). See [third-party notices](https://github.com/MedMLX/flexray-mlx/blob/main/THIRD_PARTY_NOTICES.md). Model weights retain their publisher terms. FleXray weights are noncommercial (CC-BY-NC-4.0).
|
|
66
|
+
|
|
67
|
+
For research and education. Outputs require human review.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# flexray-mlx
|
|
2
|
+
|
|
3
|
+
Segment anatomical structures in radiographs with FleXray and native MLX.
|
|
4
|
+
|
|
5
|
+
[Docs](https://github.com/MedMLX/flexray-mlx/blob/main/docs/technical.md) · [Model weights](https://huggingface.co/MedMLX/flexray-mlx) · [MedMLX](https://github.com/MedMLX)
|
|
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
|
+
## Install
|
|
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
|
+
## Run
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from flexray_mlx.medmlx import run
|
|
25
|
+
|
|
26
|
+
result = run(image_path="chest.png", output_dir="out")
|
|
27
|
+
print(result["outputs"]["mask"])
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Omitting `weights_path` downloads `MedMLX/flexray-mlx` once and reuses its local cache.
|
|
31
|
+
An explicit `weights_path` selects your own model folder, containing `ensemble.json`
|
|
32
|
+
and `members/flux0375/{config.yml,label_schema.json,model.safetensors,preprocessing.json}`.
|
|
33
|
+
Use `label_name` to select a structure. Automatic setup prepares the flagship
|
|
34
|
+
(`ensemble=False`); `ensemble=True` requires all five members in an explicit folder.
|
|
35
|
+
|
|
36
|
+
Downloaded commits are returned in `details.weights_revision`; explicit local
|
|
37
|
+
weights report `null` unless the folder carries a MedMLX download revision.
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
[Apache-2.0](https://github.com/MedMLX/flexray-mlx/blob/main/LICENSE). See [third-party notices](https://github.com/MedMLX/flexray-mlx/blob/main/THIRD_PARTY_NOTICES.md). Model weights retain their publisher terms. FleXray weights are noncommercial (CC-BY-NC-4.0).
|
|
42
|
+
|
|
43
|
+
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.
|
|
7
|
+
version = "0.2.4"
|
|
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.
|
|
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
|
|
70
|
-
"typing_extensions.Any" = {msg = "Use a precise type
|
|
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.
|
|
12
|
+
__version__ = "0.2.4"
|
|
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
|
|
6
|
-
from
|
|
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
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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(
|
|
48
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
def
|
|
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:
|
|
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
|
-
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
422
|
-
``RuntimeWarning``; a constant image
|
|
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
|
|
1013
|
-
to a square. All 61 independent sigmoid
|
|
1014
|
-
structures) are saved on that
|
|
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
|
-
|
|
1022
|
-
|
|
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:
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
|
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
|
|
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
|
|
1055
|
-
outputs without ``overwrite``.
|
|
1056
|
-
AssetNotReadyError: Missing
|
|
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") 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(
|
|
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
|
-
|
|
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:
|
|
73
|
-
|
|
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).
|
|
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``
|
|
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 (
|
|
254
|
+
return cast(
|
|
255
|
+
FloatArray,
|
|
256
|
+
((top * (np.float32(1) - wy) + bottom * wy) / np.float32(255))[:, :height, :width],
|
|
257
|
+
)
|
flexray_mlx-0.2.2/PKG-INFO
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: flexray-mlx
|
|
3
|
-
Version: 0.2.2
|
|
4
|
-
Summary: Native MLX implementation of FleXray
|
|
5
|
-
Author: Joseph Sandoval, Alfredo Sandoval Jr.
|
|
6
|
-
License-Expression: Apache-2.0 AND MIT AND BSD-3-Clause
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
License-File: THIRD_PARTY_NOTICES.md
|
|
9
|
-
Classifier: Operating System :: MacOS :: MacOS X
|
|
10
|
-
Requires-Python: <3.15,>=3.13
|
|
11
|
-
Requires-Dist: medmlx-core==0.2.4
|
|
12
|
-
Requires-Dist: mlx<1,>=0.32.4
|
|
13
|
-
Requires-Dist: numpy<3,>=2.0
|
|
14
|
-
Requires-Dist: pillow<14,>=13
|
|
15
|
-
Requires-Dist: pydicom<4,>=3
|
|
16
|
-
Requires-Dist: pyyaml<7,>=6
|
|
17
|
-
Requires-Dist: safetensors<1,>=0.6
|
|
18
|
-
Provides-Extra: dev
|
|
19
|
-
Requires-Dist: pyright==1.1.414; extra == 'dev'
|
|
20
|
-
Requires-Dist: pytest<10,>=8.4; extra == 'dev'
|
|
21
|
-
Requires-Dist: ruff<1,>=0.13; extra == 'dev'
|
|
22
|
-
Description-Content-Type: text/markdown
|
|
23
|
-
|
|
24
|
-
# flexray-mlx
|
|
25
|
-
|
|
26
|
-
Segment anatomical structures in radiographs with FleXray and native MLX.
|
|
27
|
-
|
|
28
|
-
[Docs](docs/technical.md) · [MedMLX](https://github.com/MedMLX)
|
|
29
|
-
|
|
30
|
-

|
|
31
|
-
|
|
32
|
-
*One chest radiograph → separate lung, heart and individual rib masks.*
|
|
33
|
-
|
|
34
|
-
| Input | Output |
|
|
35
|
-
| :--- | :--- |
|
|
36
|
-
| Raster image or single-frame DICOM | Structure masks and probabilities |
|
|
37
|
-
|
|
38
|
-
**Apple Silicon · Metal · Python 3.13 / 3.14** · [uv](https://docs.astral.sh/uv/)
|
|
39
|
-
|
|
40
|
-
## Quick start
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
uv venv --python 3.13
|
|
44
|
-
source .venv/bin/activate
|
|
45
|
-
repo="https://github.com/MedMLX/flexray-mlx"
|
|
46
|
-
uv pip install \
|
|
47
|
-
"flexray-mlx @ git+$repo" \
|
|
48
|
-
huggingface-hub
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Download the released weights and run:
|
|
52
|
-
|
|
53
|
-
```python
|
|
54
|
-
from huggingface_hub import snapshot_download
|
|
55
|
-
from flexray_mlx.medmlx import run
|
|
56
|
-
|
|
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")
|
|
60
|
-
print(result["outputs"]["mask"])
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
Use `label_name` to select a structure and `ensemble=True` to average all five members.
|
|
64
|
-
|
|
65
|
-
## License
|
|
66
|
-
|
|
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).
|
|
68
|
-
|
|
69
|
-
For research and education. Outputs require human review.
|
flexray_mlx-0.2.2/README.md
DELETED
|
@@ -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
|
-

|
|
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
|
|
File without changes
|
|
File without changes
|