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.
- musclemap_mlx/__init__.py +11 -0
- musclemap_mlx/assets.py +45 -0
- musclemap_mlx/cli.py +147 -0
- musclemap_mlx/config.py +343 -0
- musclemap_mlx/geometry.py +662 -0
- musclemap_mlx/labels.py +123 -0
- musclemap_mlx/medmlx.py +213 -0
- musclemap_mlx/network.py +291 -0
- musclemap_mlx/pipeline.py +149 -0
- musclemap_mlx/py.typed +0 -0
- musclemap_mlx/weights.py +152 -0
- musclemap_mlx-0.1.0.dist-info/METADATA +155 -0
- musclemap_mlx-0.1.0.dist-info/RECORD +18 -0
- musclemap_mlx-0.1.0.dist-info/WHEEL +4 -0
- musclemap_mlx-0.1.0.dist-info/entry_points.txt +5 -0
- musclemap_mlx-0.1.0.dist-info/licenses/LICENSE +21 -0
- musclemap_mlx-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +35 -0
- musclemap_mlx-0.1.0.dist-info/licenses/UPSTREAM_LICENSE +21 -0
|
@@ -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)
|
musclemap_mlx/assets.py
ADDED
|
@@ -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())
|
musclemap_mlx/config.py
ADDED
|
@@ -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
|