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,456 @@
1
+ """Native Metal networks for LesionDiffusion source 880736e2.
2
+
3
+ Weights retain their source names/layouts. Shared MedMLX array primitives own
4
+ the NCDHW to MLX convolution boundary; the architecture here is the upstream
5
+ 3-D LDM architecture, including its distinct normalization and attention rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, cast
14
+
15
+ if TYPE_CHECKING:
16
+ from lesiondiffusion_mlx._typing import Array, Mlx
17
+
18
+ from medmlx_core.ops import (
19
+ conv3d_ncdhw,
20
+ group_norm_ncdhw,
21
+ linear,
22
+ silu,
23
+ )
24
+
25
+ from lesiondiffusion_mlx._source_half import conv3d_half, group_norm_half
26
+ from lesiondiffusion_mlx._spatial import upsample_nearest2
27
+ from lesiondiffusion_mlx._typing import ArrayConstructor
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class UnetConfig:
32
+ """Architecture of one upstream 3-D UNet (LMNet or LINet).
33
+
34
+ Attributes:
35
+ model_channels: Base channel width; also sets the sinusoidal time-embedding size.
36
+ num_res_blocks: Residual blocks per resolution level.
37
+ attention_resolutions: Downsampling factors (1, 2, 4, ...) at which residual
38
+ blocks are followed by a spatial transformer. The middle block always has one.
39
+ channel_mult: Per-level multipliers of ``model_channels``; each level after
40
+ the first halves the spatial size.
41
+ num_head_channels: Channels per attention head.
42
+ skip_context: Drop the text context, so cross-attention layers attend to
43
+ their own input (upstream's ``skip_context``).
44
+ """
45
+
46
+ model_channels: int
47
+ num_res_blocks: int
48
+ attention_resolutions: tuple[int, ...]
49
+ channel_mult: tuple[int, ...] = (1, 2, 4)
50
+ num_head_channels: int = 32
51
+ skip_context: bool = False
52
+
53
+
54
+ LMNET_CONFIG = UnetConfig(16, 1, (4,))
55
+ # skip_context=True follows the pinned inference YAML; the runner overrides it per
56
+ # inference mode (context is enabled only in "conditioned" mode).
57
+ LINET_CONFIG = UnetConfig(128, 2, (4, 8), skip_context=True)
58
+
59
+
60
+ class SourceLayers:
61
+ """Source-keyed native MLX layers without a host execution path.
62
+
63
+ ``weights`` maps upstream state-dict names (for example ``input_blocks.1.0.in_layers.2``)
64
+ to MLX arrays in upstream's NCDHW layout. With ``source_arithmetic=True``, float16
65
+ convolutions use the Torch-CPU-ordered Metal kernel (``conv3d_half``); otherwise
66
+ they use the shared MedMLX convolution. Float16 group normalization always uses
67
+ the source-ordered kernel.
68
+ """
69
+
70
+ def __init__(self, weights: Mapping[str, Array], mx: Mlx, *, source_arithmetic: bool = True):
71
+ self.weights = weights
72
+ self.mx = mx
73
+ self.source_arithmetic = source_arithmetic
74
+
75
+ def conv(self, x: Array, key: str, *, padding: int = 1, stride: int = 1) -> Array:
76
+ weight, bias = self.weights[key + ".weight"], self.weights.get(key + ".bias")
77
+ if x.dtype == self.mx.float16 and self.source_arithmetic:
78
+ return conv3d_half(x, weight, bias, padding=padding, stride=stride, mx=self.mx)
79
+ return conv3d_ncdhw(
80
+ x,
81
+ weight,
82
+ bias,
83
+ padding=padding,
84
+ stride=stride,
85
+ mx=self.mx,
86
+ )
87
+
88
+ def dense(self, x: Array, key: str) -> Array:
89
+ dtype = x.dtype
90
+ x, weight, bias = self._affine_inputs(x, key)
91
+ return linear(x, weight, bias, self.mx).astype(dtype)
92
+
93
+ def _affine_inputs(self, x: Array, key: str) -> tuple[Array, Array, Array | None]:
94
+ weight = self.weights[key + ".weight"]
95
+ bias = self.weights.get(key + ".bias")
96
+ if x.dtype == self.mx.float16:
97
+ # Torch's CPU half kernels add bias to the FP32 accumulator before
98
+ # rounding once. A half convolution/matmul followed by bias rounds twice.
99
+ x, weight = x.astype(self.mx.float32), weight.astype(self.mx.float32)
100
+ if bias is not None:
101
+ bias = bias.astype(self.mx.float32)
102
+ return x, weight, bias
103
+
104
+ def norm(self, x: Array, key: str, *, groups: int, eps: float) -> Array:
105
+ if x.dtype == self.mx.float16:
106
+ return group_norm_half(
107
+ x,
108
+ self.weights[key + ".weight"],
109
+ self.weights[key + ".bias"],
110
+ groups=groups,
111
+ eps=eps,
112
+ mx=self.mx,
113
+ )
114
+ # Torch GroupNorm accumulates statistics and applies its affine in
115
+ # float32 before rounding the result back to the input precision.
116
+ return group_norm_ncdhw(
117
+ x.astype(self.mx.float32),
118
+ self.weights[key + ".weight"].astype(self.mx.float32),
119
+ self.weights[key + ".bias"].astype(self.mx.float32),
120
+ num_groups=groups,
121
+ eps=eps,
122
+ mx=self.mx,
123
+ ).astype(x.dtype)
124
+
125
+ def layer_norm(self, x: Array, key: str, *, eps: float = 1e-5) -> Array:
126
+ return self.mx.fast.layer_norm(
127
+ x,
128
+ self.weights[key + ".weight"],
129
+ self.weights[key + ".bias"],
130
+ eps,
131
+ )
132
+
133
+ def gelu(self, x: Array) -> Array:
134
+ # Source nn.GELU uses the exact erf formula, not tanh approximation.
135
+ dtype = x.dtype
136
+ x = x.astype(self.mx.float32)
137
+ return (x * (1 + self.mx.erf(x / math.sqrt(2))) * 0.5).astype(dtype)
138
+
139
+ def resize_nearest(self, x: Array, shape: tuple[int, ...]) -> Array:
140
+ """Nearest-neighbour resize of ``(B, C, D, H, W)`` to spatial ``shape`` (D, H, W).
141
+
142
+ Output index ``i`` reads input index ``floor(i * in_size / out_size)``.
143
+ """
144
+ if len(shape) != 3:
145
+ raise ValueError("LesionDiffusion nearest resizing needs three spatial axes")
146
+ if x.ndim == 5 and min(x.shape) > 0 and shape == tuple(2 * s for s in x.shape[2:]):
147
+ # Both source decoders double every spatial axis. A single strided
148
+ # copy preserves their floor-index rule without three intermediates.
149
+ return upsample_nearest2(x, mx=self.mx)
150
+ for axis, target in enumerate(shape, start=2):
151
+ indices = self.mx.floor(self.mx.arange(target) * (x.shape[axis] / target))
152
+ x = self.mx.take(x, indices.astype(self.mx.int32), axis=axis)
153
+ return x
154
+
155
+
156
+ class LdmUnet(SourceLayers):
157
+ """LMNet/LINet: upstream's 3-D OpenAI-style UNet with spatial transformers.
158
+
159
+ ``fused_attention=True`` replaces explicit softmax attention with Metal's fused
160
+ kernel (used for LMNet in "conditioned" mode); source mode keeps the explicit
161
+ form to stay on upstream's arithmetic.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ weights: Mapping[str, Array],
167
+ mx: Mlx,
168
+ config: UnetConfig,
169
+ *,
170
+ source_arithmetic: bool = True,
171
+ fused_attention: bool = False,
172
+ ):
173
+ super().__init__(weights, mx, source_arithmetic=source_arithmetic)
174
+ self.config = config
175
+ self.fused_attention = fused_attention
176
+
177
+ def silu(self, x: Array) -> Array:
178
+ # Source nn.SiLU rounds once after the activation. VQ's separate
179
+ # sigmoid/product deliberately retains its two half rounding steps.
180
+ return silu(x.astype(self.mx.float32), self.mx).astype(x.dtype)
181
+
182
+ def softmax(self, x: Array) -> Array:
183
+ # Torch softmax accumulates in float32, then returns the input dtype.
184
+ return self.mx.softmax(x, axis=-1, precise=True)
185
+
186
+ def residual(self, x: Array, emb: Array, key: str) -> Array:
187
+ channels = x.shape[1]
188
+ groups = 32 if channels % 32 == 0 else channels
189
+ h = self.conv(
190
+ self.silu(self.norm(x, key + ".in_layers.0", groups=groups, eps=1e-5)),
191
+ key + ".in_layers.2",
192
+ )
193
+ h = h + self.dense(self.silu(emb), key + ".emb_layers.1")[:, :, None, None, None]
194
+ groups = 32 if h.shape[1] % 32 == 0 else h.shape[1]
195
+ h = self.conv(
196
+ self.silu(self.norm(h, key + ".out_layers.0", groups=groups, eps=1e-5)),
197
+ key + ".out_layers.3",
198
+ )
199
+ if key + ".skip_connection.weight" in self.weights:
200
+ x = self.conv(x, key + ".skip_connection", padding=0)
201
+ return x + h
202
+
203
+ def attention(self, x: Array, context: Array | None, key: str, heads: int) -> Array:
204
+ mx = self.mx
205
+ context = x if context is None else context
206
+ q, k, v = (
207
+ self.dense(x, key + ".to_q"),
208
+ self.dense(context, key + ".to_k"),
209
+ self.dense(context, key + ".to_v"),
210
+ )
211
+ depth = q.shape[-1] // heads
212
+
213
+ def split(tensor: Array) -> Array:
214
+ return mx.transpose(
215
+ mx.reshape(tensor, (tensor.shape[0], tensor.shape[1], heads, depth)), (0, 2, 1, 3)
216
+ )
217
+
218
+ if self.fused_attention:
219
+ q, k, v = split(q), split(k), split(v)
220
+ # Metal's fused full-attention kernel starts at 64-wide heads.
221
+ # Zero padding retains the 32-wide model's original dot products
222
+ # and scale; discard the padded value channels after attention.
223
+ if depth < 64:
224
+ padding = [(0, 0), (0, 0), (0, 0), (0, 64 - depth)]
225
+ q, k, v = (mx.pad(tensor, padding) for tensor in (q, k, v))
226
+ result = mx.fast.scaled_dot_product_attention(
227
+ q, k, v, scale=depth**-0.5, force_fused=True
228
+ )[..., :depth]
229
+ else:
230
+ # Source half attention rounds similarity and probability tensors;
231
+ # LINet retains those boundaries rather than using fused SDPA.
232
+ similarity = (split(q) @ mx.swapaxes(split(k), -1, -2)) * depth**-0.5
233
+ result = self.softmax(similarity) @ split(v)
234
+ result = mx.reshape(mx.transpose(result, (0, 2, 1, 3)), x.shape)
235
+ return self.dense(result, key + ".to_out.0")
236
+
237
+ def transformer(self, x: Array, context: Array | None, key: str) -> Array:
238
+ residual = x
239
+ x = self.conv(self.norm(x, key + ".norm", groups=32, eps=1e-6), key + ".proj_in", padding=0)
240
+ shape = x.shape
241
+ x = self.mx.reshape(self.mx.transpose(x, (0, 2, 3, 4, 1)), (shape[0], -1, shape[1]))
242
+ heads = shape[1] // self.config.num_head_channels
243
+ block = key + ".transformer_blocks.0"
244
+ x = x + self.attention(self.layer_norm(x, block + ".norm1"), None, block + ".attn1", heads)
245
+ x = x + self.attention(
246
+ self.layer_norm(x, block + ".norm2"), context, block + ".attn2", heads
247
+ )
248
+ ff = self.gelu(self.dense(self.layer_norm(x, block + ".norm3"), block + ".ff.net.0.0"))
249
+ x = x + self.dense(ff, block + ".ff.net.2")
250
+ x = self.mx.transpose(self.mx.reshape(x, (shape[0], *shape[2:], shape[1])), (0, 4, 1, 2, 3))
251
+ return residual + self.conv(x, key + ".proj_out", padding=0)
252
+
253
+ def __call__(self, x: Array, timesteps: Array, context: Array | None) -> Array:
254
+ """Predict noise for one denoising step.
255
+
256
+ Args:
257
+ x: Network input ``(B, C_in, D, H, W)``, channels first. Spatial sizes
258
+ must be divisible by ``2 ** (levels - 1)`` (4 for the shipped configs).
259
+ timesteps: Integer diffusion timesteps, shape ``(B,)``.
260
+ context: Text embeddings ``(B, N, D_ctx)``, or ``None``. Ignored when the
261
+ config sets ``skip_context``.
262
+
263
+ Returns:
264
+ Prediction ``(B, C_out, D, H, W)``, computed in the weights' dtype
265
+ (float32 for LMNet, float16 for LINet in the runner).
266
+ """
267
+ mx, cfg = self.mx, self.config
268
+ half = cfg.model_channels // 2
269
+ frequency = mx.exp(-math.log(10000) * mx.arange(half, dtype=mx.float32) / half)
270
+ phase = timesteps[:, None].astype(mx.float32) * frequency[None]
271
+ emb = mx.concatenate([mx.cos(phase), mx.sin(phase)], axis=1)
272
+ dtype = self.weights["time_embed.0.weight"].dtype
273
+ emb = self.dense(self.silu(self.dense(emb.astype(dtype), "time_embed.0")), "time_embed.2")
274
+ if context is not None:
275
+ context = (
276
+ None
277
+ if cfg.skip_context
278
+ else self.dense(context.astype(dtype), "context_linear_projection")
279
+ )
280
+ x = self.conv(x.astype(dtype), "input_blocks.0.0")
281
+ skips = [x]
282
+ block_index, resolution = 1, 1
283
+ for level in range(len(cfg.channel_mult)):
284
+ for _ in range(cfg.num_res_blocks):
285
+ key = f"input_blocks.{block_index}"
286
+ x = self.residual(x, emb, key + ".0")
287
+ if resolution in cfg.attention_resolutions:
288
+ x = self.transformer(x, context, key + ".1")
289
+ skips.append(x)
290
+ block_index += 1
291
+ if level < len(cfg.channel_mult) - 1:
292
+ x = self.conv(x, f"input_blocks.{block_index}.0.op", stride=2)
293
+ skips.append(x)
294
+ block_index += 1
295
+ resolution *= 2
296
+ x = self.residual(x, emb, "middle_block.0")
297
+ x = self.transformer(x, context, "middle_block.1")
298
+ x = self.residual(x, emb, "middle_block.2")
299
+ block_index = 0
300
+ for level in reversed(range(len(cfg.channel_mult))):
301
+ for block in range(cfg.num_res_blocks + 1):
302
+ key = f"output_blocks.{block_index}"
303
+ x = self.residual(mx.concatenate([x, skips.pop()], axis=1), emb, key + ".0")
304
+ has_attention = resolution in cfg.attention_resolutions
305
+ if has_attention:
306
+ x = self.transformer(x, context, key + ".1")
307
+ if level and block == cfg.num_res_blocks:
308
+ x = self.resize_nearest(x, tuple(int(s * 2) for s in x.shape[2:]))
309
+ x = self.conv(x, key + f".{2 if has_attention else 1}.conv")
310
+ resolution //= 2
311
+ block_index += 1
312
+ return self.conv(
313
+ self.silu(
314
+ self.norm(x, "out.0", groups=32 if x.shape[1] % 32 == 0 else x.shape[1], eps=1e-5)
315
+ ),
316
+ "out.2",
317
+ )
318
+
319
+
320
+ class VqAutoencoder(SourceLayers):
321
+ """Upstream's 3-D VQ autoencoder, as used by LINet: factor-two spatial latent.
322
+
323
+ Tensors are channels first. ``encode`` maps ``(B, C, D, H, W)`` to a latent of
324
+ half the spatial size (rounded down); ``decode`` snaps latent vectors to their
325
+ nearest codebook entry and returns twice the latent's spatial size.
326
+ """
327
+
328
+ def residual(self, x: Array, key: str) -> Array:
329
+ h = self.conv(
330
+ silu(self.norm(x, key + ".norm1", groups=16, eps=1e-6), self.mx), key + ".conv1"
331
+ )
332
+ h = self.conv(
333
+ silu(self.norm(h, key + ".norm2", groups=16, eps=1e-6), self.mx), key + ".conv2"
334
+ )
335
+ if key + ".nin_shortcut.weight" in self.weights:
336
+ x = self.conv(x, key + ".nin_shortcut", padding=0)
337
+ return x + h
338
+
339
+ def encode(self, x: Array) -> Array:
340
+ x = self.conv(x, "encoder.conv_in")
341
+ for level in range(3):
342
+ x = self.residual(x, f"encoder.down.{level}.block.0")
343
+ if level == 0:
344
+ x = self.mx.pad(x, [(0, 0), (0, 0), (0, 1), (0, 1), (0, 1)])
345
+ x = self.conv(x, "encoder.down.0.downsample.conv", padding=0, stride=2)
346
+ x = self.residual(x, "encoder.mid.block_1")
347
+ x = self.residual(x, "encoder.mid.block_2")
348
+ x = self.conv(
349
+ silu(self.norm(x, "encoder.norm_out", groups=16, eps=1e-6), self.mx), "encoder.conv_out"
350
+ )
351
+ return self.conv(x, "quant_conv", padding=0)
352
+
353
+ def quantize(self, z: Array, *, chunk_size: int = 4096) -> Array:
354
+ """Replace each latent vector of ``z`` ``(B, C, D, H, W)`` by its nearest codebook entry.
355
+
356
+ Nearest means smallest squared Euclidean distance; positions are processed in
357
+ chunks of ``chunk_size`` to bound the size of the distance matrix. Selection
358
+ is discontinuous, so tiny rounding differences can change a chosen code.
359
+ """
360
+ mx = self.mx
361
+ shape = z.shape
362
+ flat = mx.reshape(mx.transpose(z, (0, 2, 3, 4, 1)), (-1, shape[1]))
363
+ codebook = self.weights["quantize.embedding.weight"]
364
+ # Source half multiplication rounds each square, then torch.sum
365
+ # accumulates in float32 and rounds once back to half.
366
+ codebook_norm = mx.sum((codebook * codebook).astype(mx.float32), axis=1).astype(
367
+ codebook.dtype
368
+ )
369
+ pieces: list[Array] = []
370
+ for start in range(0, flat.shape[0], chunk_size):
371
+ part = flat[start : start + chunk_size]
372
+ distance = (
373
+ mx.sum((part * part).astype(mx.float32), axis=1, keepdims=True).astype(part.dtype)
374
+ + codebook_norm[None]
375
+ - 2 * (part @ codebook.T)
376
+ )
377
+ selected = codebook[mx.argmin(distance, axis=1)]
378
+ # Upstream VectorQuantizer retains its straight-through arithmetic
379
+ # during inference too; fp16 cancellation changes the returned values.
380
+ pieces.append(part + (selected - part))
381
+ return mx.transpose(
382
+ mx.reshape(mx.concatenate(list(pieces)), (shape[0], *shape[2:], shape[1])),
383
+ (0, 4, 1, 2, 3),
384
+ )
385
+
386
+ def decode(self, z: Array, *, quantize: bool = True) -> Array:
387
+ """Decode a latent ``(B, C, d, h, w)`` to a volume ``(B, C_out, 2d, 2h, 2w)``.
388
+
389
+ Set ``quantize=False`` to skip the codebook lookup and decode ``z`` as given.
390
+ """
391
+ x = self.conv(self.quantize(z) if quantize else z, "post_quant_conv", padding=0)
392
+ x = self.conv(x, "decoder.conv_in")
393
+ x = self.residual(x, "decoder.mid.block_1")
394
+ x = self.residual(x, "decoder.mid.block_2")
395
+ for level in reversed(range(3)):
396
+ for block in range(2):
397
+ x = self.residual(x, f"decoder.up.{level}.block.{block}")
398
+ if level == 1:
399
+ x = self.resize_nearest(x, tuple(int(s * 2) for s in x.shape[2:]))
400
+ x = self.conv(x, "decoder.up.1.upsample.conv")
401
+ return self.conv(
402
+ silu(self.norm(x, "decoder.norm_out", groups=16, eps=1e-6), self.mx), "decoder.conv_out"
403
+ )
404
+
405
+
406
+ class BiomedTextEncoder(SourceLayers):
407
+ """PubMedBERT CLS pooling and OpenCLIP MLP projection on Metal."""
408
+
409
+ def __call__(self, token_ids: Array) -> Array:
410
+ """Embed token id sequences ``(N, L)`` as vectors ``(N, D_ctx)``.
411
+
412
+ Padding positions (id 0) are masked out of attention; the vector is the
413
+ projected first-token (CLS) state. ``L`` must not exceed the position-embedding
414
+ table (256 for BiomedCLIP).
415
+ """
416
+ mx = self.mx
417
+ prefix = "transformer.embeddings"
418
+ x = self.weights[prefix + ".word_embeddings.weight"][token_ids]
419
+ x = (
420
+ x
421
+ + self.weights[prefix + ".position_embeddings.weight"][mx.arange(token_ids.shape[1])][
422
+ None
423
+ ]
424
+ )
425
+ x = x + self.weights[prefix + ".token_type_embeddings.weight"][0]
426
+ x = self.layer_norm(x, prefix + ".LayerNorm", eps=1e-12)
427
+ mask = mx.where(
428
+ token_ids[:, None, None, :] != 0,
429
+ cast(ArrayConstructor, mx.array)(0, dtype=x.dtype),
430
+ cast(ArrayConstructor, mx.array)(-float("inf"), dtype=x.dtype),
431
+ )
432
+ for index in range(12):
433
+ key = f"transformer.encoder.layer.{index}"
434
+ q, k, v = (
435
+ self.dense(x, key + ".attention.self." + name) for name in ("query", "key", "value")
436
+ )
437
+
438
+ def split(tensor: Array) -> Array:
439
+ return mx.transpose(
440
+ mx.reshape(tensor, (tensor.shape[0], tensor.shape[1], 12, 64)), (0, 2, 1, 3)
441
+ )
442
+
443
+ h = mx.fast.scaled_dot_product_attention(
444
+ split(q), split(k), split(v), scale=0.125, mask=mask
445
+ )
446
+ h = mx.reshape(mx.transpose(h, (0, 2, 1, 3)), x.shape)
447
+ x = self.layer_norm(
448
+ x + self.dense(h, key + ".attention.output.dense"),
449
+ key + ".attention.output.LayerNorm",
450
+ eps=1e-12,
451
+ )
452
+ h = self.gelu(self.dense(x, key + ".intermediate.dense"))
453
+ x = self.layer_norm(
454
+ x + self.dense(h, key + ".output.dense"), key + ".output.LayerNorm", eps=1e-12
455
+ )
456
+ return self.dense(self.gelu(self.dense(x[:, 0], "proj.0")), "proj.2")
@@ -0,0 +1,136 @@
1
+ """Array preprocessing for the pinned upstream LesionDiffusion pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ if TYPE_CHECKING:
10
+ from lesiondiffusion_mlx._typing import Array, ArrayValue, HostArray, Mlx, TokenizerContract
11
+
12
+ import numpy as np
13
+ from medmlx_core.errors import InvalidInputError, ModelExecutionError
14
+
15
+ from lesiondiffusion_mlx._typing import ArrayConstructor
16
+ from lesiondiffusion_mlx.mlx_model import BiomedTextEncoder, VqAutoencoder
17
+
18
+ # Crop extents in source axis order (Z, Y, X), in voxels of the 1 mm grid: LMNet
19
+ # generates the mask on LMNET_CROP; LINet inpaints on LINET_CROP.
20
+ LMNET_CROP = (64, 152, 152)
21
+ LINET_CROP = (128, 128, 128)
22
+
23
+
24
+ def ras_to_source(array: HostArray) -> HostArray:
25
+ """Convert a RAS-ordered ``(X, Y, Z)`` array to the source layout ``(Z, Y, X)``.
26
+
27
+ The pinned upstream loader reorients to LAS before transposing, so the first
28
+ (X) axis is also reversed. Expects a 3-D array and returns a C-contiguous copy.
29
+ """
30
+ return np.ascontiguousarray(array[::-1].transpose(2, 1, 0))
31
+
32
+
33
+ def source_to_ras(array: HostArray) -> HostArray:
34
+ """Invert ``ras_to_source``: ``(Z, Y, X)`` LAS-ordered data back to RAS ``(X, Y, Z)``."""
35
+ return np.ascontiguousarray(array.transpose(2, 1, 0)[::-1])
36
+
37
+
38
+ def source_crop(mask: HostArray, shape: tuple[int, int, int]) -> tuple[slice, slice, slice]:
39
+ """Return the fixed-size crop centred on a mask's bounding box, in source axes.
40
+
41
+ Args:
42
+ mask: Boolean or label volume in source ``(Z, Y, X)`` order; non-zero voxels
43
+ define the extent (the box for LMNet, the lesion for LINet).
44
+ shape: Crop size ``(Z, Y, X)`` in voxels.
45
+
46
+ Returns:
47
+ Slices of length ``shape`` per axis, centred on the midpoint of the mask's
48
+ extent (integer floor) and shifted to stay inside the volume.
49
+
50
+ Raises:
51
+ ModelExecutionError: If ``mask`` is empty.
52
+ InvalidInputError: If any volume axis is shorter than the crop.
53
+ """
54
+ points = np.argwhere(mask)
55
+ if points.size == 0:
56
+ raise ModelExecutionError("LesionDiffusion cannot crop an empty lesion mask")
57
+ slices: list[slice] = []
58
+ for axis, requested in enumerate(shape):
59
+ center = int((points[:, axis].min() + points[:, axis].max()) // 2)
60
+ length = mask.shape[axis]
61
+ if length < requested:
62
+ raise InvalidInputError(
63
+ f"Pinned LesionDiffusion crop requires source axis {axis} >= {requested}; "
64
+ f"got {length}"
65
+ )
66
+ start = min(max(center - requested // 2, 0), length - requested)
67
+ slices.append(slice(start, start + requested))
68
+ return slices[0], slices[1], slices[2]
69
+
70
+
71
+ def piecewise_ct(array: Array, mx: Mlx, *, inverse: bool = False) -> Array:
72
+ """Apply the source's four-knot CT intensity transform on the Metal GPU.
73
+
74
+ Map HU landmarks (-1000, -200, 200, 1000) to (0, 0.2, 0.8, 1) with
75
+ piecewise-linear interpolation and endpoint clipping. ``inverse=True``
76
+ swaps those landmarks to return HU, also clipping outside its input range.
77
+ The input array may have any shape; the result is float32 with the same shape.
78
+ Clipping means HU outside [-1000, 1000] cannot survive a round trip.
79
+ """
80
+ inputs, outputs = (
81
+ ((0.0, 0.2, 0.8, 1.0), (-1000.0, -200.0, 200.0, 1000.0))
82
+ if inverse
83
+ else ((-1000.0, -200.0, 200.0, 1000.0), (0.0, 0.2, 0.8, 1.0))
84
+ )
85
+ result = mx.full(array.shape, outputs[0], dtype=mx.float32)
86
+ for index in range(3):
87
+ slope = (outputs[index + 1] - outputs[index]) / (inputs[index + 1] - inputs[index])
88
+ result = mx.where(
89
+ (array >= inputs[index]) & (array < inputs[index + 1]),
90
+ slope * (array - inputs[index]) + outputs[index],
91
+ result,
92
+ )
93
+ return mx.where(array >= inputs[-1], outputs[-1], result)
94
+
95
+
96
+ def encode_source(model: VqAutoencoder, array: Array, mx: Mlx) -> Array:
97
+ """Encode a normalized volume ``(B, C, D, H, W)`` to its VQ latent, evaluated eagerly.
98
+
99
+ ``array`` holds ``piecewise_ct`` output in [0, 1]. The latent has half the
100
+ spatial size, rounded down, so decoding an odd extent returns one voxel fewer;
101
+ the runner pads to even extents first. The latent is not quantized here;
102
+ ``VqAutoencoder.decode`` does that.
103
+ """
104
+ latent = model.encode(array)
105
+ mx.eval(latent)
106
+ return latent
107
+
108
+
109
+ def encode_attributes(
110
+ attributes: Mapping[str, object],
111
+ model: BiomedTextEncoder,
112
+ tokenizer_path: Path,
113
+ mx: Mlx,
114
+ ) -> Array:
115
+ """Embed each attribute value as one BiomedCLIP text vector for cross-attention.
116
+
117
+ Only the values of ``attributes`` are encoded, one sequence per entry in
118
+ insertion order; keys are ignored. Tokenization runs on the host (truncated and
119
+ padded to 256 tokens, pad id 0) and every learned text layer runs in MLX.
120
+
121
+ Returns:
122
+ Context of shape ``(1, len(attributes), D_ctx)``, in the text encoder's dtype.
123
+ """
124
+ from tokenizers import Tokenizer
125
+
126
+ tokenizer = cast("TokenizerContract", Tokenizer.from_file(str(tokenizer_path)))
127
+ tokenizer.enable_truncation(max_length=256)
128
+ tokenizer.enable_padding(length=256, pad_id=0, pad_token="[PAD]")
129
+ tokens = tokenizer.encode_batch([str(value) for value in attributes.values()])
130
+ # Tokenizer IDs are nested numeric lists; list invariance loses the recursive type.
131
+ token_ids = cast(ArrayConstructor, mx.array)(
132
+ cast("ArrayValue", [token.ids for token in tokens]), dtype=mx.int32
133
+ )
134
+ encoded = model(token_ids)
135
+ mx.eval(encoded)
136
+ return encoded[None]