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.
- medsam2_mlx/__init__.py +45 -0
- medsam2_mlx/_ops.py +344 -0
- medsam2_mlx/_runtime.py +57 -0
- medsam2_mlx/_types.py +615 -0
- medsam2_mlx/config.py +114 -0
- medsam2_mlx/conversion.py +75 -0
- medsam2_mlx/image_encoder.py +289 -0
- medsam2_mlx/mask_decoder.py +190 -0
- medsam2_mlx/medmlx.py +1125 -0
- medsam2_mlx/memory_attention.py +103 -0
- medsam2_mlx/memory_encoder.py +121 -0
- medsam2_mlx/mlx_model.py +109 -0
- medsam2_mlx/mlx_predictor.py +314 -0
- medsam2_mlx/mlx_weights.py +102 -0
- medsam2_mlx/prompt_encoder.py +128 -0
- medsam2_mlx/py.typed +0 -0
- medsam2_mlx/request.py +102 -0
- medsam2_mlx/sam2_base.py +489 -0
- medsam2_mlx/transformer.py +206 -0
- medsam2_mlx/video_predictor.py +952 -0
- medsam2_mlx-0.2.1.dist-info/METADATA +111 -0
- medsam2_mlx-0.2.1.dist-info/RECORD +26 -0
- medsam2_mlx-0.2.1.dist-info/WHEEL +4 -0
- medsam2_mlx-0.2.1.dist-info/entry_points.txt +2 -0
- medsam2_mlx-0.2.1.dist-info/licenses/LICENSE +202 -0
- medsam2_mlx-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +241 -0
medsam2_mlx/__init__.py
ADDED
|
@@ -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))
|
medsam2_mlx/_runtime.py
ADDED
|
@@ -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
|