medmlx 0.3.2__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 medmlx might be problematic. Click here for more details.
- medmlx/__init__.py +28 -0
- medmlx/__main__.py +7 -0
- medmlx/adapters/__init__.py +1 -0
- medmlx/adapters/_medvlm_worker.py +237 -0
- medmlx/adapters/fdg_pet.py +295 -0
- medmlx/adapters/medvlm.py +224 -0
- medmlx/adapters/nnunet.py +208 -0
- medmlx/api.py +334 -0
- medmlx/catalog.py +66 -0
- medmlx/cli.py +203 -0
- medmlx/families.py +75 -0
- medmlx/jobs.py +274 -0
- medmlx/payloads.py +226 -0
- medmlx/probe.py +94 -0
- medmlx/py.typed +0 -0
- medmlx/registry.py +326 -0
- medmlx/server.py +190 -0
- medmlx/setup.py +312 -0
- medmlx/worker.py +67 -0
- medmlx-0.3.2.dist-info/METADATA +163 -0
- medmlx-0.3.2.dist-info/RECORD +25 -0
- medmlx-0.3.2.dist-info/WHEEL +4 -0
- medmlx-0.3.2.dist-info/entry_points.txt +10 -0
- medmlx-0.3.2.dist-info/licenses/LICENSE +202 -0
- medmlx-0.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +25 -0
medmlx/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""MedMLX model discovery and execution without importing model frameworks."""
|
|
2
|
+
|
|
3
|
+
from medmlx.api import (
|
|
4
|
+
describe_model,
|
|
5
|
+
list_checkpoints,
|
|
6
|
+
list_models,
|
|
7
|
+
list_packages,
|
|
8
|
+
list_setup_plans,
|
|
9
|
+
model_readiness,
|
|
10
|
+
run,
|
|
11
|
+
setup_model,
|
|
12
|
+
setup_plan,
|
|
13
|
+
)
|
|
14
|
+
from medmlx.probe import runtime_info
|
|
15
|
+
|
|
16
|
+
__version__ = "0.3.1"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"describe_model",
|
|
19
|
+
"list_checkpoints",
|
|
20
|
+
"list_models",
|
|
21
|
+
"list_packages",
|
|
22
|
+
"list_setup_plans",
|
|
23
|
+
"model_readiness",
|
|
24
|
+
"run",
|
|
25
|
+
"runtime_info",
|
|
26
|
+
"setup_model",
|
|
27
|
+
"setup_plan",
|
|
28
|
+
]
|
medmlx/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Named model configurations implemented by independently installed runtimes."""
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Isolated MedVLM JSON bridge, executed by absolute filename with Python -I.
|
|
2
|
+
|
|
3
|
+
This file must not import MedMLX or medmlx_core: the selected interpreter owns
|
|
4
|
+
MedVLM's conflicting dependency set. Probe mode reads distribution metadata only.
|
|
5
|
+
Run mode accepts local assets and disables Hub access before importing the model.
|
|
6
|
+
The caller owns timeout, process-group cancellation and final output publication.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import sys
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, distribution, version
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol, cast
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Model(Protocol):
|
|
22
|
+
config: object
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Generation(Protocol):
|
|
26
|
+
text: object
|
|
27
|
+
generation_tokens: object
|
|
28
|
+
prompt_tokens: object
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Load(Protocol):
|
|
32
|
+
def __call__(
|
|
33
|
+
self, path: str, *, strict: bool, local_files_only: bool,
|
|
34
|
+
) -> tuple[Model, object]: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Template(Protocol):
|
|
38
|
+
def __call__(
|
|
39
|
+
self, processor: object, config: object, prompt: str, *, num_images: int,
|
|
40
|
+
) -> object: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Generate(Protocol):
|
|
44
|
+
def __call__(
|
|
45
|
+
self, model: object, processor: object, prompt: str, *, image: list[str],
|
|
46
|
+
max_tokens: int, temperature: float, prefill_step_size: int, verbose: bool,
|
|
47
|
+
) -> Generation: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Utilities(Protocol):
|
|
51
|
+
load: Load
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Prompts(Protocol):
|
|
55
|
+
apply_chat_template: Template
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Generator(Protocol):
|
|
59
|
+
generate: Generate
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Metal(Protocol):
|
|
63
|
+
def is_available(self) -> bool: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Mlx(Protocol):
|
|
67
|
+
metal: Metal
|
|
68
|
+
gpu: object
|
|
69
|
+
|
|
70
|
+
def set_default_device(self, device: object) -> None: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _object(value: object) -> dict[str, object]:
|
|
74
|
+
if not isinstance(value, dict) or not all(
|
|
75
|
+
isinstance(key, str) for key in cast(dict[object, object], value)
|
|
76
|
+
):
|
|
77
|
+
raise ValueError("Expected a JSON object with string keys")
|
|
78
|
+
return cast(dict[str, object], value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _runtime() -> dict[str, str]:
|
|
82
|
+
"""Check the scoped provider version and its installed runtime dependency graph."""
|
|
83
|
+
from packaging.requirements import Requirement
|
|
84
|
+
from packaging.utils import canonicalize_name
|
|
85
|
+
|
|
86
|
+
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
|
87
|
+
raise RuntimeError("MedVLM requires macOS arm64")
|
|
88
|
+
if sys.version_info[:2] != (3, 13):
|
|
89
|
+
raise RuntimeError("The isolated bridge currently requires Python 3.13")
|
|
90
|
+
if version("med-vlm-mlx") != "0.2.0":
|
|
91
|
+
raise RuntimeError("This provisional bridge targets med-vlm-mlx==0.2.0")
|
|
92
|
+
try:
|
|
93
|
+
version("mlx-vlm")
|
|
94
|
+
except PackageNotFoundError:
|
|
95
|
+
pass
|
|
96
|
+
else:
|
|
97
|
+
raise RuntimeError("Remove the conflicting mlx-vlm distribution from this environment")
|
|
98
|
+
pending = ["med-vlm-mlx"]
|
|
99
|
+
found: dict[str, str] = {}
|
|
100
|
+
while pending:
|
|
101
|
+
name = canonicalize_name(pending.pop())
|
|
102
|
+
if name in found:
|
|
103
|
+
continue
|
|
104
|
+
package = distribution(name)
|
|
105
|
+
found[name] = package.version
|
|
106
|
+
for text in package.requires or []:
|
|
107
|
+
requirement = Requirement(text)
|
|
108
|
+
if requirement.marker is not None and not requirement.marker.evaluate({"extra": ""}):
|
|
109
|
+
continue
|
|
110
|
+
installed = version(requirement.name)
|
|
111
|
+
if not requirement.specifier.contains(installed):
|
|
112
|
+
raise RuntimeError(f"{name} requires {requirement}; installed {installed}")
|
|
113
|
+
pending.append(requirement.name)
|
|
114
|
+
return {name: found[name] for name in (
|
|
115
|
+
"med-vlm-mlx", "mlx", "transformers", "huggingface-hub", "tokenizers",
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _local_assets(request: dict[str, object]) -> tuple[Path, Path, str, int]:
|
|
120
|
+
"""Check paths and declared architecture without qualifying checkpoint tensors."""
|
|
121
|
+
model_id = request.get("model_id")
|
|
122
|
+
if model_id not in ("medgemma", "lingshu"):
|
|
123
|
+
raise ValueError("model_id must be medgemma or lingshu")
|
|
124
|
+
model_value, image_value = request.get("model_dir"), request.get("image_path")
|
|
125
|
+
if not isinstance(model_value, str) or not isinstance(image_value, str):
|
|
126
|
+
raise ValueError("model_dir and image_path must be local paths")
|
|
127
|
+
model, image = Path(model_value), Path(image_value)
|
|
128
|
+
if not model.is_absolute() or not model.is_dir():
|
|
129
|
+
raise FileNotFoundError("model_dir must be an existing absolute local directory")
|
|
130
|
+
if not image.is_absolute() or not image.is_file() or image.suffix.lower() not in {
|
|
131
|
+
".png", ".jpg", ".jpeg",
|
|
132
|
+
}:
|
|
133
|
+
raise FileNotFoundError("image_path must be an existing local PNG or JPEG file")
|
|
134
|
+
for name in (
|
|
135
|
+
"config.json", "tokenizer.json", "tokenizer_config.json", "preprocessor_config.json",
|
|
136
|
+
):
|
|
137
|
+
path = model / name
|
|
138
|
+
if not path.is_file() or path.stat().st_size == 0:
|
|
139
|
+
raise FileNotFoundError(f"Local checkpoint requires {name}; downloads are disabled")
|
|
140
|
+
config = _object(json.loads((model / "config.json").read_text()))
|
|
141
|
+
expected = "gemma3" if model_id == "medgemma" else "qwen2_5_vl"
|
|
142
|
+
if config.get("model_type") != expected:
|
|
143
|
+
raise ValueError(f"{model_id} requires model_type={expected}")
|
|
144
|
+
weights = list(model.glob("*.safetensors"))
|
|
145
|
+
if not weights or any(path.stat().st_size == 0 for path in weights):
|
|
146
|
+
raise FileNotFoundError("Local checkpoint requires nonempty safetensors weights")
|
|
147
|
+
index = model / "model.safetensors.index.json"
|
|
148
|
+
if index.is_file():
|
|
149
|
+
mapping = _object(_object(json.loads(index.read_text())).get("weight_map"))
|
|
150
|
+
if not mapping:
|
|
151
|
+
raise ValueError("Checkpoint weight_map must not be empty")
|
|
152
|
+
for shard in mapping.values():
|
|
153
|
+
if not isinstance(shard, str) or Path(shard).name != shard or not shard.endswith(
|
|
154
|
+
".safetensors"
|
|
155
|
+
):
|
|
156
|
+
raise ValueError("Checkpoint shard names must be local safetensors filenames")
|
|
157
|
+
if not (model / shard).is_file():
|
|
158
|
+
raise FileNotFoundError(f"Missing checkpoint shard: {shard}")
|
|
159
|
+
prompt = request.get("prompt")
|
|
160
|
+
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 16384:
|
|
161
|
+
raise ValueError("prompt must contain 1..16384 characters, including non-whitespace")
|
|
162
|
+
tokens = request.get("max_new_tokens")
|
|
163
|
+
if type(tokens) is not int or not 1 <= tokens <= 2048:
|
|
164
|
+
raise ValueError("max_new_tokens must be an integer in [1, 2048]")
|
|
165
|
+
return model, image, prompt, tokens
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _generate(
|
|
169
|
+
assets: tuple[Path, Path, str, int], versions: dict[str, str],
|
|
170
|
+
) -> dict[str, object]:
|
|
171
|
+
"""Use the provider's public strict loader, template and greedy generation APIs."""
|
|
172
|
+
model_path, image_path, prompt, tokens = assets
|
|
173
|
+
mx = cast(Mlx, importlib.import_module("mlx.core"))
|
|
174
|
+
if not mx.metal.is_available():
|
|
175
|
+
raise RuntimeError("The isolated MedVLM interpreter has no MLX Metal device")
|
|
176
|
+
mx.set_default_device(mx.gpu)
|
|
177
|
+
load = cast(Utilities, importlib.import_module("mlx_vlm.utils")).load
|
|
178
|
+
template = cast(Prompts, importlib.import_module("mlx_vlm.prompt_utils")).apply_chat_template
|
|
179
|
+
generate = cast(Generator, importlib.import_module("mlx_vlm.generate")).generate
|
|
180
|
+
model, processor = load(str(model_path), strict=True, local_files_only=True)
|
|
181
|
+
rendered = template(processor, model.config, prompt, num_images=1)
|
|
182
|
+
if not isinstance(rendered, str):
|
|
183
|
+
raise TypeError("The medical chat template must return text")
|
|
184
|
+
result = generate(model, processor, rendered, image=[str(image_path)], max_tokens=tokens,
|
|
185
|
+
temperature=0.0, prefill_step_size=64, verbose=False)
|
|
186
|
+
text: object = result.text
|
|
187
|
+
generated: object = result.generation_tokens
|
|
188
|
+
prefilling: object = result.prompt_tokens
|
|
189
|
+
if not isinstance(text, str) or not text.strip():
|
|
190
|
+
raise RuntimeError("MedVLM generated no text")
|
|
191
|
+
if type(generated) is not int or not 1 <= generated <= tokens:
|
|
192
|
+
raise RuntimeError("MedVLM returned an invalid generation token count")
|
|
193
|
+
if type(prefilling) is not int or prefilling < 1:
|
|
194
|
+
raise RuntimeError("MedVLM returned an invalid prompt token count")
|
|
195
|
+
return {"text": text, "generation_tokens": generated, "prompt_tokens": prefilling,
|
|
196
|
+
"versions": versions, "python": sys.executable}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> int:
|
|
200
|
+
"""Read one request and atomically return a versioned JSON success/error packet."""
|
|
201
|
+
if len(sys.argv) != 3:
|
|
202
|
+
raise SystemExit("usage: _medvlm_worker.py REQUEST_JSON RESPONSE_JSON")
|
|
203
|
+
request_path, response_path = map(Path, sys.argv[1:])
|
|
204
|
+
# These are process-local execution settings, not persistent access changes.
|
|
205
|
+
os.environ.update(HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", HF_DATASETS_OFFLINE="1")
|
|
206
|
+
stage = "request"
|
|
207
|
+
try:
|
|
208
|
+
request = _object(json.loads(request_path.read_text()))
|
|
209
|
+
if request.get("protocol") != 1:
|
|
210
|
+
raise ValueError("Unsupported MedVLM bridge protocol")
|
|
211
|
+
if request.get("action") == "probe":
|
|
212
|
+
stage = "runtime"
|
|
213
|
+
result: dict[str, object] = {"versions": _runtime(), "python": sys.executable}
|
|
214
|
+
elif request.get("action") == "run":
|
|
215
|
+
stage = "assets"
|
|
216
|
+
assets = _local_assets(request)
|
|
217
|
+
stage = "runtime"
|
|
218
|
+
versions = _runtime()
|
|
219
|
+
stage = "execution"
|
|
220
|
+
result = _generate(assets, versions)
|
|
221
|
+
else:
|
|
222
|
+
raise ValueError("Unknown MedVLM bridge action")
|
|
223
|
+
packet: dict[str, object] = {"protocol": 1, "ok": True, "result": result}
|
|
224
|
+
code = 0
|
|
225
|
+
except Exception as error:
|
|
226
|
+
packet = {"protocol": 1, "ok": False, "stage": stage,
|
|
227
|
+
"error": {"type": type(error).__name__, "message": str(error)}}
|
|
228
|
+
code = 1
|
|
229
|
+
text = json.dumps(packet, allow_nan=False)
|
|
230
|
+
temporary = response_path.with_suffix(".tmp")
|
|
231
|
+
temporary.write_text(text)
|
|
232
|
+
os.replace(temporary, response_path)
|
|
233
|
+
return code
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Explicit-profile, weight-free FDG PET synthesis using fdg-pet-ct-mlx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, cast
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from medmlx_core.errors import InvalidInputError, MissingDependencyError, ModelExecutionError
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from fdg_pet_ct_mlx._typing import FdgRuntime
|
|
21
|
+
|
|
22
|
+
MODEL = {
|
|
23
|
+
"id": "fdg-pet-ct",
|
|
24
|
+
"label": "Synthetic FDG PET",
|
|
25
|
+
"task": "generation",
|
|
26
|
+
"run": "medmlx.adapters.fdg_pet:run",
|
|
27
|
+
"readiness": "medmlx.adapters.fdg_pet:readiness",
|
|
28
|
+
"summary": "Weight-free PET SUV synthesis from CT, MAISI labels and explicit profiles.",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Tissue:
|
|
34
|
+
"""Caller-provided activity mean and standard deviation in SUV, with MAISI IDs."""
|
|
35
|
+
|
|
36
|
+
suv_mean: float
|
|
37
|
+
suv_std: float
|
|
38
|
+
maisi_labels: tuple[int, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Organs:
|
|
43
|
+
"""Validated tissue profiles, including the fallback for unlisted MAISI labels."""
|
|
44
|
+
|
|
45
|
+
tissues: Mapping[str, Tissue]
|
|
46
|
+
|
|
47
|
+
def lookup_table(self, size: int) -> tuple[float, ...]:
|
|
48
|
+
"""Map labels below ``size`` to mean SUV; use the fallback for unlisted IDs."""
|
|
49
|
+
table = [self.tissues["unclassified_soft_tissue"].suv_mean] * size
|
|
50
|
+
for tissue in self.tissues.values():
|
|
51
|
+
for label in tissue.maisi_labels:
|
|
52
|
+
if label < size:
|
|
53
|
+
table[label] = tissue.suv_mean
|
|
54
|
+
return tuple(table)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Scanner:
|
|
59
|
+
"""Image-space acquisition parameters; FWHM follows the three array axes in mm."""
|
|
60
|
+
|
|
61
|
+
scatter_fraction: float
|
|
62
|
+
randoms_fraction: float
|
|
63
|
+
fwhm_mm: tuple[float, float, float]
|
|
64
|
+
counts_per_suv: float
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _object(value: object, name: str, keys: set[str] | None = None) -> dict[str, object]:
|
|
68
|
+
if not isinstance(value, dict) or not all(
|
|
69
|
+
isinstance(key, str) for key in cast(dict[object, object], value)
|
|
70
|
+
):
|
|
71
|
+
raise InvalidInputError(f"{name} must be a JSON object")
|
|
72
|
+
result = cast(dict[str, object], value)
|
|
73
|
+
if keys is not None and set(result) != keys:
|
|
74
|
+
raise InvalidInputError(f"{name} must contain exactly: {', '.join(sorted(keys))}")
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _number(
|
|
79
|
+
value: object,
|
|
80
|
+
name: str,
|
|
81
|
+
*,
|
|
82
|
+
positive: bool = False,
|
|
83
|
+
maximum: float | None = None,
|
|
84
|
+
) -> float:
|
|
85
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
86
|
+
raise InvalidInputError(f"{name} must be a finite number")
|
|
87
|
+
result = float(value)
|
|
88
|
+
if not math.isfinite(result) or result < 0 or (positive and result == 0):
|
|
89
|
+
domain = "positive" if positive else "nonnegative"
|
|
90
|
+
raise InvalidInputError(f"{name} must be finite and {domain}")
|
|
91
|
+
if maximum is not None and result > maximum:
|
|
92
|
+
raise InvalidInputError(f"{name} must be at most {maximum}")
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _profiles(path: Path) -> tuple[Organs, Scanner]:
|
|
97
|
+
"""Parse caller-owned profiles; no tissue statistics or scanner presets are invented."""
|
|
98
|
+
try:
|
|
99
|
+
raw: object = json.loads(path.read_text())
|
|
100
|
+
except (OSError, ValueError) as error:
|
|
101
|
+
raise InvalidInputError(f"Cannot read profile_path: {error}") from error
|
|
102
|
+
profile = _object(raw, "profile", {"tissues", "scanner"})
|
|
103
|
+
tissues: dict[str, Tissue] = {}
|
|
104
|
+
assigned: set[int] = set()
|
|
105
|
+
for name, raw_tissue in _object(profile["tissues"], "tissues").items():
|
|
106
|
+
tissue = _object(raw_tissue, name, {"suv_mean", "suv_std", "maisi_labels"})
|
|
107
|
+
raw_ids = tissue["maisi_labels"]
|
|
108
|
+
if not isinstance(raw_ids, list):
|
|
109
|
+
raise InvalidInputError(f"{name}.maisi_labels must be a list of integers")
|
|
110
|
+
ids: list[int] = []
|
|
111
|
+
for label in cast(list[object], raw_ids):
|
|
112
|
+
if isinstance(label, bool) or not isinstance(label, int) or not 0 <= label <= 200:
|
|
113
|
+
raise InvalidInputError("Profile MAISI label IDs must be integers in [0, 200]")
|
|
114
|
+
if label in assigned:
|
|
115
|
+
raise InvalidInputError(f"MAISI label {label} is assigned more than once")
|
|
116
|
+
assigned.add(label)
|
|
117
|
+
ids.append(label)
|
|
118
|
+
tissues[name] = Tissue(
|
|
119
|
+
_number(tissue["suv_mean"], f"{name}.suv_mean"),
|
|
120
|
+
_number(tissue["suv_std"], f"{name}.suv_std"),
|
|
121
|
+
tuple(ids),
|
|
122
|
+
)
|
|
123
|
+
if "unclassified_soft_tissue" not in tissues:
|
|
124
|
+
raise InvalidInputError("tissues requires unclassified_soft_tissue for unlisted labels")
|
|
125
|
+
scanner = _object(
|
|
126
|
+
profile["scanner"],
|
|
127
|
+
"scanner",
|
|
128
|
+
{
|
|
129
|
+
"scatter_fraction",
|
|
130
|
+
"randoms_fraction",
|
|
131
|
+
"fwhm_mm",
|
|
132
|
+
"counts_per_suv",
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
raw_fwhm = scanner["fwhm_mm"]
|
|
136
|
+
if not isinstance(raw_fwhm, list) or len(cast(list[object], raw_fwhm)) != 3:
|
|
137
|
+
raise InvalidInputError("scanner.fwhm_mm requires three axis FWHM values in mm")
|
|
138
|
+
fwhm = [_number(value, "scanner.fwhm_mm") for value in cast(list[object], raw_fwhm)]
|
|
139
|
+
return Organs(tissues), Scanner(
|
|
140
|
+
_number(scanner["scatter_fraction"], "scatter_fraction", maximum=1),
|
|
141
|
+
_number(scanner["randoms_fraction"], "randoms_fraction", maximum=1),
|
|
142
|
+
(fwhm[0], fwhm[1], fwhm[2]),
|
|
143
|
+
_number(scanner["counts_per_suv"], "counts_per_suv", positive=True),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run(
|
|
148
|
+
*,
|
|
149
|
+
image_path: str,
|
|
150
|
+
labels_path: str,
|
|
151
|
+
profile_path: str,
|
|
152
|
+
output_dir: str,
|
|
153
|
+
count_level: float = 1.0,
|
|
154
|
+
seed: int = 0,
|
|
155
|
+
overwrite: bool = False,
|
|
156
|
+
) -> dict[str, object]:
|
|
157
|
+
"""Synthesize PET SUV on the input CT grid with explicit organ and scanner profiles.
|
|
158
|
+
|
|
159
|
+
No learned weights are used. The independent provider owns activity sampling
|
|
160
|
+
and acquisition approximation. This adapter does not simulate projection
|
|
161
|
+
physics/reconstruction, resample inputs, add lesions, or establish clinical validity.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
image_path: Finite scalar 3-D NIfTI CT in HU, with spatial units set to mm.
|
|
165
|
+
labels_path: Co-registered NIfTI MAISI labels, integer IDs in [0, 200],
|
|
166
|
+
with the CT shape, affine (absolute tolerance 1e-5) and mm units.
|
|
167
|
+
profile_path: JSON object containing exactly ``tissues`` and ``scanner``.
|
|
168
|
+
Each tissue contains exactly finite nonnegative ``suv_mean`` and
|
|
169
|
+
``suv_std`` (SUV), and a list of globally unique integer ``maisi_labels``
|
|
170
|
+
in [0, 200]. ``unclassified_soft_tissue`` is required for unlisted IDs.
|
|
171
|
+
Scanner contains exactly ``scatter_fraction`` and ``randoms_fraction``
|
|
172
|
+
in [0, 1], ``fwhm_mm`` as three finite nonnegative values in array-axis
|
|
173
|
+
order, and finite positive ``counts_per_suv``. No profile is built in.
|
|
174
|
+
output_dir: Write pet.nii.gz as float32 SUV, retaining the CT shape/affine.
|
|
175
|
+
count_level: Finite positive multiplier on scanner counts_per_suv.
|
|
176
|
+
seed: Integer in [0, 4294967294]. Activity uses seed; acquisition uses seed+1.
|
|
177
|
+
overwrite: Replace pet.nii.gz only when true. Input/output aliases are refused.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Absolute outputs.pet path and explicit image-space synthesis settings.
|
|
181
|
+
"""
|
|
182
|
+
count_level = _number(count_level, "count_level", positive=True)
|
|
183
|
+
if type(seed) is not int or not 0 <= seed <= 4294967294:
|
|
184
|
+
raise InvalidInputError("seed must be an integer in [0, 4294967294]")
|
|
185
|
+
if type(overwrite) is not bool:
|
|
186
|
+
raise InvalidInputError("overwrite must be boolean")
|
|
187
|
+
image, labels, profile = [
|
|
188
|
+
Path(item).expanduser().resolve() for item in (image_path, labels_path, profile_path)
|
|
189
|
+
]
|
|
190
|
+
destination = Path(output_dir).expanduser().resolve() / "pet.nii.gz"
|
|
191
|
+
if destination.resolve() in {image, labels, profile}:
|
|
192
|
+
raise InvalidInputError("Output must differ from all input paths")
|
|
193
|
+
if destination.is_dir() or (
|
|
194
|
+
(destination.exists() or destination.is_symlink()) and not overwrite
|
|
195
|
+
):
|
|
196
|
+
raise InvalidInputError(f"Output exists: {destination}; pass overwrite=True to replace it")
|
|
197
|
+
organs, scanner = _profiles(profile)
|
|
198
|
+
if not math.isfinite(count_level * scanner.counts_per_suv):
|
|
199
|
+
raise InvalidInputError("count_level * counts_per_suv must be finite")
|
|
200
|
+
try:
|
|
201
|
+
import nibabel as nib
|
|
202
|
+
except ImportError as error:
|
|
203
|
+
raise MissingDependencyError("Install medmlx's generate extra for NIfTI I/O") from error
|
|
204
|
+
try:
|
|
205
|
+
ct = nib.load(image)
|
|
206
|
+
segmentation = nib.load(labels)
|
|
207
|
+
except (OSError, ValueError) as error:
|
|
208
|
+
raise InvalidInputError(f"Cannot load CT/labels NIfTI: {error}") from error
|
|
209
|
+
if not isinstance(ct, nib.Nifti1Image | nib.Nifti2Image) or not isinstance(
|
|
210
|
+
segmentation, nib.Nifti1Image | nib.Nifti2Image
|
|
211
|
+
):
|
|
212
|
+
raise InvalidInputError("CT and labels must be NIfTI images")
|
|
213
|
+
if ct.header.get_xyzt_units()[0] != "mm" or segmentation.header.get_xyzt_units()[0] != "mm":
|
|
214
|
+
raise InvalidInputError("CT and labels must declare NIfTI spatial units in mm")
|
|
215
|
+
if ct.affine is None or segmentation.affine is None:
|
|
216
|
+
raise InvalidInputError("CT and labels require an affine")
|
|
217
|
+
hu = ct.get_fdata(dtype=np.float32)
|
|
218
|
+
ids = segmentation.get_fdata(dtype=np.float64)
|
|
219
|
+
affine = ct.affine
|
|
220
|
+
if hu.ndim != 3 or not hu.size or not np.isfinite(hu).all():
|
|
221
|
+
raise InvalidInputError("CT must be a nonempty finite scalar 3-D volume in HU")
|
|
222
|
+
if ids.shape != hu.shape or not np.allclose(affine, segmentation.affine, atol=1e-5, rtol=0):
|
|
223
|
+
raise InvalidInputError("CT and labels must share shape and affine; no resampling occurs")
|
|
224
|
+
if (
|
|
225
|
+
not np.isfinite(ids).all()
|
|
226
|
+
or (ids < 0).any()
|
|
227
|
+
or (ids > 200).any()
|
|
228
|
+
or (ids != np.floor(ids)).any()
|
|
229
|
+
):
|
|
230
|
+
raise InvalidInputError("MAISI labels must be finite integer IDs in [0, 200]")
|
|
231
|
+
spacing = np.linalg.norm(affine[:3, :3], axis=0)
|
|
232
|
+
if not np.isfinite(affine).all() or np.linalg.det(affine[:3, :3]) == 0 or (spacing <= 0).any():
|
|
233
|
+
raise InvalidInputError("CT affine must be finite, nonsingular and have positive spacing")
|
|
234
|
+
from fdg_pet_ct_mlx.acquisition import apply_acquisition_model
|
|
235
|
+
from fdg_pet_ct_mlx.activity import synthesize_activity_map
|
|
236
|
+
from medmlx_core.runtime import import_mlx
|
|
237
|
+
|
|
238
|
+
mx = cast("FdgRuntime", import_mlx())
|
|
239
|
+
activity = synthesize_activity_map(mx.array(ids.astype(np.int32)), organs, seed=seed, mx=mx)
|
|
240
|
+
pet = apply_acquisition_model(
|
|
241
|
+
activity,
|
|
242
|
+
mx.array(hu),
|
|
243
|
+
scanner=scanner,
|
|
244
|
+
count_level=count_level,
|
|
245
|
+
spacing_mm=(float(spacing[0]), float(spacing[1]), float(spacing[2])),
|
|
246
|
+
seed=seed + 1,
|
|
247
|
+
mx=mx,
|
|
248
|
+
)
|
|
249
|
+
result: NDArray[np.float32] = np.asarray(pet, dtype=np.float32)
|
|
250
|
+
if result.shape != hu.shape or not np.isfinite(result).all() or (result < 0).any():
|
|
251
|
+
raise ModelExecutionError("FDG synthesis returned invalid shape or SUV values")
|
|
252
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
header = ct.header.copy()
|
|
254
|
+
header.set_data_dtype(np.float32)
|
|
255
|
+
output = type(ct)(result, affine, header)
|
|
256
|
+
with tempfile.NamedTemporaryFile(
|
|
257
|
+
dir=destination.parent,
|
|
258
|
+
suffix=".nii.gz",
|
|
259
|
+
delete=False,
|
|
260
|
+
) as temp:
|
|
261
|
+
staged = Path(temp.name)
|
|
262
|
+
try:
|
|
263
|
+
nib.save(output, staged)
|
|
264
|
+
if overwrite:
|
|
265
|
+
os.replace(staged, destination)
|
|
266
|
+
else:
|
|
267
|
+
os.link(staged, destination)
|
|
268
|
+
finally:
|
|
269
|
+
staged.unlink(missing_ok=True)
|
|
270
|
+
return {
|
|
271
|
+
"outputs": {"pet": destination.as_posix()},
|
|
272
|
+
"details": {
|
|
273
|
+
"method": "image-space FDG synthesis",
|
|
274
|
+
"units": "SUV",
|
|
275
|
+
"seed": seed,
|
|
276
|
+
"count_level": count_level,
|
|
277
|
+
"shape": list(result.shape),
|
|
278
|
+
"profile_path": profile.as_posix(),
|
|
279
|
+
"requires_human_review": True,
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def readiness() -> dict[str, object]:
|
|
285
|
+
"""Check optional packages; profiles and input contents are validated by run."""
|
|
286
|
+
try:
|
|
287
|
+
version("fdg-pet-ct-mlx")
|
|
288
|
+
version("nibabel")
|
|
289
|
+
except PackageNotFoundError as error:
|
|
290
|
+
return {
|
|
291
|
+
"ready": False,
|
|
292
|
+
"reason": str(error),
|
|
293
|
+
"setup_hint": "Install the generate extra: uv sync --locked --extra generate",
|
|
294
|
+
}
|
|
295
|
+
return {"ready": True, "reason": None, "setup_hint": None}
|