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.
- lesiondiffusion_mlx/__init__.py +66 -0
- lesiondiffusion_mlx/_arrays.py +24 -0
- lesiondiffusion_mlx/_nifti.py +73 -0
- lesiondiffusion_mlx/_schedules.py +38 -0
- lesiondiffusion_mlx/_source_half.py +541 -0
- lesiondiffusion_mlx/_spatial.py +57 -0
- lesiondiffusion_mlx/_typing.py +110 -0
- lesiondiffusion_mlx/medmlx.py +1764 -0
- lesiondiffusion_mlx/mlx_model.py +456 -0
- lesiondiffusion_mlx/mlx_pipeline.py +136 -0
- lesiondiffusion_mlx/mlx_sampling.py +132 -0
- lesiondiffusion_mlx/mlx_weights.py +216 -0
- lesiondiffusion_mlx/py.typed +0 -0
- lesiondiffusion_mlx/reference.json +141 -0
- lesiondiffusion_mlx-0.2.1.dist-info/METADATA +99 -0
- lesiondiffusion_mlx-0.2.1.dist-info/RECORD +20 -0
- lesiondiffusion_mlx-0.2.1.dist-info/WHEEL +4 -0
- lesiondiffusion_mlx-0.2.1.dist-info/entry_points.txt +2 -0
- lesiondiffusion_mlx-0.2.1.dist-info/licenses/LICENSE +202 -0
- lesiondiffusion_mlx-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +147 -0
|
@@ -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
|
+
]
|