lesiondiffusion-mlx 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lesiondiffusion-mlx might be problematic. Click here for more details.

@@ -0,0 +1,1764 @@
1
+ """MedMLX file runner for the pinned upstream LMNet and LINet pipeline.
2
+
3
+ ``run`` takes a CT and an organ mask as NIfTI files, standardizes them to a RAS 1 mm
4
+ grid, and writes a CT with one synthetic lesion plus the lesion and organ masks.
5
+ The helpers below it cover weight loading, option parsing, geometry, box placement,
6
+ and output writing. Research software: outputs are synthetic and need human review.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gc
12
+ import json
13
+ import math
14
+ import time
15
+ from collections.abc import Callable, Mapping, Sequence
16
+ from dataclasses import dataclass, replace
17
+ from datetime import UTC, datetime
18
+ from os import PathLike
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Final, Literal, TypedDict, cast
21
+
22
+ import numpy as np
23
+ from medmlx_core.errors import (
24
+ AssetNotReadyError,
25
+ InvalidInputError,
26
+ MedmlxError,
27
+ ModelExecutionError,
28
+ )
29
+ from medmlx_core.runtime import import_mlx
30
+
31
+ from lesiondiffusion_mlx._arrays import BoolArray, FloatArray, HostArray, NumericArray
32
+ from lesiondiffusion_mlx._nifti import ImageFileError, NiftiImage, nib
33
+ from lesiondiffusion_mlx._typing import ArrayConstructor
34
+
35
+ if TYPE_CHECKING:
36
+ from lesiondiffusion_mlx._typing import Array, Dtype, Mlx
37
+
38
+ from lesiondiffusion_mlx.mlx_model import (
39
+ LINET_CONFIG,
40
+ LMNET_CONFIG,
41
+ BiomedTextEncoder,
42
+ LdmUnet,
43
+ VqAutoencoder,
44
+ )
45
+ from lesiondiffusion_mlx.mlx_pipeline import (
46
+ LINET_CROP,
47
+ LMNET_CROP,
48
+ encode_attributes,
49
+ encode_source,
50
+ piecewise_ct,
51
+ ras_to_source,
52
+ source_crop,
53
+ source_to_ras,
54
+ )
55
+ from lesiondiffusion_mlx.mlx_sampling import DdimSampler, lesion_diffusion_timesteps
56
+
57
+ MODEL_ID: Final = "lesiondiffusion"
58
+ DEFAULT_DIFFUSION_STEPS: Final = 200
59
+ DEFAULT_SEED: Final = 23
60
+ DEFAULT_INFERENCE_MODE: Final = "conditioned"
61
+ DEFAULT_OUTPUT_REGION: Final = "full"
62
+ # Cosine feather width (mm) for ``output_region="box"``: the generated box is kept,
63
+ # blended into the input CT over this margin, and every farther voxel stays exact.
64
+ LOCALIZATION_MARGIN_MM: Final = 6.0
65
+ _REFERENCE: Final = json.loads(Path(__file__).with_name("reference.json").read_text())
66
+ SOURCE_REF: Final = _REFERENCE["revision"]
67
+ CONVERSION_SCHEMA: Final = "medmlx.lesiondiffusion.weights.v1"
68
+ CONVERSION_VERSION: Final = 1
69
+ CONVERTED_FILES: Final = ("lmnet.npz", "linet.npz", "vq.npz", "lmnet_text.npz", "linet_text.npz")
70
+ SOURCE_INFERENCE_FILES: Final = tuple(name for name in CONVERTED_FILES if name != "linet_text.npz")
71
+ TOKENIZER_FILE: Final = "tokenizer.json"
72
+ CONVERSION_FILE: Final = "conversion.json"
73
+ WARNING: Final = (
74
+ "Synthetic lesion output requires human review and is not patient data or ground truth."
75
+ )
76
+ WEIGHTS_HINT: Final = (
77
+ "Convert explicit upstream checkpoints with scripts/convert_weights.py; "
78
+ "weights_path must contain safetensors or NPZ stages, tokenizer.json and conversion.json."
79
+ )
80
+
81
+ # Canonical labels match upstream organ_type.json and its inference vocabulary.
82
+ SOURCE_ORGAN_LABELS: Final[dict[str, tuple[int, str]]] = {
83
+ name.lower().replace(" ", "_"): (int(label), name)
84
+ for label, name in _REFERENCE["organ_labels"].items()
85
+ }
86
+ # Hollow organs for box placement, as in upstream preprocessing. The cavity phrase in
87
+ # _lmnet_attributes follows upstream's inference code, whose set also has gallbladder.
88
+ _HOLLOW_ORGANS = frozenset({"colon", "stomach", "esophagus", "urinary_bladder"})
89
+ _CAVITY_TEXT = {
90
+ "inside_parenchyma": "within the parenchymal organ",
91
+ "protruding_from_parenchyma": "protruding from the parenchymal organ",
92
+ "within_lumen": "within the lumen of a hollow organ",
93
+ "wall_thickening": "wall thickening",
94
+ }
95
+
96
+ _SYNTHETIC_CT = "synthetic_ct.nii.gz"
97
+ _RAW_SYNTHETIC_CT = "raw_synthetic_ct.nii.gz"
98
+ _LESION_MASK = "lesion_mask.nii.gz"
99
+ _ORGAN_MASK = "organ_mask.nii.gz"
100
+
101
+ MODEL: Final[dict[str, str]] = {
102
+ "id": MODEL_ID,
103
+ "label": "LesionDiffusion CT lesion insertion",
104
+ "task": "generation",
105
+ "run": "lesiondiffusion_mlx.medmlx:run",
106
+ "summary": (
107
+ "Insert one mask-generating, phenotype-controlled synthetic lesion into an "
108
+ "aligned 1 mm RAS CT, given a binary organ mask."
109
+ ),
110
+ }
111
+
112
+
113
+ class NativeRuntime(TypedDict):
114
+ """Runtime facts recorded under ``details.generation_spec.native_runtime``.
115
+
116
+ Covers device, per-stage compute dtypes, the inference mode and the arithmetic it
117
+ selects, the text attributes sent to each network, per-stage records, and a
118
+ plain-language statement of what a seed reproduces.
119
+ """
120
+
121
+ conversion: dict[str, object]
122
+ device: str
123
+ dtype: dict[str, str]
124
+ inference_mode: str
125
+ convolution_arithmetic: str
126
+ lmnet_attempts: int
127
+ lmnet_conditioning: dict[str, object]
128
+ lmnet_anatomy_context: str
129
+ linet_conditioning: dict[str, object]
130
+ linet_skip_context: bool
131
+ lmnet_stage: dict[str, object]
132
+ linet_stage: dict[str, object]
133
+ model_organ_label: int
134
+ sampling: str
135
+ synchronization: str
136
+ seed_contract: str
137
+
138
+
139
+ class GenerationSpec(TypedDict):
140
+ """Provenance of one lesion: box (voxel indices on the standardized grid), placement,
141
+ runtime facts and output checks.
142
+ """
143
+
144
+ schema_version: str
145
+ source_ref: str
146
+ bbox_center_voxel: list[int]
147
+ bbox_size_voxel: list[int]
148
+ placement: str
149
+ output_mapping: str
150
+ native_runtime: NativeRuntime
151
+ output_validation: dict[str, object]
152
+
153
+
154
+ class RunDetails(TypedDict):
155
+ """JSON-safe record of one run; ``elapsed_s`` is seconds, ``started_at`` UTC ISO 8601."""
156
+
157
+ model_id: str
158
+ source_ref: str
159
+ organ: str
160
+ lesion_type: str
161
+ seed: int
162
+ diffusion_steps: int
163
+ generation_spec: GenerationSpec
164
+ stages: dict[str, dict[str, object]]
165
+ output_region: str
166
+ output_kind_by_role: dict[str, str]
167
+ output_shape_by_role: dict[str, list[int]]
168
+ output_dtype_by_role: dict[str, str]
169
+ output_labels_by_role: dict[str, list[dict[str, str | int]]]
170
+ weights_path: str
171
+ started_at: str
172
+ elapsed_s: float
173
+ requires_human_review: bool
174
+ warning: str
175
+
176
+
177
+ class RunResult(TypedDict):
178
+ """Return value of ``run``.
179
+
180
+ ``outputs`` maps a role to a POSIX file path: ``synthetic_image``, ``target_mask``
181
+ and ``organ_mask``, plus ``raw_synthetic_image`` when ``output_region="box"``.
182
+ """
183
+
184
+ outputs: dict[str, str]
185
+ details: RunDetails
186
+
187
+
188
+ type _PathArg = str | PathLike[str]
189
+
190
+
191
+ @dataclass(frozen=True, slots=True)
192
+ class LesionDiffusionOptions:
193
+ """Validated controls for one run.
194
+
195
+ ``organ`` is a key of ``SOURCE_ORGAN_LABELS``. ``bbox_size_mm`` is the box extent
196
+ (X, Y, Z) in mm along the standardized RAS axes, at least 2 mm each.
197
+ ``bbox_center_voxel`` is an optional (I, J, K) voxel index on that grid; when it is
198
+ ``None`` the box is placed from ``seed``. ``placement`` picks the placement rule and
199
+ the cavity phrase in the text. ``shape`` through ``specific_features`` are free
200
+ text for the upstream report. ``seed`` (0 to 2**32 - 1) seeds MT19937 box placement
201
+ and MLX sampling. ``inference_mode`` is ``"conditioned"`` or ``"source"`` and
202
+ ``output_region`` is ``"full"`` or ``"box"``; see ``run``.
203
+
204
+ Raises:
205
+ InvalidInputError: From construction, for any value outside these contracts.
206
+ """
207
+
208
+ organ: str
209
+ lesion_type: str
210
+ bbox_size_mm: tuple[float, float, float]
211
+ placement: str
212
+ shape: str = "irregular"
213
+ density: str = "hypodense lesion"
214
+ density_variation: str = "heterogeneous"
215
+ enhancement: str = "non-contrast CT"
216
+ surface: str = "ill-defined margin"
217
+ adjacent_relationship: str = "no close relationship with surrounding organs"
218
+ specific_features: str = ""
219
+ bbox_center_voxel: tuple[int, int, int] | None = None
220
+ diffusion_steps: int = DEFAULT_DIFFUSION_STEPS
221
+ seed: int = DEFAULT_SEED
222
+ inference_mode: str = DEFAULT_INFERENCE_MODE
223
+ output_region: str = DEFAULT_OUTPUT_REGION
224
+
225
+ def __post_init__(self) -> None:
226
+ if self.inference_mode not in {"conditioned", "source"}:
227
+ raise InvalidInputError("LesionDiffusion inference_mode must be conditioned or source")
228
+ if self.output_region not in {"full", "box"}:
229
+ raise InvalidInputError("LesionDiffusion output_region must be full or box")
230
+ if self.organ not in SOURCE_ORGAN_LABELS:
231
+ raise InvalidInputError(f"Unknown upstream organ: {self.organ}")
232
+ if not self.lesion_type.strip():
233
+ raise InvalidInputError("LesionDiffusion lesion_type must not be empty")
234
+ if len(self.bbox_size_mm) != 3:
235
+ raise InvalidInputError("LesionDiffusion bbox_size_mm must contain X, Y, Z")
236
+ for value in self.bbox_size_mm:
237
+ if not math.isfinite(value):
238
+ raise InvalidInputError("LesionDiffusion bbox_size_mm must be finite")
239
+ if value < 2:
240
+ raise InvalidInputError("LesionDiffusion bbox dimensions must be at least 2 mm")
241
+ if self.placement not in _CAVITY_TEXT:
242
+ raise InvalidInputError(f"Unknown LesionDiffusion placement: {self.placement}")
243
+ for name, value in (
244
+ ("shape", self.shape),
245
+ ("density_variation", self.density_variation),
246
+ ("density", self.density),
247
+ ("enhancement", self.enhancement),
248
+ ("surface", self.surface),
249
+ ("adjacent_relationship", self.adjacent_relationship),
250
+ ):
251
+ if not value.strip():
252
+ raise InvalidInputError(f"LesionDiffusion {name} must not be empty")
253
+ if self.bbox_center_voxel is not None:
254
+ if len(self.bbox_center_voxel) != 3:
255
+ raise InvalidInputError("LesionDiffusion bbox_center_voxel must contain I, J, K")
256
+ if any(not _is_integer(value) for value in self.bbox_center_voxel):
257
+ raise InvalidInputError("LesionDiffusion bbox_center_voxel values must be integers")
258
+ if any(value < 0 for value in self.bbox_center_voxel):
259
+ raise InvalidInputError("LesionDiffusion bbox_center_voxel must not be negative")
260
+ if not _is_integer(self.diffusion_steps):
261
+ raise InvalidInputError("LesionDiffusion diffusion_steps must be an integer")
262
+ try:
263
+ lesion_diffusion_timesteps(self.diffusion_steps)
264
+ except ValueError as exc:
265
+ raise InvalidInputError(str(exc)) from exc
266
+ if not _is_integer(self.seed):
267
+ raise InvalidInputError("LesionDiffusion seed must be an integer")
268
+ if self.seed < 0:
269
+ raise InvalidInputError("LesionDiffusion seed must not be negative")
270
+ if self.seed >= 2**32:
271
+ raise InvalidInputError("LesionDiffusion seed must be less than 2**32 for MT19937")
272
+
273
+
274
+ @dataclass(frozen=True, slots=True)
275
+ class _Geometry:
276
+ """Standardized inputs shared by every stage.
277
+
278
+ ``reference`` and the arrays are on the even-padded RAS 1 mm grid the networks use;
279
+ ``output_reference`` is the unpadded standardized grid that outputs are cropped
280
+ back to. ``bbox_mask`` is uint8 with 2 inside the box. ``center`` is the box
281
+ center (I, J, K) and ``bbox_size_voxel`` its nominal size; the box spans
282
+ ``size // 2`` voxels on each side of the center.
283
+ """
284
+
285
+ reference: NiftiImage
286
+ output_reference: NiftiImage
287
+ source_data: NumericArray
288
+ organ_mask: BoolArray
289
+ organ_label: int
290
+ organ_source: dict[str, object]
291
+ bbox_mask: HostArray
292
+ bbox_size_voxel: tuple[int, int, int]
293
+ center: tuple[int, int, int]
294
+ anatomy_mask: HostArray | None = None
295
+
296
+
297
+ def run(
298
+ *,
299
+ image_path: _PathArg,
300
+ organ_mask_path: _PathArg,
301
+ anatomy_mask_path: _PathArg | None = None,
302
+ output_dir: _PathArg,
303
+ weights_path: _PathArg,
304
+ organ: str,
305
+ lesion_type: str,
306
+ bbox_size_mm: Sequence[float],
307
+ placement: str,
308
+ shape: str = "irregular",
309
+ density: str = "hypodense lesion",
310
+ density_variation: str = "heterogeneous",
311
+ enhancement: str = "non-contrast CT",
312
+ surface: str = "ill-defined margin",
313
+ adjacent_relationship: str = "no close relationship with surrounding organs",
314
+ specific_features: str = "",
315
+ bbox_center_voxel: Sequence[int] | None = None,
316
+ diffusion_steps: int = DEFAULT_DIFFUSION_STEPS,
317
+ seed: int = DEFAULT_SEED,
318
+ inference_mode: str = DEFAULT_INFERENCE_MODE,
319
+ output_region: str = DEFAULT_OUTPUT_REGION,
320
+ overwrite: bool = False,
321
+ ) -> RunResult:
322
+ """Insert one synthetic lesion into a CT and write NIfTI outputs.
323
+
324
+ LMNet generates a lesion mask inside a box, then LINet inpaints the CT latent under
325
+ that mask and the VQ decoder reconstructs the volume (upstream's v2 inpainting
326
+ route). CT intensities are taken as Hounsfield units. The CT and masks must be
327
+ axis-aligned grids; they are reoriented and resampled to RAS 1 mm (linear for the
328
+ CT, nearest for masks) and padded to even extents for the VQ stages, then outputs
329
+ are cropped back to that standardized grid, which is the input grid when the
330
+ input is already RAS 1 mm. The generated lesion is not constrained to the box or
331
+ the organ. Output is synthetic and needs human review.
332
+
333
+ Args:
334
+ image_path: 3-D CT, ``.nii`` or ``.nii.gz``, finite values in HU.
335
+ organ_mask_path: Binary organ mask (0 background, 1 organ) on the same grid
336
+ after standardization.
337
+ anatomy_mask_path: Optional multi-organ map with upstream TotalSegmentator v1
338
+ labels 0-104 on the same grid; its label for ``organ`` must equal the
339
+ binary mask. Without it LMNet sees only the target organ.
340
+ output_dir: Directory for the outputs, created if needed.
341
+ weights_path: Directory written by ``write_conversion``.
342
+ organ: Key of ``SOURCE_ORGAN_LABELS`` (lower-case name, spaces as underscores).
343
+ lesion_type: Free text such as ``"tumor"``. A selected LMNet component of at
344
+ most 400 voxels (50 if the text contains ``"stone"``) triggers one resample.
345
+ bbox_size_mm: Box extent (X, Y, Z) in mm, each at least 2.
346
+ placement: ``inside_parenchyma``, ``protruding_from_parenchyma``,
347
+ ``within_lumen`` or ``wall_thickening``. It guides automatic box placement
348
+ and, in ``conditioned`` mode, LINet's text. LMNet's cavity phrase is fixed
349
+ by organ and lesion type, as in the pinned inference path.
350
+ shape, density, density_variation, enhancement, surface,
351
+ adjacent_relationship, specific_features: Free-text phenotype for the
352
+ upstream report; empty strings are rejected except ``specific_features``.
353
+ bbox_center_voxel: Optional (I, J, K) box center on the standardized grid.
354
+ diffusion_steps: DDIM steps for both stages; must give a valid upstream
355
+ uniform schedule (see ``lesion_diffusion_timesteps``).
356
+ seed: Seeds box placement and native MLX sampling. MLX's RNG differs from
357
+ Torch's, so a seed repeats within MLX only.
358
+ inference_mode: ``"conditioned"`` uses native MLX FP16 convolution and the
359
+ trained LINet text path. ``"source"`` reproduces upstream's disabled image
360
+ text path and Torch CPU reduction order.
361
+ output_region: ``"full"`` writes upstream's full-volume reconstruction, whose HU
362
+ are clipped to [-1000, 1000] and can differ from the input outside the
363
+ lesion. ``"box"`` keeps the generated box, feathers it into the input CT over
364
+ ``LOCALIZATION_MARGIN_MM``, leaves every other voxel exact, and also
365
+ writes the full reconstruction as ``raw_synthetic_image``. The lesion
366
+ mask is not clipped to the box.
367
+ overwrite: Replace existing output files instead of refusing.
368
+
369
+ Returns:
370
+ ``{"outputs": ..., "details": ...}``. ``synthetic_image`` is float32 HU;
371
+ ``target_mask`` and ``organ_mask`` are uint8 with value 1 for lesion and
372
+ organ. All keep the standardized grid's affine, qform and sform.
373
+
374
+ Raises:
375
+ InvalidInputError: For invalid options, unreadable or mismatched inputs, or
376
+ outputs that would overwrite an input or an existing file.
377
+ AssetNotReadyError: If the weights directory is missing or not current.
378
+ ModelExecutionError: If a stage produces non-finite values, LMNet yields an
379
+ empty lesion mask, or another error occurs during execution.
380
+ """
381
+
382
+ options = _parse_options(
383
+ organ=organ,
384
+ lesion_type=lesion_type,
385
+ bbox_size_mm=bbox_size_mm,
386
+ placement=placement,
387
+ shape=shape,
388
+ density=density,
389
+ density_variation=density_variation,
390
+ enhancement=enhancement,
391
+ surface=surface,
392
+ adjacent_relationship=adjacent_relationship,
393
+ specific_features=specific_features,
394
+ bbox_center_voxel=bbox_center_voxel,
395
+ diffusion_steps=diffusion_steps,
396
+ seed=seed,
397
+ inference_mode=inference_mode,
398
+ output_region=output_region,
399
+ )
400
+ source, supplied_mask, outputs = _run_paths(
401
+ image_path=image_path,
402
+ organ_mask_path=organ_mask_path,
403
+ output_dir=output_dir,
404
+ output_region=options.output_region,
405
+ overwrite=overwrite,
406
+ )
407
+ anatomy_path = (
408
+ Path(anatomy_mask_path).expanduser().resolve() if anatomy_mask_path is not None else None
409
+ )
410
+ if anatomy_path in outputs.values():
411
+ raise InvalidInputError("LesionDiffusion output files must not overwrite the anatomy mask")
412
+ directory = require_weights(weights_path, inference_mode=options.inference_mode)
413
+ started_at = datetime.now(UTC)
414
+ started = time.perf_counter()
415
+ try:
416
+ geometry = prepare_geometry(
417
+ source=source,
418
+ supplied_mask=supplied_mask,
419
+ supplied_anatomy=anatomy_path,
420
+ options=options,
421
+ )
422
+ spec, stages = _execute_run(geometry, options, directory, outputs)
423
+ except MedmlxError:
424
+ raise
425
+ except Exception as exc:
426
+ raise ModelExecutionError(f"LesionDiffusion failed during execution: {exc}") from exc
427
+ return _run_result(options, geometry, outputs, directory, spec, stages, started_at, started)
428
+
429
+
430
+ def _run_paths(
431
+ *,
432
+ image_path: _PathArg,
433
+ organ_mask_path: _PathArg,
434
+ output_dir: _PathArg,
435
+ output_region: str,
436
+ overwrite: bool,
437
+ ) -> tuple[Path, Path, dict[str, Path]]:
438
+ if not isinstance(cast(object, overwrite), bool):
439
+ raise InvalidInputError("overwrite must be a boolean")
440
+ source = Path(image_path).expanduser().resolve()
441
+ supplied_mask = Path(organ_mask_path).expanduser().resolve()
442
+ output = Path(output_dir).expanduser().resolve()
443
+ if not source.is_file():
444
+ raise InvalidInputError(f"Missing input CT NIfTI: {source}")
445
+ if not supplied_mask.is_file():
446
+ raise InvalidInputError(f"Missing organ mask NIfTI: {supplied_mask}")
447
+ outputs = output_paths(output, output_region)
448
+ if source in outputs.values():
449
+ raise InvalidInputError("LesionDiffusion output files must not overwrite the input CT")
450
+ if supplied_mask in outputs.values():
451
+ raise InvalidInputError(
452
+ "LesionDiffusion output files must not overwrite the input organ mask"
453
+ )
454
+ if output.exists() and not output.is_dir():
455
+ raise InvalidInputError(f"LesionDiffusion output path is not a directory: {output}")
456
+ if not overwrite:
457
+ existing = [path for path in outputs.values() if path.exists()]
458
+ if existing:
459
+ raise InvalidInputError(
460
+ f"{existing[0]} already exists; pass overwrite=true to replace outputs"
461
+ )
462
+ return source, supplied_mask, outputs
463
+
464
+
465
+ def _execute_run(
466
+ geometry: _Geometry,
467
+ options: LesionDiffusionOptions,
468
+ directory: Path,
469
+ outputs: dict[str, Path],
470
+ ) -> tuple[GenerationSpec, dict[str, dict[str, object]]]:
471
+ spec: dict[str, object] = {
472
+ "schema_version": "medmlx.lesiondiffusion.generation.v1",
473
+ "source_ref": SOURCE_REF,
474
+ "bbox_center_voxel": list(geometry.center),
475
+ "bbox_size_voxel": list(geometry.bbox_size_voxel),
476
+ "placement": options.placement,
477
+ "output_mapping": "source_las_zyx_to_input_ras_grid",
478
+ }
479
+ model_label, _ = source_organ_label(options.organ)
480
+ report = _structured_report(
481
+ reference=geometry.reference,
482
+ organ_mask=geometry.organ_mask,
483
+ organ_label=model_label,
484
+ center=geometry.center,
485
+ bbox_size=geometry.bbox_size_voxel,
486
+ options=options,
487
+ )
488
+ attributes = cast(dict[str, object], cast(list[object], report[str(model_label)])[2])
489
+ outputs["synthetic_image"].parent.mkdir(parents=True, exist_ok=True)
490
+ _save_like_reference(
491
+ data=np.where(geometry.organ_mask, geometry.organ_label, 0).astype(np.uint8),
492
+ reference=geometry.output_reference,
493
+ destination=outputs["organ_mask"],
494
+ dtype=np.uint8,
495
+ )
496
+ mx = cast("Mlx", import_mlx())
497
+ stages: dict[str, dict[str, object]] = {}
498
+ image, mask, facts = run_native_pipeline(
499
+ source_ct_ras=geometry.source_data,
500
+ organ_mask_ras=geometry.organ_mask,
501
+ bbox_mask_ras=geometry.bbox_mask,
502
+ options=options,
503
+ attributes=attributes,
504
+ weights_dir=directory,
505
+ mx=mx,
506
+ on_stage_completed=stages.__setitem__,
507
+ anatomy_mask_ras=geometry.anatomy_mask,
508
+ )
509
+ spec["native_runtime"] = facts
510
+ if geometry.anatomy_mask is not None:
511
+ spec["anatomy_mask"] = geometry.organ_source["anatomy_mask"]
512
+ materialize_arrays(
513
+ outputs=outputs,
514
+ geometry=geometry,
515
+ image_data=image,
516
+ lesion_data=mask,
517
+ generation_spec=spec,
518
+ output_region=options.output_region,
519
+ )
520
+ return cast(GenerationSpec, spec), stages
521
+
522
+
523
+ def _run_result(
524
+ options: LesionDiffusionOptions,
525
+ geometry: _Geometry,
526
+ outputs: dict[str, Path],
527
+ directory: Path,
528
+ spec: GenerationSpec,
529
+ stages: dict[str, dict[str, object]],
530
+ started_at: datetime,
531
+ started: float,
532
+ ) -> RunResult:
533
+ grid = [int(value) for value in geometry.output_reference.shape]
534
+ details: dict[str, object] = {
535
+ "model_id": MODEL_ID,
536
+ "source_ref": SOURCE_REF,
537
+ "organ": options.organ,
538
+ "lesion_type": options.lesion_type,
539
+ "seed": options.seed,
540
+ "diffusion_steps": options.diffusion_steps,
541
+ "generation_spec": spec,
542
+ "stages": stages,
543
+ "output_region": options.output_region,
544
+ "output_kind_by_role": {
545
+ "synthetic_image": "synthetic_image",
546
+ "target_mask": "label_map",
547
+ "organ_mask": "label_map",
548
+ **(
549
+ {"raw_synthetic_image": "synthetic_image"}
550
+ if "raw_synthetic_image" in outputs
551
+ else {}
552
+ ),
553
+ },
554
+ "output_shape_by_role": {role: grid for role in outputs},
555
+ "output_dtype_by_role": {
556
+ "synthetic_image": "float32",
557
+ "target_mask": "uint8",
558
+ "organ_mask": "uint8",
559
+ **({"raw_synthetic_image": "float32"} if "raw_synthetic_image" in outputs else {}),
560
+ },
561
+ "output_labels_by_role": {
562
+ "target_mask": [{"name": options.lesion_type, "value": 1}],
563
+ "organ_mask": [{"name": options.organ, "value": geometry.organ_label}],
564
+ },
565
+ "weights_path": directory.as_posix(),
566
+ "started_at": started_at.isoformat(timespec="seconds"),
567
+ "elapsed_s": round(time.perf_counter() - started, 4),
568
+ "requires_human_review": True,
569
+ "warning": WARNING,
570
+ }
571
+ return {
572
+ "outputs": {role: path.as_posix() for role, path in outputs.items()},
573
+ "details": cast(RunDetails, _json_safe(details)),
574
+ }
575
+
576
+
577
+ def run_native_pipeline(
578
+ *,
579
+ source_ct_ras: NumericArray,
580
+ organ_mask_ras: BoolArray,
581
+ bbox_mask_ras: HostArray,
582
+ options: LesionDiffusionOptions,
583
+ attributes: Mapping[str, object],
584
+ weights_dir: Path,
585
+ mx: Mlx,
586
+ on_stage_completed: Callable[[str, dict[str, object]], None],
587
+ anatomy_mask_ras: HostArray | None = None,
588
+ ) -> tuple[HostArray, HostArray, NativeRuntime]:
589
+ """Run LMNet, then LINet and the VQ decoder, with the released EMA weights.
590
+
591
+ Volumes are in standardized RAS ``(X, Y, Z)`` order on the even-padded grid and are
592
+ converted to upstream's ``(Z, Y, X)`` layout internally. Seeds MLX's RNG with
593
+ ``options.seed``. Each stage is evaluated and exported to the host before the next
594
+ loads, to keep peak memory down.
595
+
596
+ Args:
597
+ source_ct_ras: CT in HU.
598
+ organ_mask_ras: Boolean target-organ mask.
599
+ bbox_mask_ras: Box volume, 2 inside the box and 0 elsewhere (``bbox_mask``).
600
+ options: Validated run options.
601
+ attributes: Upstream report fields from ``_structured_report``.
602
+ weights_dir: Converted-weights directory (see ``require_weights``).
603
+ mx: The imported ``mlx.core`` module.
604
+ on_stage_completed: Called as ``on_stage_completed("lmnet" | "linet", record)``
605
+ after each stage with its settings and timings.
606
+ anatomy_mask_ras: Optional TotalSegmentator v1 label map (0-104); defaults to
607
+ the target organ carrying its upstream label id.
608
+
609
+ Returns:
610
+ ``(generated_ct, label_map, facts)``. ``generated_ct`` is the full
611
+ VQ-reconstructed CT, float32 HU clipped to [-1000, 1000]. ``label_map`` is
612
+ uint8 with 2 for lesion, 1 for the remaining organ voxels and 0 elsewhere.
613
+ ``facts`` records the runtime settings.
614
+
615
+ Raises:
616
+ ModelExecutionError: If a stage produces non-finite values or LMNet yields an
617
+ empty lesion.
618
+ InvalidInputError: If the grid is smaller than a network crop.
619
+ """
620
+ directory = weights_dir
621
+ conversion = load_conversion(directory)
622
+ tokenizer_path = directory / TOKENIZER_FILE
623
+ mx.random.seed(options.seed)
624
+ image = ras_to_source(source_ct_ras).astype(np.float32)
625
+ organ = ras_to_source(organ_mask_ras).astype(bool)
626
+ bbox = ras_to_source(bbox_mask_ras) == 2
627
+ organ_id, _ = source_organ_label(options.organ)
628
+ anatomy = (
629
+ ras_to_source(anatomy_mask_ras)
630
+ if anatomy_mask_ras is not None
631
+ else organ.astype(np.uint8) * organ_id
632
+ )
633
+ lm_attributes = _lmnet_attributes(attributes, options)
634
+ lesion, lm_stage = _run_lmnet(
635
+ organ, bbox, options, lm_attributes, directory, tokenizer_path, mx, anatomy=anatomy
636
+ )
637
+ on_stage_completed("lmnet", lm_stage)
638
+ lin_attributes = _linet_attributes(attributes, options)
639
+ generated_ras, lin_stage = _run_linet(
640
+ image, lesion, options, lin_attributes, directory, tokenizer_path, mx
641
+ )
642
+ on_stage_completed("linet", lin_stage)
643
+ lesion_ras = source_to_ras(np.where(lesion, 2, organ.astype(np.uint8)))
644
+ details: NativeRuntime = {
645
+ "conversion": conversion,
646
+ "device": str(mx.default_device()),
647
+ "dtype": {"lmnet": "float32", "linet": "float16", "vq": "float16"},
648
+ "inference_mode": options.inference_mode,
649
+ "convolution_arithmetic": (
650
+ "torch_cpu_source_reduction" if options.inference_mode == "source" else "mlx_native"
651
+ ),
652
+ "lmnet_attempts": cast(int, lm_stage["attempts"]),
653
+ "lmnet_conditioning": lm_attributes,
654
+ "lmnet_anatomy_context": (
655
+ "supplied_source_labels" if anatomy_mask_ras is not None else "target_organ_only"
656
+ ),
657
+ "linet_conditioning": lin_attributes,
658
+ "linet_skip_context": options.inference_mode == "source",
659
+ "lmnet_stage": lm_stage,
660
+ "linet_stage": lin_stage,
661
+ "model_organ_label": organ_id,
662
+ "sampling": "source_uniform_ddim_eta_1",
663
+ "synchronization": "mx.eval_each_ddim_step_and_export",
664
+ "seed_contract": (
665
+ "MLX native RNG; same seed is reproducible within MLX, not identical to Torch RNG"
666
+ ),
667
+ }
668
+ return generated_ras, lesion_ras, details
669
+
670
+
671
+ def _linet_attributes(
672
+ attributes: Mapping[str, object], options: LesionDiffusionOptions
673
+ ) -> dict[str, object]:
674
+ _, organ_name = source_organ_label(options.organ)
675
+ omitted = {"size"}
676
+ if options.inference_mode == "conditioned":
677
+ # The report's numeric CT value is the input organ mean, not a requested
678
+ # lesion density. It must not override the user's categorical phenotype.
679
+ omitted.add("CT value")
680
+ return {
681
+ **{
682
+ key: value
683
+ for key, value in attributes.items()
684
+ if key not in omitted and (options.inference_mode == "source" or value != "")
685
+ },
686
+ "lesion location": organ_name,
687
+ }
688
+
689
+
690
+ def _lmnet_attributes(
691
+ attributes: Mapping[str, object],
692
+ options: LesionDiffusionOptions,
693
+ ) -> dict[str, object]:
694
+ _, organ_name = source_organ_label(options.organ)
695
+ lm_attributes = dict(attributes)
696
+ lm_attributes["lesion location"] = organ_name
697
+ # The mask generator is not given size, density or the organ-mean CT sentence.
698
+ for key in ("size", "CT value", "density variations", "density"):
699
+ lm_attributes.pop(key, None)
700
+ if not lm_attributes.get("specific features"):
701
+ lm_attributes.pop("specific features", None)
702
+ # This matches the pinned inference path: its supplied coarseg and target
703
+ # are identical, so solid organs receive the protruding condition. The
704
+ # ``placement`` option does not enter here; it only shapes box placement and
705
+ # LINet's text. Gallbladder counts as hollow here but not for box placement.
706
+ lm_attributes["cavity"] = (
707
+ (
708
+ "within the lumen of a hollow organ,"
709
+ if "stone" in options.lesion_type
710
+ else "wall thickening,"
711
+ )
712
+ if options.organ in {"colon", "urinary_bladder", "esophagus", "gallbladder", "stomach"}
713
+ else "protruding from the parenchymal organ,"
714
+ )
715
+ return lm_attributes
716
+
717
+
718
+ def _run_lmnet(
719
+ organ: BoolArray,
720
+ bbox: BoolArray,
721
+ options: LesionDiffusionOptions,
722
+ lm_attributes: Mapping[str, object],
723
+ directory: Path,
724
+ tokenizer_path: Path,
725
+ mx: Mlx,
726
+ *,
727
+ anatomy: HostArray,
728
+ ) -> tuple[BoolArray, dict[str, object]]:
729
+ crop = source_crop(bbox, LMNET_CROP)
730
+ lm_started = time.perf_counter()
731
+ optimized = options.inference_mode == "conditioned"
732
+ lmnet = LdmUnet(
733
+ load_stage(directory, "lmnet", mx, dtype=mx.float32),
734
+ mx,
735
+ LMNET_CONFIG,
736
+ fused_attention=optimized,
737
+ )
738
+ text = BiomedTextEncoder(load_stage(directory, "lmnet_text", mx, dtype=mx.float32), mx)
739
+ condition = encode_attributes(lm_attributes, text, tokenizer_path, mx)
740
+ denoiser = mx.compile(lmnet.__call__) if optimized else lmnet.__call__
741
+ # Conditioning channels: anatomy label id / 104 (the upstream label count),
742
+ # target organ, box; shape (1, 3, *LMNET_CROP).
743
+ conditioning = cast(ArrayConstructor, mx.array)(
744
+ np.stack((anatomy[crop].astype(np.float64) / 104.0, organ[crop], bbox[crop]))[None],
745
+ dtype=mx.float32,
746
+ )
747
+ sampler = DdimSampler(lmnet.weights["alphas_cumprod"], mx, options.diffusion_steps)
748
+ lesion = np.zeros(bbox.shape, dtype=bool)
749
+ attempts = 0
750
+ samples: Array | None = None
751
+ # Volume QC: resample once if the selected component is too small (at most 400
752
+ # voxels, 50 for stones); the second attempt is kept either way.
753
+ for attempt in range(1, 3):
754
+ attempts = attempt
755
+ samples = sampler(denoiser, condition, conditioning)
756
+ mx.eval(samples)
757
+ if not bool(mx.all(mx.isfinite(samples)).item()):
758
+ raise ModelExecutionError("Native LMNet produced non-finite samples")
759
+ component = select_lesion_component(cast(FloatArray, np.array(samples))[0, 0], bbox[crop])
760
+ lesion[crop] = component
761
+ if int(np.sum(component)) > (50 if "stone" in options.lesion_type else 400):
762
+ break
763
+ # Release LMNet before LINet and the VQ autoencoder load, to bound peak memory.
764
+ del lmnet, denoiser, text, condition, conditioning, sampler, samples
765
+ gc.collect()
766
+ mx.clear_cache()
767
+ if not np.any(lesion):
768
+ raise ModelExecutionError(
769
+ "LesionDiffusion LMNet QC rejected this seed: mask has no lesion label 2"
770
+ )
771
+ lm_stage: dict[str, object] = {
772
+ "status": "completed",
773
+ "device": str(mx.default_device()),
774
+ "dtype": "float32",
775
+ "crop_shape": list(LMNET_CROP),
776
+ "diffusion_steps_actual": len(lesion_diffusion_timesteps(options.diffusion_steps)),
777
+ "attempts": attempts,
778
+ "lesion_voxels": int(np.sum(lesion)),
779
+ "finite": True,
780
+ "attention": "mlx_fused_padded_fp32" if optimized else "source_explicit",
781
+ "denoiser_compiled": optimized,
782
+ "synchronized_elapsed_s": time.perf_counter() - lm_started,
783
+ }
784
+ return lesion, lm_stage
785
+
786
+
787
+ def _run_linet(
788
+ image: FloatArray,
789
+ lesion: BoolArray,
790
+ options: LesionDiffusionOptions,
791
+ lin_attributes: Mapping[str, object],
792
+ directory: Path,
793
+ tokenizer_path: Path,
794
+ mx: Mlx,
795
+ ) -> tuple[HostArray, dict[str, object]]:
796
+ lin_started = time.perf_counter()
797
+ source_arithmetic = options.inference_mode == "source"
798
+ linet = LdmUnet(
799
+ load_stage(directory, "linet", mx, dtype=mx.float16),
800
+ mx,
801
+ replace(LINET_CONFIG, skip_context=source_arithmetic),
802
+ source_arithmetic=source_arithmetic,
803
+ )
804
+ vq = VqAutoencoder(
805
+ load_stage(directory, "vq", mx, dtype=mx.float16),
806
+ mx,
807
+ source_arithmetic=source_arithmetic,
808
+ )
809
+ condition: Array | None = None
810
+ if not source_arithmetic:
811
+ text = BiomedTextEncoder(load_stage(directory, "linet_text", mx, dtype=mx.float32), mx)
812
+ condition = encode_attributes(lin_attributes, text, tokenizer_path, mx)
813
+ del text
814
+ # Force the lazy weight loads here so load_and_condition_s measures them.
815
+ mx.eval(*linet.weights.values(), *vq.weights.values())
816
+ load_elapsed = time.perf_counter() - lin_started
817
+ encode_started = time.perf_counter()
818
+ native_image = piecewise_ct(cast(ArrayConstructor, mx.array)(image[None, None]), mx).astype(
819
+ mx.float16
820
+ )
821
+ latent = encode_source(vq, native_image, mx)
822
+ mx.eval(latent)
823
+ if not bool(mx.all(mx.isfinite(latent)).item()):
824
+ raise ModelExecutionError("Native VQ encoder produced non-finite latents")
825
+ encode_elapsed = time.perf_counter() - encode_started
826
+ # Lesion mask on the latent grid: nearest-neighbour resize, then binarize.
827
+ mask = vq.resize_nearest(
828
+ cast(ArrayConstructor, mx.array)(lesion[None, None], dtype=mx.float16),
829
+ tuple(int(value) for value in latent.shape[2:]),
830
+ )
831
+ mask = (mask > 0.5).astype(mx.float16)
832
+ crop = source_crop(lesion, LINET_CROP)
833
+ # The latent has half the image resolution (VQ factor two), so halve the crop.
834
+ latent_crop = tuple(slice(int(axis.start) // 2, int(axis.stop) // 2) for axis in crop)
835
+ selection = (slice(None), slice(None), *latent_crop)
836
+ sampler = DdimSampler(linet.weights["alphas_cumprod"], mx, options.diffusion_steps)
837
+ denoiser = linet.__call__ if source_arithmetic else mx.compile(linet.__call__)
838
+ mx.eval(mask)
839
+ sampling_started = time.perf_counter()
840
+ generated = sampler(denoiser, condition, latent[selection], mask=mask[selection])
841
+ latent[selection] = generated.astype(mx.float16)
842
+ mx.eval(latent)
843
+ if not bool(mx.all(mx.isfinite(latent)).item()):
844
+ raise ModelExecutionError("Native LINet produced non-finite latents before quantization")
845
+ sampling_elapsed = time.perf_counter() - sampling_started
846
+ # Free the UNet before decoding; only the VQ autoencoder and latent are still needed.
847
+ del linet, denoiser, sampler, generated, native_image, mask, condition
848
+ gc.collect()
849
+ mx.clear_cache()
850
+ decode_started = time.perf_counter()
851
+ decoded = vq.decode(latent)
852
+ mx.eval(decoded)
853
+ if not bool(mx.all(mx.isfinite(decoded)).item()):
854
+ raise ModelExecutionError(
855
+ "Native VQ decoder produced non-finite voxels before CT remapping"
856
+ )
857
+ decoded = piecewise_ct(decoded.astype(mx.float32), mx, inverse=True)
858
+ mx.eval(decoded)
859
+ if not bool(mx.all(mx.isfinite(decoded)).item()):
860
+ raise ModelExecutionError("Native LINet/VQ produced non-finite CT voxels")
861
+ generated_ras = source_to_ras(np.array(decoded)[0, 0]).astype(np.float32)
862
+ decode_elapsed = time.perf_counter() - decode_started
863
+ lin_stage: dict[str, object] = {
864
+ "status": "completed",
865
+ "device": str(mx.default_device()),
866
+ "dtype": "float16",
867
+ "crop_shape": list(LINET_CROP),
868
+ "latent_shape": list(latent.shape),
869
+ "output_shape": list(image.shape),
870
+ "diffusion_steps_actual": len(lesion_diffusion_timesteps(options.diffusion_steps)),
871
+ "finite": True,
872
+ "attention": "source_explicit",
873
+ "denoiser_compiled": not source_arithmetic,
874
+ "load_and_condition_s": load_elapsed,
875
+ "encode_s": encode_elapsed,
876
+ "sampling_s": sampling_elapsed,
877
+ "decode_and_export_s": decode_elapsed,
878
+ "synchronized_elapsed_s": time.perf_counter() - lin_started,
879
+ }
880
+ return generated_ras, lin_stage
881
+
882
+
883
+ def select_lesion_component(samples: NumericArray, reference: BoolArray) -> BoolArray:
884
+ """Pick the lesion mask from LMNet samples as upstream does.
885
+
886
+ For each threshold in 0.9, 0.8, ..., 0.3 the samples are binarized (``>``), reduced
887
+ to their largest connected component (SciPy's default face connectivity), and the
888
+ threshold whose component volume is closest to the box volume wins; ties keep
889
+ the higher threshold.
890
+
891
+ Args:
892
+ samples: LMNet output crop ``(D, H, W)`` in source order.
893
+ reference: Boolean box mask of the same shape; only its voxel count is used.
894
+
895
+ Returns:
896
+ Boolean lesion mask with the shape of ``samples``.
897
+
898
+ Raises:
899
+ ModelExecutionError: If no threshold yields a candidate.
900
+ """
901
+ from scipy import ndimage
902
+
903
+ best: BoolArray | None = None
904
+ difference = float("inf")
905
+ for threshold in np.arange(0.9, 0.2, -0.1):
906
+ mask = samples > threshold
907
+ labels, count = ndimage.label(mask)
908
+ if count:
909
+ sizes = ndimage.sum(mask, labels, range(count + 1))
910
+ mask = labels == int(np.argmax(sizes[1:]) + 1)
911
+ current = abs(int(np.sum(mask)) - int(np.sum(reference)))
912
+ if current < difference:
913
+ difference, best = current, mask.copy()
914
+ if best is None:
915
+ raise ModelExecutionError("LesionDiffusion lesion selection found no threshold")
916
+ return best
917
+
918
+
919
+ def source_organ_label(organ: str) -> tuple[int, str]:
920
+ """Return ``(upstream label id, report name)`` for an organ key of ``SOURCE_ORGAN_LABELS``."""
921
+
922
+ return SOURCE_ORGAN_LABELS[organ]
923
+
924
+
925
+ # --- converted weights ----------------------------------------------------------------
926
+
927
+
928
+ def _stage_file(directory: Path, name: str) -> Path:
929
+ """Use the consolidated release or an existing local NPZ conversion."""
930
+ path = directory / "model.safetensors"
931
+ return path if path.is_file() else directory / (name + ".npz")
932
+
933
+
934
+ def require_weights(path: _PathArg, *, inference_mode: str = DEFAULT_INFERENCE_MODE) -> Path:
935
+ """Return the resolved weights directory after checking it is a current conversion.
936
+
937
+ Stages come from per-stage ``.npz`` files or one consolidated ``model.safetensors``
938
+ whose keys are prefixed ``<stage>.``. ``tokenizer.json`` and a current
939
+ ``conversion.json`` are always required. ``source`` mode does not need the LINet
940
+ text encoder (``linet_text``).
941
+
942
+ Raises:
943
+ AssetNotReadyError: If the directory, a stage, the tokenizer or the metadata is
944
+ missing, or the metadata does not match this package's conversion schema.
945
+ """
946
+
947
+ directory = Path(path).expanduser().resolve()
948
+ if not directory.is_dir():
949
+ raise AssetNotReadyError(
950
+ f"LesionDiffusion MLX weights directory is missing: {directory}",
951
+ reason=f"missing {directory.as_posix()}",
952
+ hint=WEIGHTS_HINT,
953
+ )
954
+ inference_files = SOURCE_INFERENCE_FILES if inference_mode == "source" else CONVERTED_FILES
955
+ files = [_stage_file(directory, Path(name).stem) for name in inference_files]
956
+ files.extend((directory / TOKENIZER_FILE, directory / CONVERSION_FILE))
957
+ missing = [file for file in files if not file.is_file()]
958
+ if missing:
959
+ raise AssetNotReadyError(
960
+ f"Native MLX LesionDiffusion weights are not ready: missing {missing[0]}",
961
+ reason=f"Missing native LesionDiffusion asset: {missing[0]}",
962
+ hint=WEIGHTS_HINT,
963
+ )
964
+ packed = directory / "model.safetensors"
965
+ if packed.is_file():
966
+ from safetensors import safe_open
967
+
968
+ with safe_open(packed, framework="np") as tensors:
969
+ names = tensors.keys()
970
+ present = {key.split(".", 1)[0] for key in names}
971
+ for name in inference_files:
972
+ if Path(name).stem not in present:
973
+ raise AssetNotReadyError(
974
+ f"Native MLX LesionDiffusion weights are missing stage {Path(name).stem}",
975
+ reason=f"Missing stage in {packed.name}",
976
+ hint=WEIGHTS_HINT,
977
+ )
978
+ try:
979
+ load_conversion(directory)
980
+ except (ValueError, OSError) as exc:
981
+ raise AssetNotReadyError(
982
+ f"Native MLX LesionDiffusion weights are not ready: {exc}",
983
+ reason=str(exc),
984
+ hint=WEIGHTS_HINT,
985
+ ) from exc
986
+ return directory
987
+
988
+
989
+ def load_conversion(directory: Path) -> dict[str, object]:
990
+ """Read and check ``conversion.json``; return its provenance fields.
991
+
992
+ Raises:
993
+ ValueError: If the file is not a JSON object, or its schema, source revision,
994
+ EMA flag, layout or precision profile is not the current one.
995
+ OSError: If the file cannot be read.
996
+ """
997
+ metadata = _load_json_object(directory / CONVERSION_FILE)
998
+ if (
999
+ metadata.get("schema_version") != CONVERSION_SCHEMA
1000
+ or metadata.get("source_ref") != SOURCE_REF
1001
+ ):
1002
+ raise ValueError("LesionDiffusion MLX conversion schema/source is not current")
1003
+ if metadata.get("converter_version") != CONVERSION_VERSION or metadata.get("ema") is not True:
1004
+ raise ValueError("LesionDiffusion MLX conversion must use current EMA conversion")
1005
+ if metadata.get("layout") != "source_torch_ncdhw" or metadata.get("stored_dtype") != "float32":
1006
+ raise ValueError("LesionDiffusion MLX conversion layout/dtype is not current")
1007
+ if metadata.get("inference_dtype") != {
1008
+ "lmnet": "float32",
1009
+ "linet": "float16",
1010
+ "vq": "float16",
1011
+ }:
1012
+ raise ValueError("LesionDiffusion MLX conversion precision profile is not current")
1013
+ return {
1014
+ key: metadata[key]
1015
+ for key in (
1016
+ "schema_version",
1017
+ "converter_version",
1018
+ "source_ref",
1019
+ "layout",
1020
+ "stored_dtype",
1021
+ "inference_dtype",
1022
+ "ema",
1023
+ )
1024
+ }
1025
+
1026
+
1027
+ def load_stage(directory: Path, name: str, mx: Mlx, *, dtype: Dtype) -> dict[str, Array]:
1028
+ """Load one stage's converted arrays, cast to ``dtype``.
1029
+
1030
+ Keys are upstream state-dict names (any ``<stage>.`` prefix from a consolidated
1031
+ ``model.safetensors`` is removed). Checks ``conversion.json`` first.
1032
+ """
1033
+ load_conversion(directory)
1034
+ path = _stage_file(directory, name)
1035
+ loaded = mx.load(str(path))
1036
+ if path.name == "model.safetensors":
1037
+ prefix = name + "."
1038
+ return {
1039
+ key.removeprefix(prefix): value.astype(dtype)
1040
+ for key, value in loaded.items()
1041
+ if key.startswith(prefix)
1042
+ }
1043
+ return {key: value.astype(dtype) for key, value in loaded.items()}
1044
+
1045
+
1046
+ def _load_json_object(path: Path) -> dict[str, object]:
1047
+ try:
1048
+ payload: object = json.loads(path.read_text(encoding="utf-8"))
1049
+ except (UnicodeError, json.JSONDecodeError) as exc:
1050
+ raise ValueError(f"Could not read JSON object: {path}") from exc
1051
+ if not isinstance(payload, dict):
1052
+ raise ValueError(f"Expected JSON object: {path}")
1053
+ return cast(dict[str, object], payload)
1054
+
1055
+
1056
+ # --- options --------------------------------------------------------------------------
1057
+
1058
+
1059
+ def _parse_options(
1060
+ *,
1061
+ organ: object,
1062
+ lesion_type: object,
1063
+ bbox_size_mm: object,
1064
+ placement: object,
1065
+ shape: object,
1066
+ density: object,
1067
+ density_variation: object,
1068
+ enhancement: object,
1069
+ surface: object,
1070
+ adjacent_relationship: object,
1071
+ specific_features: object,
1072
+ bbox_center_voxel: object,
1073
+ diffusion_steps: object,
1074
+ seed: object,
1075
+ inference_mode: object,
1076
+ output_region: object = DEFAULT_OUTPUT_REGION,
1077
+ ) -> LesionDiffusionOptions:
1078
+ for name, value in (
1079
+ ("organ", organ),
1080
+ ("lesion_type", lesion_type),
1081
+ ("placement", placement),
1082
+ ("shape", shape),
1083
+ ("density", density),
1084
+ ("density_variation", density_variation),
1085
+ ("enhancement", enhancement),
1086
+ ("surface", surface),
1087
+ ("adjacent_relationship", adjacent_relationship),
1088
+ ("specific_features", specific_features),
1089
+ ("inference_mode", inference_mode),
1090
+ ("output_region", output_region),
1091
+ ):
1092
+ if not isinstance(value, str):
1093
+ raise InvalidInputError(f"LesionDiffusion {name} must be a string")
1094
+ if isinstance(bbox_size_mm, str | bytes) or not isinstance(bbox_size_mm, Sequence):
1095
+ raise InvalidInputError("LesionDiffusion bbox_size_mm must contain X, Y, Z")
1096
+ size_values = cast(Sequence[object], bbox_size_mm)
1097
+ if any(isinstance(value, bool) or not isinstance(value, int | float) for value in size_values):
1098
+ raise InvalidInputError("LesionDiffusion bbox_size_mm values must be numbers")
1099
+ center: tuple[int, int, int] | None = None
1100
+ if bbox_center_voxel is not None:
1101
+ if isinstance(bbox_center_voxel, str | bytes) or not isinstance(
1102
+ bbox_center_voxel, Sequence
1103
+ ):
1104
+ raise InvalidInputError("LesionDiffusion bbox_center_voxel must contain I, J, K")
1105
+ center = cast(tuple[int, int, int], tuple(cast(Sequence[object], bbox_center_voxel)))
1106
+ return LesionDiffusionOptions(
1107
+ organ=cast(str, organ),
1108
+ lesion_type=cast(str, lesion_type),
1109
+ bbox_size_mm=cast(
1110
+ tuple[float, float, float],
1111
+ tuple(float(value) for value in cast(Sequence[int | float], size_values)),
1112
+ ),
1113
+ placement=cast(str, placement),
1114
+ shape=cast(str, shape),
1115
+ density=cast(str, density),
1116
+ density_variation=cast(str, density_variation),
1117
+ enhancement=cast(str, enhancement),
1118
+ surface=cast(str, surface),
1119
+ adjacent_relationship=cast(str, adjacent_relationship),
1120
+ specific_features=cast(str, specific_features),
1121
+ bbox_center_voxel=center,
1122
+ diffusion_steps=cast(int, diffusion_steps),
1123
+ seed=cast(int, seed),
1124
+ inference_mode=cast(str, inference_mode),
1125
+ output_region=cast(str, output_region),
1126
+ )
1127
+
1128
+
1129
+ def _is_integer(value: object) -> bool:
1130
+ return isinstance(value, int) and not isinstance(value, bool)
1131
+
1132
+
1133
+ # --- geometry -------------------------------------------------------------------------
1134
+
1135
+
1136
+ def prepare_geometry(
1137
+ *,
1138
+ source: Path,
1139
+ supplied_mask: Path,
1140
+ options: LesionDiffusionOptions,
1141
+ supplied_anatomy: Path | None = None,
1142
+ ) -> _Geometry:
1143
+ """Standardize the inputs and place the box.
1144
+
1145
+ Loads the CT onto the RAS 1 mm grid, edge-pads to even extents, loads the organ
1146
+ mask (and optional anatomy map) onto the same grid, converts ``bbox_size_mm`` to
1147
+ voxels with ``round`` at the grid spacing, and resolves the box center.
1148
+
1149
+ Raises:
1150
+ InvalidInputError: If a file is unreadable, a grid is oblique or does not
1151
+ match, a mask has invalid labels, the anatomy map disagrees with the organ
1152
+ mask or uses labels above 104, or the box does not fit.
1153
+ """
1154
+ output_reference, standardized = _load_standardized_ct(source)
1155
+ reference, source_data = _pad_to_even(output_reference, standardized, mode="edge")
1156
+ organ_label = 1
1157
+ organ_mask, organ_source = prepare_organ_mask(
1158
+ reference=reference,
1159
+ supplied_mask=supplied_mask,
1160
+ )
1161
+ anatomy_mask = None
1162
+ if supplied_anatomy is not None:
1163
+ anatomy_mask = _load_label_map(reference, supplied_anatomy, "anatomy mask")
1164
+ values = np.unique(anatomy_mask)
1165
+ if np.any(values > 104):
1166
+ raise InvalidInputError(
1167
+ "Anatomy mask must use upstream TotalSegmentator v1 labels 0-104"
1168
+ )
1169
+ source_label, _ = source_organ_label(options.organ)
1170
+ if not np.array_equal(anatomy_mask == source_label, organ_mask):
1171
+ raise InvalidInputError(
1172
+ "Anatomy mask selected-organ label must match the binary organ mask"
1173
+ )
1174
+ anatomy_mask = anatomy_mask.astype(np.uint8)
1175
+ organ_source["anatomy_mask"] = {
1176
+ "path": supplied_anatomy.as_posix(),
1177
+ "label_vocabulary": "upstream_totalsegmentator_v1_104",
1178
+ "labels_present": [int(value) for value in values],
1179
+ }
1180
+ spacing_mm = tuple(float(value) for value in reference.header.get_zooms()[:3])
1181
+ bbox_size = tuple(
1182
+ int(round(options.bbox_size_mm[index] / spacing_mm[index])) # noqa: RUF046 - verbatim
1183
+ for index in range(3)
1184
+ )
1185
+ bbox_size_voxel = cast(tuple[int, int, int], bbox_size)
1186
+ center = resolve_bbox_center(
1187
+ organ_mask=organ_mask,
1188
+ bbox_size=bbox_size_voxel,
1189
+ placement=options.placement,
1190
+ requested_center=options.bbox_center_voxel,
1191
+ seed=options.seed,
1192
+ organ=options.organ,
1193
+ )
1194
+ bbox_array = bbox_mask(reference.shape, center, bbox_size_voxel)
1195
+ return _Geometry(
1196
+ reference,
1197
+ output_reference,
1198
+ source_data,
1199
+ organ_mask,
1200
+ organ_label,
1201
+ organ_source,
1202
+ bbox_array,
1203
+ bbox_size_voxel,
1204
+ center,
1205
+ anatomy_mask,
1206
+ )
1207
+
1208
+
1209
+ _RAS_AXCODES: Final = ("R", "A", "S")
1210
+
1211
+
1212
+ def _is_ras_1mm_axis_aligned(image: NiftiImage) -> bool:
1213
+ """True when ``image`` is already RAS, 1 mm isotropic, and axis-aligned."""
1214
+ orientation = tuple(str(value) for value in nib.aff2axcodes(image.affine))
1215
+ spacing = tuple(float(value) for value in image.header.get_zooms()[:3])
1216
+ return bool(
1217
+ orientation == _RAS_AXCODES
1218
+ and np.allclose(spacing, (1.0, 1.0, 1.0), atol=0.01, rtol=0)
1219
+ and np.allclose(image.affine[:3, :3], np.eye(3), atol=1e-4, rtol=0)
1220
+ )
1221
+
1222
+
1223
+ def _standardize_to_ras_1mm(
1224
+ image: NiftiImage, data: NumericArray, *, order: Literal[0, 1]
1225
+ ) -> tuple[NiftiImage, NumericArray]:
1226
+ """Reorient and resample to an axis-aligned RAS 1 mm grid, as upstream's offline
1227
+ ``preprocess.standardize_image`` does, when the input is not already on one.
1228
+
1229
+ Spatial units are normalized to millimeters, including affine translations
1230
+ and coded forms. An unspecified unit retains the established mm interpretation.
1231
+ Reorientation (axis permutation and sign flips) is exact; isotropic resampling
1232
+ uses ``scipy.ndimage.zoom`` (linear for ``order=1`` intensity volumes, nearest
1233
+ for ``order=0`` label maps). Already-conforming input is returned unchanged.
1234
+ A grid that is still not axis-aligned after reorientation (a true oblique or
1235
+ sheared affine) is rejected rather than resampled; this port does not implement
1236
+ general affine resampling.
1237
+ """
1238
+ try:
1239
+ spatial_unit, temporal_unit = image.header.get_xyzt_units()
1240
+ scale = {"unknown": 1.0, "mm": 1.0, "meter": 1000.0, "micron": 0.001}[spatial_unit]
1241
+ except KeyError as error:
1242
+ raise InvalidInputError(
1243
+ "LesionDiffusion NIfTI units must be mm, meter, micron or unknown"
1244
+ ) from error
1245
+ if spatial_unit != "mm":
1246
+ affine_mm = np.array(image.affine, dtype=np.float64, copy=True)
1247
+ affine_mm[:3] *= scale
1248
+ header = nib.Nifti1Header.from_header(image.header)
1249
+ header.set_xyzt_units("mm", temporal_unit)
1250
+ normalized = nib.Nifti1Image(cast(HostArray, data), affine_mm, header)
1251
+ for read_form, write_form in (
1252
+ (image.get_qform, normalized.set_qform),
1253
+ (image.get_sform, normalized.set_sform),
1254
+ ):
1255
+ form, code = read_form(coded=True)
1256
+ form_mm = np.array(affine_mm if form is None else form, copy=True)
1257
+ if form is not None:
1258
+ form_mm[:3] *= scale
1259
+ write_form(form_mm, code=int(code))
1260
+ image = normalized
1261
+ if _is_ras_1mm_axis_aligned(image):
1262
+ return image, data
1263
+ affine = cast(HostArray, np.asarray(image.affine, dtype=np.float64))
1264
+ current = nib.io_orientation(affine)
1265
+ target = nib.orientations.axcodes2ornt(_RAS_AXCODES)
1266
+ transform = nib.orientations.ornt_transform(current, target)
1267
+ reoriented = cast(
1268
+ NumericArray, nib.orientations.apply_orientation(cast(HostArray, data), transform)
1269
+ )
1270
+ reoriented_affine = affine.dot(nib.orientations.inv_ornt_aff(transform, data.shape))
1271
+ linear = reoriented_affine[:3, :3]
1272
+ if not np.allclose(linear, np.diag(np.diagonal(linear)), atol=1e-4, rtol=0):
1273
+ raise InvalidInputError(
1274
+ "LesionDiffusion requires an axis-aligned grid; this NIfTI's transform is "
1275
+ "oblique and is not resampled automatically"
1276
+ )
1277
+ zoom_factors = tuple(float(value) for value in np.abs(np.diagonal(linear)))
1278
+ if np.allclose(zoom_factors, 1.0, atol=1e-6):
1279
+ resampled, new_affine = reoriented, reoriented_affine
1280
+ else:
1281
+ from scipy.ndimage import zoom
1282
+
1283
+ resampled = cast(NumericArray, zoom(reoriented, zoom_factors, order=order))
1284
+ new_affine = cast(HostArray, reoriented_affine.copy())
1285
+ new_affine[:3, :3] = np.diag(np.sign(np.diagonal(linear)))
1286
+ header = nib.Nifti1Header.from_header(image.header)
1287
+ header.set_data_dtype(resampled.dtype.type)
1288
+ standardized = nib.Nifti1Image(cast(HostArray, resampled), new_affine, header)
1289
+ return standardized, resampled
1290
+
1291
+
1292
+ def _pad_to_even(
1293
+ image: NiftiImage, data: NumericArray, *, mode: Literal["edge", "constant"]
1294
+ ) -> tuple[NiftiImage, NumericArray]:
1295
+ """Pad trailing voxels so every spatial axis is even, keeping the origin fixed.
1296
+
1297
+ Resampling to 1 mm can land on an odd extent; the full-volume VQ stages need
1298
+ even dimensions. ``edge`` repeats the border voxel (for an intensity volume),
1299
+ ``constant`` zero-pads (for a label mask already handled as background 0).
1300
+ """
1301
+ pad_width = tuple((0, 1) if size % 2 else (0, 0) for size in data.shape)
1302
+ if not any(width[1] for width in pad_width):
1303
+ return image, data
1304
+ padded = (
1305
+ np.pad(data, pad_width, mode="edge")
1306
+ if mode == "edge"
1307
+ else np.pad(data, pad_width, mode="constant", constant_values=0)
1308
+ )
1309
+ header = nib.Nifti1Header.from_header(image.header)
1310
+ header.set_data_dtype(padded.dtype.type)
1311
+ padded_image = nib.Nifti1Image(cast(HostArray, padded), image.affine, header)
1312
+ return padded_image, padded
1313
+
1314
+
1315
+ def load_reference_nifti(path: Path) -> tuple[NiftiImage, NumericArray]:
1316
+ """Load the CT on the even-padded RAS 1 mm grid the networks consume."""
1317
+ image, data = _load_standardized_ct(path)
1318
+ return _pad_to_even(image, data, mode="edge")
1319
+
1320
+
1321
+ def _load_standardized_ct(path: Path) -> tuple[NiftiImage, NumericArray]:
1322
+ """Load a finite 3-D NIfTI CT and standardize it to the RAS 1 mm grid (unpadded)."""
1323
+ if not path.name.endswith((".nii", ".nii.gz")):
1324
+ raise InvalidInputError("LesionDiffusion input must be a .nii or .nii.gz NIfTI")
1325
+ try:
1326
+ image = nib.load(str(path))
1327
+ except (OSError, ValueError, ImageFileError) as exc:
1328
+ raise InvalidInputError(f"Could not read input CT NIfTI: {path}") from exc
1329
+ if len(image.shape) != 3:
1330
+ raise InvalidInputError(f"LesionDiffusion input must be 3D; got {image.shape}")
1331
+ if not np.all(np.isfinite(image.affine)):
1332
+ raise InvalidInputError("LesionDiffusion input affine contains non-finite values")
1333
+ data = cast(NumericArray, np.asanyarray(image.dataobj))
1334
+ if not np.all(np.isfinite(data)):
1335
+ raise InvalidInputError("LesionDiffusion input CT contains non-finite values")
1336
+ return _standardize_to_ras_1mm(image, data, order=1)
1337
+
1338
+
1339
+ def prepare_organ_mask(
1340
+ *,
1341
+ reference: NiftiImage,
1342
+ supplied_mask: Path,
1343
+ ) -> tuple[BoolArray, dict[str, object]]:
1344
+ """Load the binary organ mask on the CT's even-padded grid.
1345
+
1346
+ Returns:
1347
+ ``(mask, provenance)``: a boolean array on ``reference``'s grid and a record of
1348
+ the mask file.
1349
+
1350
+ Raises:
1351
+ InvalidInputError: If the file is unreadable, the grid differs from
1352
+ ``reference``, labels are negative or fractional, or the foreground is
1353
+ not exactly the single label 1 (this also rejects an empty mask).
1354
+ """
1355
+ provenance: dict[str, object] = {
1356
+ "producer": "supplied_organ_mask",
1357
+ "mask": supplied_mask.as_posix(),
1358
+ "binary_label_id": 1,
1359
+ "requires_human_review": True,
1360
+ "is_ground_truth": False,
1361
+ }
1362
+ data = _load_label_map(reference, supplied_mask, "organ mask")
1363
+ positive = np.unique(data[data > 0])
1364
+ if len(positive) != 1:
1365
+ raise InvalidInputError(
1366
+ "Organ mask must contain exactly one foreground label; multi-label maps are ambiguous"
1367
+ )
1368
+ if positive[0] != 1:
1369
+ raise InvalidInputError(
1370
+ "Supplied organ mask must be binary with label 1 for the selected organ; "
1371
+ "extract that organ from a labeled anatomy map first"
1372
+ )
1373
+ return data > 0, provenance
1374
+
1375
+
1376
+ def _load_label_map(reference: NiftiImage, supplied_mask: Path, role: str) -> NumericArray:
1377
+ """Load an integer label map onto ``reference``'s grid (nearest-neighbour, zero-padded).
1378
+
1379
+ ``role`` names the map in error messages. Raises ``InvalidInputError`` if the file
1380
+ is unreadable, has non-finite or non-integer or negative labels, or its grid
1381
+ differs from ``reference`` after standardization.
1382
+ """
1383
+ if not supplied_mask.is_file():
1384
+ raise InvalidInputError(f"Missing {role} NIfTI: {supplied_mask}")
1385
+ try:
1386
+ image = nib.load(str(supplied_mask))
1387
+ except (OSError, ValueError, ImageFileError) as exc:
1388
+ raise InvalidInputError(f"Could not read {role} NIfTI: {supplied_mask}") from exc
1389
+ data = cast(NumericArray, np.asanyarray(image.dataobj))
1390
+ if not np.all(np.isfinite(data)):
1391
+ raise InvalidInputError(f"LesionDiffusion {role} contains non-finite values")
1392
+ image, data = _standardize_to_ras_1mm(image, data, order=0)
1393
+ image, data = _pad_to_even(image, data, mode="constant")
1394
+ if image.shape != reference.shape or not np.allclose(
1395
+ image.affine, reference.affine, atol=1e-4, rtol=0
1396
+ ):
1397
+ raise InvalidInputError(f"LesionDiffusion {role} must share the input CT grid")
1398
+ values = np.unique(data)
1399
+ if np.any(values < 0) or np.any(values != np.floor(values)):
1400
+ raise InvalidInputError(f"{role.capitalize()} labels must be non-negative integers")
1401
+ return data
1402
+
1403
+
1404
+ def resolve_bbox_center(
1405
+ *,
1406
+ organ_mask: BoolArray,
1407
+ bbox_size: tuple[int, int, int],
1408
+ placement: str,
1409
+ requested_center: tuple[int, int, int] | None,
1410
+ seed: int,
1411
+ organ: str = "liver",
1412
+ ) -> tuple[int, int, int]:
1413
+ """Return the box center ``(I, J, K)`` in voxels, as upstream preprocessing places it.
1414
+
1415
+ An explicit ``requested_center`` is returned unchanged if its box fits the grid; it
1416
+ need not lie inside the organ. Otherwise a ``np.random.RandomState(seed)`` (MT19937)
1417
+ picks a candidate organ voxel. For a solid organ with ``inside_parenchyma`` the
1418
+ candidates are shuffled and the first whose whole box lies in the organ is used.
1419
+ For a hollow organ, ``within_lumen`` draws from the organ eroded by 5 voxels and
1420
+ ``wall_thickening`` from the organ minus that erosion (the whole organ if the
1421
+ region is empty). Any other case, or no fitting candidate, draws one voxel at
1422
+ random. The center is finally clamped so the box stays inside the grid.
1423
+
1424
+ Raises:
1425
+ InvalidInputError: If a requested box does not fit, or the organ has no voxels.
1426
+ """
1427
+ from scipy.ndimage import binary_erosion
1428
+
1429
+ if requested_center is not None:
1430
+ if bbox_slices(requested_center, bbox_size, organ_mask.shape) is None:
1431
+ raise InvalidInputError("Requested bbox_center_voxel does not fit the image grid")
1432
+ return requested_center
1433
+ rng = np.random.RandomState(seed)
1434
+ region = organ_mask
1435
+ if organ in _HOLLOW_ORGANS and placement in {"within_lumen", "wall_thickening"}:
1436
+ eroded = binary_erosion(organ_mask, iterations=5)
1437
+ region = eroded if placement == "within_lumen" else organ_mask & ~eroded
1438
+ if not np.any(region):
1439
+ region = organ_mask
1440
+ candidates = np.argwhere(region)
1441
+ if not len(candidates):
1442
+ raise InvalidInputError("No organ voxels available for box placement")
1443
+ half = np.asarray(bbox_size) // 2
1444
+ center = None
1445
+ if organ not in _HOLLOW_ORGANS and placement == "inside_parenchyma":
1446
+ rng.shuffle(candidates)
1447
+ for candidate in candidates:
1448
+ low, high = candidate - half, candidate + half
1449
+ if np.any(low < 0) or np.any(high >= organ_mask.shape):
1450
+ continue
1451
+ if np.all(organ_mask[tuple(slice(a, b + 1) for a, b in zip(low, high, strict=True))]):
1452
+ center = candidate
1453
+ break
1454
+ if center is None:
1455
+ # Upstream rebuilds the unshuffled candidates after an unsuccessful scan.
1456
+ candidates = np.argwhere(region)
1457
+ center = candidates[rng.randint(len(candidates))]
1458
+ center = np.maximum(center, half)
1459
+ center = np.minimum(center, np.asarray(organ_mask.shape) - half - 1)
1460
+ return int(center[0]), int(center[1]), int(center[2])
1461
+
1462
+
1463
+ def bbox_slices(
1464
+ center: tuple[int, int, int],
1465
+ size: tuple[int, int, int],
1466
+ shape: tuple[int, ...],
1467
+ ) -> tuple[slice, slice, slice] | None:
1468
+ """Return the box as slices ``[center - size // 2, center + size // 2)``, or ``None``.
1469
+
1470
+ Each side has length ``2 * (size // 2)``, so an odd size loses one voxel. ``None``
1471
+ means the box falls outside ``shape``.
1472
+ """
1473
+ half = np.asarray(size) // 2
1474
+ low, high = np.asarray(center) - half, np.asarray(center) + half
1475
+ if np.any(low < 0) or np.any(high > shape):
1476
+ return None
1477
+ slices = [slice(int(a), int(b)) for a, b in zip(low, high, strict=True)]
1478
+ return slices[0], slices[1], slices[2]
1479
+
1480
+
1481
+ def bbox_mask(
1482
+ shape: tuple[int, ...],
1483
+ center: tuple[int, int, int],
1484
+ size: tuple[int, int, int],
1485
+ ) -> HostArray:
1486
+ """Return a uint8 volume of ``shape`` with 2 inside the box and 0 elsewhere.
1487
+
1488
+ Raises:
1489
+ InvalidInputError: If the box extends outside ``shape``.
1490
+ """
1491
+ slices = bbox_slices(center, size, shape)
1492
+ if slices is None:
1493
+ raise InvalidInputError("Resolved LesionDiffusion bbox extends outside the image")
1494
+ mask = np.zeros(shape, dtype=np.uint8)
1495
+ mask[slices] = 2
1496
+ return mask
1497
+
1498
+
1499
+ def _structured_report(
1500
+ *,
1501
+ reference: NiftiImage,
1502
+ organ_mask: BoolArray,
1503
+ organ_label: int,
1504
+ center: tuple[int, int, int],
1505
+ bbox_size: tuple[int, int, int],
1506
+ options: LesionDiffusionOptions,
1507
+ ) -> dict[str, object]:
1508
+ """Build upstream's structured report for one lesion.
1509
+
1510
+ Returns ``{label: [shape, "", fields, [[center], [size]]]}``. Phrases end with a
1511
+ comma, as upstream's do. ``lesion location`` starts as the numeric label and is
1512
+ replaced by the organ name before encoding. ``size`` is in voxels. ``CT value``
1513
+ states the mean HU of the organ in the standardized CT, although its wording says
1514
+ "lesion region"; it is not a lesion density and is never embedded as text.
1515
+ """
1516
+ image_data = cast(NumericArray, np.asanyarray(reference.dataobj))
1517
+ organ_values = image_data[organ_mask]
1518
+ mean_hu = float(np.mean(organ_values)) if organ_values.size else 0.0
1519
+ details = {
1520
+ "enhancement status": _with_comma(options.enhancement),
1521
+ "lesion location": str(organ_label),
1522
+ "shape": _with_comma(options.shape),
1523
+ "size": f"[[{' '.join(str(value) for value in bbox_size)}]] pixel,",
1524
+ "density": _with_comma(options.density),
1525
+ "density variations": _with_comma(options.density_variation),
1526
+ "CT value": f"Mean density of the lesion region is approximately {mean_hu:.1f} HU,",
1527
+ "surface characteristics": _with_comma(options.surface),
1528
+ "relationship with adjacent organs": _with_comma(options.adjacent_relationship),
1529
+ "specific features": _with_comma(options.specific_features)
1530
+ if options.specific_features
1531
+ else "",
1532
+ "cavity": _with_comma(_CAVITY_TEXT[options.placement]),
1533
+ }
1534
+ return {
1535
+ str(organ_label): [
1536
+ _with_comma(options.shape),
1537
+ "",
1538
+ details,
1539
+ [[[int(value) for value in center]], [[int(value) for value in bbox_size]]],
1540
+ ]
1541
+ }
1542
+
1543
+
1544
+ # --- outputs --------------------------------------------------------------------------
1545
+
1546
+
1547
+ def materialize_arrays(
1548
+ *,
1549
+ outputs: Mapping[str, Path],
1550
+ geometry: _Geometry,
1551
+ image_data: HostArray,
1552
+ lesion_data: HostArray,
1553
+ generation_spec: dict[str, object],
1554
+ output_region: str = DEFAULT_OUTPUT_REGION,
1555
+ ) -> None:
1556
+ """Validate the generated arrays and write the output NIfTI files.
1557
+
1558
+ Args:
1559
+ outputs: Role to destination path, from ``output_paths``.
1560
+ geometry: Standardized inputs; ``output_reference`` defines the written grid.
1561
+ image_data: Generated CT (HU) on the even-padded grid.
1562
+ lesion_data: Label map on the same grid; 2 marks the lesion.
1563
+ generation_spec: Updated in place with ``output_validation`` and, for the box
1564
+ region, ``compositing``.
1565
+ output_region: ``"full"`` or ``"box"``; see ``run``.
1566
+
1567
+ Raises:
1568
+ ModelExecutionError: If shapes differ from the grid, values are non-finite, or
1569
+ the label map has no lesion.
1570
+ """
1571
+ if (
1572
+ image_data.shape != geometry.reference.shape
1573
+ or lesion_data.shape != geometry.reference.shape
1574
+ ):
1575
+ raise ModelExecutionError("LesionDiffusion generated arrays differ from the input grid")
1576
+ if not np.all(np.isfinite(image_data)) or not np.all(np.isfinite(lesion_data)):
1577
+ raise ModelExecutionError("LesionDiffusion generated arrays contain non-finite voxels")
1578
+ lesion = (lesion_data[_input_grid(geometry.output_reference)] == 2).astype(np.uint8)
1579
+ if not np.any(lesion):
1580
+ raise ModelExecutionError("LesionDiffusion mask output does not contain lesion label 2")
1581
+ images: list[tuple[HostArray, str]] = [(image_data, "synthetic_image")]
1582
+ if output_region == "box":
1583
+ localized, facts = localize_output(
1584
+ original=geometry.source_data,
1585
+ generated=cast(NumericArray, image_data),
1586
+ bbox=cast(
1587
+ tuple[slice, slice, slice],
1588
+ bbox_slices(geometry.center, geometry.bbox_size_voxel, geometry.reference.shape),
1589
+ ),
1590
+ spacing_mm=cast(
1591
+ tuple[float, float, float],
1592
+ tuple(float(value) for value in geometry.reference.header.get_zooms()[:3]),
1593
+ ),
1594
+ )
1595
+ images = [(localized, "synthetic_image"), (image_data, "raw_synthetic_image")]
1596
+ generation_spec["compositing"] = facts
1597
+ for data, role, dtype in (
1598
+ *((data, role, np.float32) for data, role in images),
1599
+ (lesion, "target_mask", np.uint8),
1600
+ ):
1601
+ _save_like_reference(
1602
+ data=data,
1603
+ reference=geometry.output_reference,
1604
+ destination=outputs[role],
1605
+ dtype=dtype,
1606
+ )
1607
+ generation_spec["output_validation"] = {
1608
+ "schema_version": 1,
1609
+ "lesion_voxel_count": int(lesion.sum()),
1610
+ "finite": True,
1611
+ "method": "input_grid_shape_and_finite_voxels",
1612
+ }
1613
+
1614
+
1615
+ def _save_like_reference(
1616
+ *,
1617
+ data: HostArray,
1618
+ reference: NiftiImage,
1619
+ destination: Path,
1620
+ dtype: type[np.generic],
1621
+ ) -> None:
1622
+ """Write ``data`` cropped to ``reference``'s grid, keeping its affine, qform and sform."""
1623
+ destination.parent.mkdir(parents=True, exist_ok=True)
1624
+ header = nib.Nifti1Header.from_header(reference.header)
1625
+ header.set_data_dtype(dtype)
1626
+ cropped = np.asarray(data, dtype=dtype)[_input_grid(reference)]
1627
+ output = nib.Nifti1Image(cropped, reference.affine, header)
1628
+ qform, qcode = reference.get_qform(coded=True)
1629
+ sform, scode = reference.get_sform(coded=True)
1630
+ output.set_qform(reference.affine if qform is None else qform, int(qcode))
1631
+ output.set_sform(reference.affine if sform is None else sform, int(scode))
1632
+ nib.save(output, str(destination))
1633
+
1634
+
1635
+ def _input_grid(reference: NiftiImage) -> tuple[slice, ...]:
1636
+ """Drop the trailing ``_pad_to_even`` voxels; the origin is unchanged."""
1637
+ return tuple(slice(0, int(size)) for size in reference.shape[:3])
1638
+
1639
+
1640
+ def output_paths(output: Path, output_region: str = DEFAULT_OUTPUT_REGION) -> dict[str, Path]:
1641
+ """Map each output role to its file; ``box`` mode adds ``raw_synthetic_image``."""
1642
+ paths = {
1643
+ "synthetic_image": output / _SYNTHETIC_CT,
1644
+ "target_mask": output / _LESION_MASK,
1645
+ "organ_mask": output / _ORGAN_MASK,
1646
+ }
1647
+ if output_region == "box":
1648
+ paths["raw_synthetic_image"] = output / _RAW_SYNTHETIC_CT
1649
+ return paths
1650
+
1651
+
1652
+ def localize_output(
1653
+ *,
1654
+ original: NumericArray,
1655
+ generated: NumericArray,
1656
+ bbox: tuple[slice, slice, slice],
1657
+ spacing_mm: tuple[float, float, float],
1658
+ margin_mm: float = LOCALIZATION_MARGIN_MM,
1659
+ ) -> tuple[FloatArray, dict[str, object]]:
1660
+ """Composite the generated CT into the original CT around the box.
1661
+
1662
+ Inside ``bbox`` the generated CT is kept. A voxel whose center lies less than
1663
+ ``margin_mm`` from the box (Euclidean distance in mm, from the nearest box voxel
1664
+ center) takes ``w * generated + (1 - w) * original`` with
1665
+ ``w = 0.5 * (1 + cos(pi * d / margin_mm))``; every other voxel is the original CT.
1666
+
1667
+ Args:
1668
+ original: Standardized input CT on the even-padded grid.
1669
+ generated: Full generated CT on the same grid.
1670
+ bbox: Box as slices, from ``bbox_slices``.
1671
+ spacing_mm: Voxel spacing (X, Y, Z) in mm.
1672
+ margin_mm: Feather width in mm.
1673
+
1674
+ Returns:
1675
+ ``(localized_ct, facts)``: a float32 array and a record of the blend policy.
1676
+
1677
+ Raises:
1678
+ ModelExecutionError: If ``original`` and ``generated`` differ in shape.
1679
+ """
1680
+ if original.shape != generated.shape:
1681
+ raise ModelExecutionError("LesionDiffusion raw output shape does not match the original CT")
1682
+ spacing = np.asarray(spacing_mm, dtype=np.float64)
1683
+ radius = np.ceil(margin_mm / spacing).astype(int)
1684
+ start = np.asarray([int(value.start) for value in bbox])
1685
+ stop = np.asarray([int(value.stop) for value in bbox])
1686
+ low = np.maximum(0, start - radius)
1687
+ high = np.minimum(np.asarray(original.shape[:3]), stop + radius)
1688
+ region = cast(
1689
+ tuple[slice, slice, slice],
1690
+ tuple(slice(int(a), int(b)) for a, b in zip(low, high, strict=True)),
1691
+ )
1692
+ squared = np.zeros(tuple(int(value) for value in high - low), dtype=np.float64)
1693
+ for axis in range(3):
1694
+ coordinates = np.arange(int(low[axis]), int(high[axis]))
1695
+ offset = (
1696
+ np.where(
1697
+ coordinates < start[axis],
1698
+ start[axis] - coordinates,
1699
+ np.where(coordinates >= stop[axis], coordinates - (stop[axis] - 1), 0),
1700
+ )
1701
+ * spacing[axis]
1702
+ )
1703
+ shape = [1, 1, 1]
1704
+ shape[axis] = offset.size
1705
+ squared = squared + (offset**2).reshape(shape)
1706
+ distance = np.sqrt(squared)
1707
+ weights = np.zeros(distance.shape, dtype=np.float64)
1708
+ affected = distance < margin_mm
1709
+ weights[affected] = 0.5 * (1.0 + np.cos(np.pi * distance[affected] / margin_mm))
1710
+ localized = np.asarray(original, dtype=np.float32).copy()
1711
+ raw = np.asarray(generated[region], dtype=np.float32)
1712
+ window = localized[region]
1713
+ transition = (weights > 0) & (weights < 1)
1714
+ window[transition] = (
1715
+ raw[transition] * weights[transition] + window[transition] * (1.0 - weights[transition])
1716
+ ).astype(np.float32)
1717
+ localized[bbox] = generated[bbox]
1718
+ return localized, {
1719
+ "policy": "bbox_generated_cosine_feather_to_original_ct",
1720
+ "margin_mm": margin_mm,
1721
+ "blend_weight": "0.5 * (1 + cos(pi * distance_mm / margin_mm))",
1722
+ "distance_metric": "euclidean_voxel_center_distance_in_physical_mm",
1723
+ "bounding_box": {
1724
+ "start_voxel": [int(value) for value in start],
1725
+ "stop_voxel_exclusive": [int(value) for value in stop],
1726
+ },
1727
+ "affected_region": {
1728
+ "start_voxel": [int(value) for value in low],
1729
+ "stop_voxel_exclusive": [int(value) for value in high],
1730
+ "weighted_voxel_count": int(np.count_nonzero(affected)),
1731
+ },
1732
+ "outside_affected_region": "exact_original_ct",
1733
+ }
1734
+
1735
+
1736
+ def _with_comma(value: str) -> str:
1737
+ # Upstream report phrases are comma-terminated.
1738
+ stripped = value.strip().rstrip(",")
1739
+ return f"{stripped},"
1740
+
1741
+
1742
+ def _json_safe(value: object) -> object:
1743
+ if isinstance(value, Mapping):
1744
+ return {
1745
+ str(key): _json_safe(item) for key, item in cast(Mapping[object, object], value).items()
1746
+ }
1747
+ if isinstance(value, list | tuple):
1748
+ return [_json_safe(item) for item in cast(Sequence[object], value)]
1749
+ if isinstance(value, np.generic):
1750
+ return cast(object, value.item())
1751
+ return value
1752
+
1753
+
1754
+ __all__ = [
1755
+ "MODEL",
1756
+ "LesionDiffusionOptions",
1757
+ "load_conversion",
1758
+ "load_stage",
1759
+ "require_weights",
1760
+ "run",
1761
+ "run_native_pipeline",
1762
+ "select_lesion_component",
1763
+ "source_organ_label",
1764
+ ]