medsam2-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 medsam2-mlx might be problematic. Click here for more details.

@@ -0,0 +1,45 @@
1
+ """Native MLX implementation of MedSAM2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from typing import TYPE_CHECKING
7
+
8
+ from medsam2_mlx.request import (
9
+ MedSAM2Prompt,
10
+ MedSAM2RuntimeConfig,
11
+ )
12
+
13
+ if TYPE_CHECKING:
14
+ from medsam2_mlx.mlx_model import create_model
15
+ from medsam2_mlx.mlx_predictor import PreparedVideoFrames, build_predictor
16
+ from medsam2_mlx.mlx_weights import load_model
17
+
18
+ __version__ = "0.2.1"
19
+
20
+ __all__ = [
21
+ "MedSAM2Prompt",
22
+ "MedSAM2RuntimeConfig",
23
+ "PreparedVideoFrames",
24
+ "build_predictor",
25
+ "create_model",
26
+ "load_model",
27
+ ]
28
+
29
+ # MLX loads with the model modules, so importing the medmlx declaration stays light.
30
+ _LAZY = {
31
+ "PreparedVideoFrames": "medsam2_mlx.mlx_predictor",
32
+ "build_predictor": "medsam2_mlx.mlx_predictor",
33
+ "create_model": "medsam2_mlx.mlx_model",
34
+ "load_model": "medsam2_mlx.mlx_weights",
35
+ }
36
+
37
+
38
+ def __getattr__(name: str) -> object:
39
+ """Import a model-dependent public name on first access and cache it."""
40
+ module = _LAZY.get(name)
41
+ if module is None:
42
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
43
+ value = getattr(import_module(module), name)
44
+ globals()[name] = value
45
+ return value
medsam2_mlx/_ops.py ADDED
@@ -0,0 +1,344 @@
1
+ # Ported from MedSAM2 (bowang-lab/MedSAM2 @ 332f30d), itself derived from SAM 2
2
+ # (Meta Platforms, Inc. and affiliates). Licensed under the Apache License, Version 2.0.
3
+ # Source files: sam2/modeling/sam2_utils.py, sam2/modeling/position_encoding.py,
4
+ # sam2/modeling/backbones/utils.py, plus PyTorch interpolation semantics.
5
+ """Shared MLX layers and PyTorch-compatible resampling used by the MedSAM2 graph.
6
+
7
+ Parameters keep PyTorch names, shapes, and layouts. Convolutions therefore hold
8
+ ``(out, in / groups, kh, kw)`` weights and transpose them at call time.
9
+ Spatial tensors that cross module boundaries use PyTorch's NCHW layout.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ from medsam2_mlx._types import FloatArray, HostArray
17
+
18
+ if TYPE_CHECKING:
19
+ from mlx.core import array
20
+
21
+ import math
22
+ from collections.abc import Callable, Sequence
23
+ from functools import lru_cache
24
+
25
+ import numpy as np
26
+
27
+ from medsam2_mlx._runtime import mx, nn
28
+
29
+
30
+ def gelu(x: array) -> array:
31
+ """Exact (erf) GELU, as ``torch.nn.GELU()``."""
32
+ return nn.gelu(x)
33
+
34
+
35
+ def relu(x: array) -> array:
36
+ """Rectified linear unit."""
37
+ return mx.maximum(x, 0)
38
+
39
+
40
+ class Conv2d(nn.Module):
41
+ """``torch.nn.Conv2d`` on NCHW inputs, holding the PyTorch OIHW weight."""
42
+
43
+ def __init__(
44
+ self,
45
+ in_channels: int,
46
+ out_channels: int,
47
+ kernel_size: int,
48
+ stride: int = 1,
49
+ padding: int = 0,
50
+ groups: int = 1,
51
+ ) -> None:
52
+ super().__init__()
53
+ scale = 1.0 / math.sqrt(in_channels // groups * kernel_size * kernel_size)
54
+ self.weight = mx.random.uniform(
55
+ -scale, scale, (out_channels, in_channels // groups, kernel_size, kernel_size)
56
+ )
57
+ self.bias = mx.random.uniform(-scale, scale, (out_channels,))
58
+ self.stride = stride
59
+ self.padding = padding
60
+ self.groups = groups
61
+
62
+ def nhwc(self, x: array) -> array:
63
+ """Apply to an NHWC input and return NHWC."""
64
+ if self.weight.shape[2:] == (1, 1) and self.groups == 1 and self.stride == 1:
65
+ weight = mx.reshape(self.weight, (self.weight.shape[0], -1))
66
+ return x @ weight.T + self.bias
67
+ y = mx.conv2d(
68
+ x,
69
+ mx.transpose(self.weight, (0, 2, 3, 1)),
70
+ stride=self.stride,
71
+ padding=self.padding,
72
+ groups=self.groups,
73
+ )
74
+ return y + self.bias
75
+
76
+ def __call__(self, x: array) -> array:
77
+ return mx.transpose(self.nhwc(mx.transpose(x, (0, 2, 3, 1))), (0, 3, 1, 2))
78
+
79
+
80
+ class ConvTranspose2x2(nn.Module):
81
+ """``torch.nn.ConvTranspose2d(kernel_size=2, stride=2)`` on NCHW inputs.
82
+
83
+ With stride equal to the kernel, every output pixel receives exactly one input
84
+ pixel, so the operation is a per-pixel matrix product followed by a pixel shuffle.
85
+ The weight keeps PyTorch's ``(in, out, 2, 2)`` layout.
86
+ """
87
+
88
+ def __init__(self, in_channels: int, out_channels: int) -> None:
89
+ super().__init__()
90
+ scale = 1.0 / math.sqrt(out_channels * 4)
91
+ self.weight = mx.random.uniform(-scale, scale, (in_channels, out_channels, 2, 2))
92
+ self.bias = mx.random.uniform(-scale, scale, (out_channels,))
93
+
94
+ def __call__(self, x: array) -> array:
95
+ b, c, h, w = x.shape
96
+ out = self.weight.shape[1]
97
+ y = mx.reshape(mx.transpose(x, (0, 2, 3, 1)), (b * h * w, c)) @ mx.reshape(
98
+ self.weight, (c, out * 4)
99
+ )
100
+ y = mx.transpose(mx.reshape(y, (b, h, w, out, 2, 2)), (0, 3, 1, 4, 2, 5))
101
+ return mx.reshape(y, (b, out, 2 * h, 2 * w)) + self.bias[None, :, None, None]
102
+
103
+
104
+ class LayerNorm2d(nn.Module):
105
+ """Channel LayerNorm on NCHW tensors (SAM's ``LayerNorm2d``)."""
106
+
107
+ def __init__(self, num_channels: int, eps: float = 1e-6) -> None:
108
+ super().__init__()
109
+ self.weight = mx.ones((num_channels,))
110
+ self.bias = mx.zeros((num_channels,))
111
+ self.eps = eps
112
+
113
+ def __call__(self, x: array) -> array:
114
+ u = mx.mean(x, axis=1, keepdims=True)
115
+ s = mx.mean(mx.square(x - u), axis=1, keepdims=True)
116
+ x = (x - u) / mx.sqrt(s + self.eps)
117
+ return self.weight[:, None, None] * x + self.bias[:, None, None]
118
+
119
+
120
+ class LayerNorm(nn.Module):
121
+ """``torch.nn.LayerNorm`` over the last axis."""
122
+
123
+ def __init__(self, dims: int, eps: float = 1e-5) -> None:
124
+ super().__init__()
125
+ self.weight = mx.ones((dims,))
126
+ self.bias = mx.zeros((dims,))
127
+ self.eps = eps
128
+
129
+ def __call__(self, x: array) -> array:
130
+ return mx.fast.layer_norm(x, self.weight, self.bias, self.eps)
131
+
132
+
133
+ class Activation(nn.Module):
134
+ """Parameter-free activation slot, keeping ``nn.Sequential`` indices aligned."""
135
+
136
+ def __init__(self, fn: Callable[[array], array]) -> None:
137
+ super().__init__()
138
+ self._fn = fn
139
+
140
+ def __call__(self, x: array) -> array:
141
+ return self._fn(x)
142
+
143
+
144
+ class Embedding(nn.Module):
145
+ """``torch.nn.Embedding`` used only through its ``weight`` table."""
146
+
147
+ def __init__(self, num: int, dims: int) -> None:
148
+ super().__init__()
149
+ self.weight = mx.random.normal((num, dims))
150
+
151
+
152
+ class MLP(nn.Module):
153
+ """SAM's MLP: ``num_layers`` linears, activation between them."""
154
+
155
+ def __init__(
156
+ self,
157
+ input_dim: int,
158
+ hidden_dim: int,
159
+ output_dim: int,
160
+ num_layers: int,
161
+ activation: Callable[[array], array] = relu,
162
+ sigmoid_output: bool = False,
163
+ ) -> None:
164
+ super().__init__()
165
+ hidden = [hidden_dim] * (num_layers - 1)
166
+ self.layers = [
167
+ nn.Linear(n, k)
168
+ for n, k in zip([input_dim, *hidden], [*hidden, output_dim], strict=True)
169
+ ]
170
+ self._activation = activation
171
+ self.sigmoid_output = sigmoid_output
172
+
173
+ def __call__(self, x: array) -> array:
174
+ last = len(self.layers) - 1
175
+ for index, layer in enumerate(self.layers):
176
+ x = layer(x) if index == last else self._activation(layer(x))
177
+ return mx.sigmoid(x) if self.sigmoid_output else x
178
+
179
+
180
+ def sequential(layers: Sequence[Callable[[array], array]], x: array) -> array:
181
+ """Apply ``layers`` in order (``nn.Sequential`` for module lists held as attributes)."""
182
+ for layer in layers:
183
+ x = layer(x)
184
+ return x
185
+
186
+
187
+ # --- Positional encodings -----------------------------------------------------------
188
+
189
+
190
+ @lru_cache(maxsize=32)
191
+ def _sine_position(num_pos_feats: int, height: int, width: int) -> HostArray:
192
+ """``PositionEmbeddingSine(num_pos_feats, normalize=True)`` for one image, (C, H, W)."""
193
+ half = num_pos_feats // 2
194
+ scale = np.float32(2 * math.pi)
195
+ eps = np.float32(1e-6)
196
+ y = np.arange(1, height + 1, dtype=np.float32)
197
+ x = np.arange(1, width + 1, dtype=np.float32)
198
+ y = y / (y[-1] + eps) * scale
199
+ x = x / (x[-1] + eps) * scale
200
+ dim_t = np.arange(half, dtype=np.float32)
201
+ dim_t = np.float32(10000) ** (2 * (dim_t // 2) / np.float32(half))
202
+
203
+ def encode(values: FloatArray) -> FloatArray:
204
+ pos = values[:, None] / dim_t
205
+ return np.stack((np.sin(pos[:, 0::2]), np.cos(pos[:, 1::2])), axis=2).reshape(
206
+ len(values), -1
207
+ )
208
+
209
+ pos_y = np.broadcast_to(encode(y)[:, None, :], (height, width, half))
210
+ pos_x = np.broadcast_to(encode(x)[None, :, :], (height, width, half))
211
+ return np.ascontiguousarray(np.concatenate([pos_y, pos_x], axis=2).transpose(2, 0, 1))
212
+
213
+
214
+ def sine_position_encoding(num_pos_feats: int, batch: int, height: int, width: int) -> array:
215
+ """Sine position encoding broadcast to (batch, num_pos_feats, height, width).
216
+
217
+ The encoding is the same for every batch element, so it is computed once per size.
218
+ """
219
+ pos = mx.array(_sine_position(num_pos_feats, height, width))
220
+ return mx.broadcast_to(pos[None], (batch, *pos.shape))
221
+
222
+
223
+ def get_1d_sine_pe(pos_inds: array, dim: int, temperature: float = 10000) -> array:
224
+ """1D sine/cosine encoding of positions ``pos_inds`` (...,) as (..., dim).
225
+
226
+ The first ``dim // 2`` channels are sines and the rest cosines (not interleaved).
227
+ """
228
+ pe_dim = dim // 2
229
+ dim_t = np.arange(pe_dim, dtype=np.float32)
230
+ dim_t = np.float32(temperature) ** (2 * (dim_t // 2) / np.float32(pe_dim))
231
+ pos_embed = pos_inds[..., None] / mx.array(dim_t)
232
+ return mx.concatenate([mx.sin(pos_embed), mx.cos(pos_embed)], axis=-1)
233
+
234
+
235
+ @lru_cache(maxsize=16)
236
+ def axial_rope(dim: int, end_x: int, end_y: int, theta: float) -> tuple[HostArray, HostArray]:
237
+ """Cosine/sine tables of ``compute_axial_cis``: x frequencies, then y, (L, dim / 2)."""
238
+ exponents = np.arange(0, dim, 4, dtype=np.float32)[: dim // 4] / np.float32(dim)
239
+ freqs = np.float32(1.0) / (np.float32(theta) ** exponents)
240
+ t = np.arange(end_x * end_y, dtype=np.float32)
241
+ t_x = t % np.float32(end_x)
242
+ t_y = np.floor(t / np.float32(end_x))
243
+ angles = np.concatenate([np.outer(t_x, freqs), np.outer(t_y, freqs)], axis=-1)
244
+ angles = angles.astype(np.float32)
245
+ return np.cos(angles), np.sin(angles)
246
+
247
+
248
+ def apply_rope(x: array, cos: array, sin: array) -> array:
249
+ """Rotate adjacent (real, imaginary) channel pairs; ``x`` is (..., L, D)."""
250
+ pairs = mx.reshape(x, (*x.shape[:-1], -1, 2))
251
+ real, imag = pairs[..., 0], pairs[..., 1]
252
+ rotated = mx.stack([real * cos - imag * sin, real * sin + imag * cos], axis=-1)
253
+ return mx.reshape(rotated, x.shape)
254
+
255
+
256
+ # --- PyTorch-compatible interpolation -------------------------------------------------
257
+
258
+
259
+ @lru_cache(maxsize=64)
260
+ def _bilinear_matrix(source: int, target: int, antialias: bool) -> HostArray:
261
+ """Row-stochastic (target, source) weights of ``F.interpolate(mode="bilinear")``.
262
+
263
+ ``align_corners=False``. With ``antialias`` and downsampling, the triangle filter
264
+ is widened by the scale factor, as in PyTorch's antialiased kernels.
265
+ """
266
+ scale = np.float32(source / target)
267
+ weights = np.zeros((target, source), dtype=np.float32)
268
+ if antialias and scale > 1:
269
+ centers = (np.arange(target, dtype=np.float32) + np.float32(0.5)) * scale
270
+ support = scale
271
+ for index, center in enumerate(centers):
272
+ low = max(int(center - support + np.float32(0.5)), 0)
273
+ high = min(int(center + support + np.float32(0.5)), source)
274
+ taps = np.arange(low, high, dtype=np.float32)
275
+ distance = np.abs((taps - center + np.float32(0.5)) / scale)
276
+ tap_weights = np.where(distance < 1, 1 - distance, 0).astype(np.float32)
277
+ total = tap_weights.sum(dtype=np.float32)
278
+ if total != 0:
279
+ tap_weights = tap_weights / total
280
+ weights[index, low:high] = tap_weights
281
+ return weights
282
+ src = (np.arange(target, dtype=np.float32) + np.float32(0.5)) * scale - np.float32(0.5)
283
+ src = np.maximum(src, np.float32(0))
284
+ i0 = np.minimum(np.floor(src).astype(np.int64), source - 1)
285
+ i1 = np.minimum(i0 + 1, source - 1)
286
+ l1 = (src - i0.astype(np.float32)).astype(np.float32)
287
+ l0 = (np.float32(1) - l1).astype(np.float32)
288
+ rows = np.arange(target)
289
+ np.add.at(weights, (rows, i0), l0)
290
+ np.add.at(weights, (rows, i1), l1)
291
+ return weights
292
+
293
+
294
+ def interpolate_bilinear(x: array, size: tuple[int, int], *, antialias: bool = False) -> array:
295
+ """``F.interpolate(x, size, mode="bilinear", align_corners=False)`` on NCHW.
296
+
297
+ ``size`` is (height, width). Resizing is a matrix product with precomputed weights on
298
+ the last two axes, so any leading shape works. An unchanged size returns ``x`` itself.
299
+ ``antialias=True`` follows PyTorch's antialiased kernel when downsampling.
300
+ """
301
+ height, width = x.shape[-2:]
302
+ if (height, width) == tuple(size):
303
+ return x
304
+ rows = mx.array(_bilinear_matrix(height, size[0], antialias))
305
+ cols = mx.array(_bilinear_matrix(width, size[1], antialias))
306
+ return rows @ x @ cols.T
307
+
308
+
309
+ @lru_cache(maxsize=8)
310
+ def _bicubic_matrix(source: int, target: int) -> HostArray:
311
+ """(target, source) weights of ``F.interpolate(mode="bicubic", align_corners=False)``."""
312
+ a = -0.75
313
+ scale = source / target
314
+ weights = np.zeros((target, source), dtype=np.float64)
315
+ for index in range(target):
316
+ real = scale * (index + 0.5) - 0.5
317
+ base = math.floor(real)
318
+ t = real - base
319
+
320
+ def near(x: float) -> float:
321
+ return ((a + 2) * x - (a + 3)) * x * x + 1
322
+
323
+ def far(x: float) -> float:
324
+ return ((a * x - 5 * a) * x + 8 * a) * x - 4 * a
325
+
326
+ for offset, weight in zip(
327
+ (-1, 0, 1, 2), (far(t + 1), near(t), near(1 - t), far(2 - t)), strict=True
328
+ ):
329
+ weights[index, min(max(base + offset, 0), source - 1)] += weight
330
+ return weights.astype(np.float32)
331
+
332
+
333
+ def interpolate_bicubic(x: array, size: tuple[int, int]) -> array:
334
+ """``F.interpolate(x, size, mode="bicubic", align_corners=False)`` on NCHW."""
335
+ rows = mx.array(_bicubic_matrix(x.shape[-2], size[0]))
336
+ cols = mx.array(_bicubic_matrix(x.shape[-1], size[1]))
337
+ return rows @ x @ cols.T
338
+
339
+
340
+ def upsample_nearest2x(x: array) -> array:
341
+ """``F.interpolate(x, scale_factor=2.0, mode="nearest")`` on NCHW."""
342
+ b, c, h, w = x.shape
343
+ x = mx.broadcast_to(x[:, :, :, None, :, None], (b, c, h, 2, w, 2))
344
+ return mx.reshape(x, (b, c, 2 * h, 2 * w))
@@ -0,0 +1,57 @@
1
+ """Apple Silicon and Metal admission for the native graph; imports remain lazy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ from importlib import import_module
7
+ from types import ModuleType
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from medsam2_mlx._types import CoreRuntime
11
+
12
+ if TYPE_CHECKING:
13
+ from mlx import nn as nn
14
+
15
+ mx: CoreRuntime
16
+
17
+ from medmlx_core.errors import MissingDependencyError
18
+ from medmlx_core.runtime import import_mlx as _import_mlx
19
+
20
+ _METAL_HINT = "Run medsam2-mlx on macOS arm64 with MLX installed and Metal available."
21
+
22
+
23
+ def import_mlx() -> CoreRuntime:
24
+ """Return the MLX core module after admitting macOS arm64 with an available Metal GPU.
25
+
26
+ Raises:
27
+ MissingDependencyError: The host is not macOS arm64, MLX cannot be imported, or
28
+ Metal is unavailable. There is no CPU fallback.
29
+ """
30
+ host = (platform.system(), platform.machine())
31
+ if host != ("Darwin", "arm64"):
32
+ raise MissingDependencyError(
33
+ f"MedSAM2 requires macOS arm64 with Metal; this host is {' '.join(host)}",
34
+ extra="mlx",
35
+ hint=_METAL_HINT,
36
+ )
37
+ try:
38
+ mx = cast(CoreRuntime, _import_mlx())
39
+ except MissingDependencyError as error:
40
+ raise MissingDependencyError(str(error), extra="mlx", hint=_METAL_HINT) from error
41
+ if not mx.metal.is_available():
42
+ raise MissingDependencyError(
43
+ "MedSAM2 requires Metal; no Metal device is available",
44
+ extra="mlx",
45
+ hint=_METAL_HINT,
46
+ )
47
+ return mx
48
+
49
+
50
+ def __getattr__(name: str) -> CoreRuntime | ModuleType:
51
+ """Resolve ``mx`` and ``nn`` lazily so importing the package does not load MLX."""
52
+ if name not in {"mx", "nn"}:
53
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
54
+ mx = import_mlx()
55
+ value = mx if name == "mx" else import_module("mlx.nn")
56
+ globals()[name] = value
57
+ return value