musclemap-mlx 0.1.0__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.
@@ -0,0 +1,11 @@
1
+ """MuscleMap v1.4 whole-body segmentation with native MLX on Apple Silicon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+
7
+
8
+ def __getattr__(name: str) -> str:
9
+ if name == "__version__":
10
+ return version("musclemap-mlx")
11
+ raise AttributeError(name)
@@ -0,0 +1,45 @@
1
+ """Check publisher asset completeness without deserializing checkpoint data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ from musclemap_mlx.config import CONFIG_NAME, MODEL_ID
10
+
11
+ ASSETS = {
12
+ "contrast_agnostic_wholebody_model.pth": 104884148,
13
+ "contrast_agnostic_wholebody_model.json": 10555,
14
+ "LICENSE": 1087,
15
+ }
16
+
17
+
18
+ def verify_assets(folder: Path) -> dict[str, str]:
19
+ """Check publisher file sizes and return each asset's absolute path.
20
+
21
+ Raises:
22
+ ValueError: An asset is missing or incomplete for Zenodo record 21929873.
23
+ """
24
+ paths: dict[str, str] = {}
25
+ for name, size in ASSETS.items():
26
+ path = folder / name
27
+ if not path.is_file():
28
+ raise ValueError(f"Missing asset: {path}; obtain it from Zenodo record 21929873")
29
+ if path.stat().st_size != size:
30
+ raise ValueError(f"Unexpected size for {name}; expected {size} bytes")
31
+ paths[name] = path.absolute().as_posix()
32
+ return paths
33
+
34
+
35
+ def inspect_assets(folder: Path) -> dict[str, object]:
36
+ """Check asset completeness and return the original configuration as JSON data."""
37
+ verify_assets(folder)
38
+ config = cast(object, json.loads((folder / CONFIG_NAME).read_text(encoding="utf-8")))
39
+ return {
40
+ "status": "assets-complete",
41
+ "model_id": MODEL_ID,
42
+ "record": "https://zenodo.org/records/21929873",
43
+ "configuration": config,
44
+ "checkpoint_deserialized": False,
45
+ }
musclemap_mlx/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """Command line for MuscleMap v1.4: optional weights download, then segmentation.
2
+
3
+ Every subcommand prints one JSON object on stdout. Errors print one line on stderr
4
+ and exit 1; usage errors exit 2. medmlx.org reads this parser with ``ast`` to
5
+ check published commands, so each subcommand uses literal flags and choices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from importlib import import_module
16
+ from importlib.metadata import version
17
+ from pathlib import Path
18
+
19
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
20
+
21
+
22
+ def main(argv: Sequence[str] | None = None) -> int:
23
+ """Run one subcommand and return its exit status."""
24
+ parser = argparse.ArgumentParser(
25
+ prog="musclemap-mlx",
26
+ description="MuscleMap v1.4 whole-body segmentation with native MLX",
27
+ )
28
+ parser.add_argument("--version", action="version", version=version("musclemap-mlx"))
29
+ commands = parser.add_subparsers(dest="command", required=True)
30
+
31
+ download = commands.add_parser("download", help="Download converted weights from the Hub")
32
+ download.add_argument(
33
+ "--repo",
34
+ default="MedMLX/musclemap-mlx",
35
+ help="Hugging Face repository (default: %(default)s)",
36
+ )
37
+ download.add_argument(
38
+ "--revision", type=_commit, help="Override the reviewed 40-character commit"
39
+ )
40
+ download.add_argument(
41
+ "--model-dir", type=Path, help="Destination folder; omit to use the cache"
42
+ )
43
+
44
+ segment = commands.add_parser("segment", help="Segment one 3D NIfTI volume")
45
+ segment.add_argument("--input", required=True, type=Path, help="3D NIfTI image")
46
+ segment.add_argument(
47
+ "--model-dir", type=Path, help="Folder containing model.safetensors"
48
+ )
49
+ segment.add_argument("--output-dir", required=True, type=Path)
50
+ segment.add_argument(
51
+ "--chunk-size",
52
+ type=int,
53
+ default=None,
54
+ help="Positive axial slices per independent pass; omit for automatic",
55
+ )
56
+ segment.add_argument(
57
+ "--overlap",
58
+ type=int,
59
+ default=90,
60
+ help="Gaussian slice-window overlap percentage in [0, 99] (default: %(default)s)",
61
+ )
62
+ segment.add_argument("--overwrite", action="store_true")
63
+
64
+ args = parser.parse_args(argv)
65
+ try:
66
+ if args.command == "download":
67
+ result = _download(args.repo, args.revision, args.model_dir)
68
+ else:
69
+ from musclemap_mlx.medmlx import run
70
+
71
+ result = run(
72
+ image_path=args.input,
73
+ weights_path=args.model_dir,
74
+ output_dir=args.output_dir,
75
+ chunk_size=args.chunk_size,
76
+ overlap_percent=args.overlap,
77
+ overwrite=args.overwrite,
78
+ )
79
+ except (OSError, ValueError, RuntimeError, ImportError) as error:
80
+ print(f"{parser.prog}: {type(error).__name__}: {error}", file=sys.stderr)
81
+ return 1
82
+ print(json.dumps(result, indent=2))
83
+ return 0
84
+
85
+
86
+ def _commit(value: str) -> str:
87
+ if not _COMMIT.fullmatch(value):
88
+ raise argparse.ArgumentTypeError("must be a full 40-character lowercase commit hash")
89
+ return value
90
+
91
+
92
+ def _download(repo: str, revision: str | None, model_dir: Path | None) -> dict[str, object]:
93
+ """Download one Hub commit into ``model_dir`` and record it for revision lookup."""
94
+ from httpx import HTTPError
95
+ from medmlx_core.errors import AssetNotReadyError
96
+ from medmlx_core.weights import weights_dir, weights_revision
97
+
98
+ if revision is None:
99
+ if repo != "MedMLX/musclemap-mlx":
100
+ raise ValueError("Custom repositories require --revision with a full reviewed commit")
101
+ cached = weights_dir("musclemap")
102
+ revision = weights_revision(cached)
103
+ if revision is None:
104
+ raise AssetNotReadyError("The completed MuscleMap cache has no revision")
105
+ import shutil
106
+
107
+ folder = cached if model_dir is None else model_dir.expanduser().absolute()
108
+ if folder != cached:
109
+ folder.mkdir(parents=True, exist_ok=True)
110
+ (folder / ".medmlx-revision").unlink(missing_ok=True)
111
+ for source in cached.iterdir():
112
+ if source.is_file() and not source.name.startswith("."):
113
+ shutil.copyfile(source, folder / source.name)
114
+ (folder / ".medmlx-revision").write_text(revision + "\n", encoding="utf-8")
115
+ return {"model_dir": folder.as_posix(), "repo": repo, "revision": revision}
116
+ if model_dir is None:
117
+ raise ValueError("An explicit --revision requires --model-dir")
118
+ hub = import_module("huggingface_hub") # installed by medmlx-core
119
+ folder = model_dir.expanduser().absolute()
120
+ marker = folder / ".medmlx-revision"
121
+ marker.unlink(missing_ok=True)
122
+ try:
123
+ hub.snapshot_download(
124
+ repo,
125
+ revision=revision,
126
+ local_dir=folder,
127
+ allow_patterns=[
128
+ "model.safetensors",
129
+ "config.json",
130
+ "contrast_agnostic_wholebody_model.json",
131
+ "LICENSE",
132
+ "NOTICE",
133
+ "labels.json",
134
+ "README.md",
135
+ "conversion.json",
136
+ ],
137
+ )
138
+ except (HTTPError, OSError) as error:
139
+ raise AssetNotReadyError(
140
+ f"Could not download {repo}@{revision}: {error}", hint="Rerun to resume."
141
+ ) from error
142
+ marker.write_text(revision + "\n", encoding="utf-8")
143
+ return {"model_dir": folder.as_posix(), "repo": repo, "revision": revision}
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
@@ -0,0 +1,343 @@
1
+ """Publisher configuration, label order and the pinned MONAI UNet inventory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal, cast
10
+
11
+ from musclemap_mlx.labels import LABELS
12
+
13
+ CONFIG_NAME = "contrast_agnostic_wholebody_model.json"
14
+ CHECKPOINT_NAME = "contrast_agnostic_wholebody_model.pth"
15
+ CONVERTED_NAME = "model.safetensors"
16
+ MODEL_ID = "musclemap"
17
+ UPSTREAM_REVISION = "35a2138a4a3a63be713bae5584632ffc6433b960"
18
+ ZENODO_RECORD = "https://zenodo.org/records/21929873"
19
+
20
+ # The publisher's inference configuration and checkpoint were trained with these
21
+ # fixed choices. The converter and network reject a config that drifts from them.
22
+ EXPECTED_CHANNELS = (64, 128, 256, 512, 1024)
23
+ EXPECTED_STRIDES = (2, 2, 2, 2)
24
+ EXPECTED_PIXDIM = (1.0, 1.0, -1.0)
25
+ EXPECTED_ROI = (256, 256)
26
+ EXPECTED_OUTPUTS = 114
27
+ EXPECTED_LABELS = 113
28
+ INSTANCE_NORM_EPS = 1e-5
29
+ LEAKY_SLOPE = 0.01
30
+ CROP_MARGIN = 20
31
+ PAD_SIZE = (256, 256, 1)
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Label:
36
+ """One publisher anatomical label, keeping its original integer ID."""
37
+
38
+ value: int
39
+ region: str
40
+ anatomy: str
41
+ side: str
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ """Return a readable name without replacing the anatomical ID."""
46
+ return self.anatomy if self.side == "no side" else f"{self.side} {self.anatomy}"
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ModelSpec:
51
+ """Validated publisher model and inference settings."""
52
+
53
+ version: str
54
+ spatial_dims: Literal[2]
55
+ in_channels: Literal[1]
56
+ out_channels: Literal[114]
57
+ channels: tuple[int, ...]
58
+ strides: tuple[int, ...]
59
+ num_res_units: int
60
+ act: Literal["LeakyReLU"]
61
+ norm: Literal["instance"]
62
+ roi_size: tuple[int, int]
63
+ pix_dim: tuple[float, float, float]
64
+ spatial_window_batch_size: int
65
+ labels: tuple[Label, ...]
66
+
67
+ @property
68
+ def class_labels(self) -> tuple[int, ...]:
69
+ """Return background followed by the sorted original anatomical IDs."""
70
+ return (0, *(label.value for label in self.labels))
71
+
72
+ @property
73
+ def label_details(self) -> tuple[dict[str, str | int], ...]:
74
+ """Return the MedMLX ``details.labels`` entries in network channel order."""
75
+ return (
76
+ {"value": 0, "name": "background"},
77
+ *({"value": label.value, "name": label.name} for label in self.labels),
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class ParameterInventory:
83
+ """Expected checkpoint names, shapes and the transposed-convolution subset."""
84
+
85
+ shapes: dict[str, tuple[int, ...]]
86
+ deconv_weights: frozenset[str]
87
+
88
+
89
+ def default_labels() -> tuple[Label, ...]:
90
+ """Return the publisher's 113 anatomical labels in ascending ID order."""
91
+ return tuple(
92
+ Label(value=value, region=region, anatomy=anatomy, side=side)
93
+ for value, region, anatomy, side in LABELS
94
+ )
95
+
96
+
97
+ def default_spec() -> ModelSpec:
98
+ """Return the pinned v1.4 model contract without reading local assets."""
99
+ return ModelSpec(
100
+ version="1.4",
101
+ spatial_dims=2,
102
+ in_channels=1,
103
+ out_channels=114,
104
+ channels=EXPECTED_CHANNELS,
105
+ strides=EXPECTED_STRIDES,
106
+ num_res_units=2,
107
+ act="LeakyReLU",
108
+ norm="instance",
109
+ roi_size=EXPECTED_ROI,
110
+ pix_dim=EXPECTED_PIXDIM,
111
+ spatial_window_batch_size=1,
112
+ labels=default_labels(),
113
+ )
114
+
115
+
116
+ def load_model_config(path: Path) -> ModelSpec:
117
+ """Load the publisher JSON and reject anything outside the v1.4 contract.
118
+
119
+ Raises:
120
+ OSError: The file cannot be read.
121
+ ValueError: The JSON is malformed, a label ID is duplicated, or an
122
+ architecture or inference setting differs from the published v1.4
123
+ configuration this native port implements.
124
+ """
125
+ payload: object = json.loads(path.read_text(encoding="utf-8"))
126
+ root = _mapping(payload, "configuration")
127
+ parameters = _mapping(root.get("parameters"), "parameters")
128
+ model = _mapping(root.get("model"), "model")
129
+ raw_labels = _sequence(root.get("labels"), "labels")
130
+
131
+ labels = tuple(_parse_label(item, index) for index, item in enumerate(raw_labels))
132
+ if len(labels) != EXPECTED_LABELS:
133
+ raise ValueError(f"expected {EXPECTED_LABELS} anatomical labels, got {len(labels)}")
134
+ values = {label.value for label in labels}
135
+ if len(values) != len(labels) or 0 in values or min(values) < 1:
136
+ raise ValueError("anatomical label IDs must be unique positive integers")
137
+ if tuple(sorted(values)) != tuple(label.value for label in labels):
138
+ raise ValueError("publisher labels are not in ascending anatomical ID order")
139
+ if tuple((label.value, label.region, label.anatomy, label.side) for label in labels) != LABELS:
140
+ raise ValueError("publisher labels differ from the pinned v1.4 anatomical table")
141
+
142
+ spatial_dims = _integer(model.get("spatial_dims"), "spatial_dims")
143
+ in_channels = _integer(model.get("in_channels"), "in_channels")
144
+ out_channels = _integer(model.get("out_channels"), "out_channels")
145
+ num_res_units = _integer(model.get("num_res_units"), "num_res_units")
146
+ channels = _integers(model.get("channels"), "channels")
147
+ strides = _integers(model.get("strides"), "strides")
148
+ roi_size = _integers(parameters.get("roi_size"), "roi_size")
149
+ pix_dim = _floats(parameters.get("pix_dim"), "pix_dim")
150
+ window_batch = _integer(
151
+ parameters.get("spatial_window_batch_size"), "spatial_window_batch_size"
152
+ )
153
+ version = f"{_number(model.get('version'), 'version'):g}"
154
+ act = _string(model.get("act"), "act")
155
+ norm = _string(model.get("norm"), "norm")
156
+
157
+ if spatial_dims != 2:
158
+ raise ValueError("MuscleMap v1.4 native MLX port requires spatial_dims=2")
159
+ if in_channels != 1 or out_channels != EXPECTED_OUTPUTS:
160
+ raise ValueError("MuscleMap v1.4 requires one input and 114 output channels")
161
+ if channels != EXPECTED_CHANNELS or strides != EXPECTED_STRIDES:
162
+ raise ValueError("MuscleMap v1.4 channels/strides differ from the pinned UNet")
163
+ if num_res_units != 2:
164
+ raise ValueError("MuscleMap v1.4 requires two residual units per encoder stage")
165
+ if act != "LeakyReLU" or norm != "instance":
166
+ raise ValueError("MuscleMap v1.4 requires LeakyReLU and instance normalization")
167
+ if roi_size != EXPECTED_ROI:
168
+ raise ValueError(f"MuscleMap v1.4 requires roi_size={EXPECTED_ROI}")
169
+ if pix_dim != EXPECTED_PIXDIM:
170
+ raise ValueError(f"MuscleMap v1.4 requires pix_dim={EXPECTED_PIXDIM}")
171
+ if window_batch < 1:
172
+ raise ValueError("spatial_window_batch_size must be positive")
173
+ if tuple(_version_parts(version)) != (1, 4):
174
+ raise ValueError("MuscleMap native MLX port implements configuration version 1.4")
175
+
176
+ return ModelSpec(
177
+ version=version,
178
+ spatial_dims=2,
179
+ in_channels=1,
180
+ out_channels=114,
181
+ channels=channels,
182
+ strides=strides,
183
+ num_res_units=num_res_units,
184
+ act="LeakyReLU",
185
+ norm="instance",
186
+ roi_size=(roi_size[0], roi_size[1]),
187
+ pix_dim=(pix_dim[0], pix_dim[1], pix_dim[2]),
188
+ spatial_window_batch_size=window_batch,
189
+ labels=labels,
190
+ )
191
+
192
+
193
+ def expected_parameters(spec: ModelSpec) -> ParameterInventory:
194
+ """Return every checkpoint tensor and shape for the published UNet.
195
+
196
+ The names and nesting follow ``monai.networks.nets.UNet``: top-level
197
+ ``model.0``/``model.1``/``model.2``, nested ``submodule`` sequences, two
198
+ residual convolutions per encoder stage and one transposed convolution per
199
+ decoder stage.
200
+ """
201
+ shapes: dict[str, tuple[int, ...]] = {}
202
+ deconv_weights: set[str] = set()
203
+
204
+ def add(name: str, shape: tuple[int, ...]) -> None:
205
+ if name in shapes:
206
+ raise ValueError(f"duplicate expected parameter: {name}")
207
+ shapes[name] = shape
208
+
209
+ def residual_unit(
210
+ prefix: str, in_channels: int, out_channels: int, stride: int, subunits: int
211
+ ) -> None:
212
+ for unit in range(subunits):
213
+ input_channels = in_channels if unit == 0 else out_channels
214
+ add(f"{prefix}.conv.unit{unit}.conv.weight", (out_channels, input_channels, 3, 3))
215
+ add(f"{prefix}.conv.unit{unit}.conv.bias", (out_channels,))
216
+ if stride != 1 or in_channels != out_channels:
217
+ kernel = 1 if stride == 1 else 3
218
+ add(f"{prefix}.residual.weight", (out_channels, in_channels, kernel, kernel))
219
+ add(f"{prefix}.residual.bias", (out_channels,))
220
+
221
+ def up_layer(prefix: str, in_channels: int, out_channels: int, is_top: bool) -> None:
222
+ deconv = f"{prefix}.0.conv.weight"
223
+ deconv_weights.add(deconv)
224
+ add(deconv, (in_channels, out_channels, 3, 3))
225
+ add(f"{prefix}.0.conv.bias", (out_channels,))
226
+ residual_unit(f"{prefix}.1", out_channels, out_channels, 1, 1)
227
+ # The final top residual unit is conv-only; instance norm has no parameters.
228
+ _ = is_top
229
+
230
+ def block(
231
+ down_prefix: str,
232
+ up_prefix: str,
233
+ sub_prefix: str,
234
+ in_channels: int,
235
+ out_channels: int,
236
+ channels: tuple[int, ...],
237
+ strides: tuple[int, ...],
238
+ is_top: bool,
239
+ ) -> None:
240
+ channel_values = list(channels)
241
+ stride_values = list(strides)
242
+ channels_here = channel_values[0]
243
+ stride = stride_values[0]
244
+ residual_unit(down_prefix, in_channels, channels_here, stride, spec.num_res_units)
245
+ if len(channel_values) > 2:
246
+ block(
247
+ f"{sub_prefix}.submodule.0",
248
+ f"{sub_prefix}.submodule.2",
249
+ f"{sub_prefix}.submodule.1",
250
+ channels_here,
251
+ channels_here,
252
+ tuple(channel_values[1:]),
253
+ tuple(stride_values[1:]),
254
+ False,
255
+ )
256
+ up_channels = channels_here * 2
257
+ else:
258
+ next_channels = channel_values[1]
259
+ residual_unit(
260
+ f"{sub_prefix}.submodule",
261
+ channels_here,
262
+ next_channels,
263
+ 1,
264
+ spec.num_res_units,
265
+ )
266
+ up_channels = channels_here + next_channels
267
+ up_layer(up_prefix, up_channels, out_channels, is_top)
268
+
269
+ block(
270
+ "model.0",
271
+ "model.2",
272
+ "model.1",
273
+ spec.in_channels,
274
+ spec.out_channels,
275
+ spec.channels,
276
+ spec.strides,
277
+ True,
278
+ )
279
+ return ParameterInventory(shapes=shapes, deconv_weights=frozenset(deconv_weights))
280
+
281
+
282
+ def _parse_label(value: object, index: int) -> Label:
283
+ entry = _mapping(value, f"labels[{index}]")
284
+ return Label(
285
+ value=_integer(entry.get("value"), f"labels[{index}].value"),
286
+ region=_string(entry.get("region"), f"labels[{index}].region"),
287
+ anatomy=_string(entry.get("anatomy"), f"labels[{index}].anatomy"),
288
+ side=_string(entry.get("side"), f"labels[{index}].side"),
289
+ )
290
+
291
+
292
+ def _mapping(value: object, what: str) -> dict[str, object]:
293
+ if not isinstance(value, dict):
294
+ raise ValueError(f"{what} must be a JSON object")
295
+ return cast(dict[str, object], value)
296
+
297
+
298
+ def _sequence(value: object, what: str) -> list[object]:
299
+ if not isinstance(value, list):
300
+ raise ValueError(f"{what} must be a JSON array")
301
+ return cast(list[object], value)
302
+
303
+
304
+ def _integer(value: object, what: str) -> int:
305
+ if isinstance(value, bool) or not isinstance(value, int):
306
+ raise ValueError(f"{what} must be an integer")
307
+ return value
308
+
309
+
310
+ def _string(value: object, what: str) -> str:
311
+ if not isinstance(value, str):
312
+ raise ValueError(f"{what} must be a string")
313
+ return value
314
+
315
+
316
+ def _number(value: object, what: str) -> float:
317
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
318
+ raise ValueError(f"{what} must be a number")
319
+ return float(value)
320
+
321
+
322
+ def _integers(value: object, what: str) -> tuple[int, ...]:
323
+ values = _sequence(value, what)
324
+ return tuple(_integer(item, f"{what}[]") for item in values)
325
+
326
+
327
+ def _floats(value: object, what: str) -> tuple[float, ...]:
328
+ values = _sequence(value, what)
329
+ result: list[float] = []
330
+ for item in values:
331
+ if isinstance(item, bool) or not isinstance(item, (int, float)):
332
+ raise ValueError(f"{what} must contain numbers")
333
+ result.append(float(item))
334
+ return tuple(result)
335
+
336
+
337
+ def _version_parts(version: str) -> Sequence[int]:
338
+ parts: list[int] = []
339
+ for component in version.split("."):
340
+ if not component.isdigit():
341
+ raise ValueError(f"unsupported model version: {version}")
342
+ parts.append(int(component))
343
+ return parts