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 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,7 @@
1
+ """Entry point for ``python -m medmlx``; behaves like the ``medmlx`` command."""
2
+
3
+ import sys
4
+
5
+ from medmlx.cli import main
6
+
7
+ sys.exit(main())
@@ -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}