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,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]
|