spine-segment 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.
- spine_segment/__init__.py +16 -0
- spine_segment/api.py +214 -0
- spine_segment/backend.py +92 -0
- spine_segment/backend_template.py +34 -0
- spine_segment/checkpoint_mapping.py +132 -0
- spine_segment/cli.py +165 -0
- spine_segment/compartments.py +195 -0
- spine_segment/device.py +38 -0
- spine_segment/graph_resources.py +15 -0
- spine_segment/io.py +46 -0
- spine_segment/labels.py +5 -0
- spine_segment/landmarks.py +31 -0
- spine_segment/model_bundle.py +198 -0
- spine_segment/native_torch_backend.py +859 -0
- spine_segment/outputs.py +33 -0
- spine_segment/postprocess.py +132 -0
- spine_segment/preprocess.py +75 -0
- spine_segment/process_body_relabel.py +342 -0
- spine_segment/pytorch_models.py +380 -0
- spine_segment/resources/__init__.py +1 -0
- spine_segment/resources/possible_successors.pickle +0 -0
- spine_segment/resources/units_distances.pickle +0 -0
- spine_segment/sequence_solver.py +148 -0
- spine_segment/tiling.py +45 -0
- spine_segment-0.1.0.dist-info/METADATA +298 -0
- spine_segment-0.1.0.dist-info/RECORD +29 -0
- spine_segment-0.1.0.dist-info/WHEEL +5 -0
- spine_segment-0.1.0.dist-info/entry_points.txt +2 -0
- spine_segment-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from spine_segment.api import segment_file, segment_files
|
|
2
|
+
from spine_segment.backend import (
|
|
3
|
+
SegmentationResult,
|
|
4
|
+
SpineSegmentBackend,
|
|
5
|
+
SpineSegmentBackendError,
|
|
6
|
+
load_backend,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SegmentationResult",
|
|
11
|
+
"SpineSegmentBackend",
|
|
12
|
+
"SpineSegmentBackendError",
|
|
13
|
+
"load_backend",
|
|
14
|
+
"segment_file",
|
|
15
|
+
"segment_files",
|
|
16
|
+
]
|
spine_segment/api.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import SimpleITK as sitk
|
|
10
|
+
|
|
11
|
+
from spine_segment.backend import SpineSegmentBackend, SpineSegmentBackendError
|
|
12
|
+
from spine_segment.compartments import CortTrabConfig, derive_cort_trab_labels
|
|
13
|
+
from spine_segment.device import resolve_device
|
|
14
|
+
from spine_segment.io import read_image, write_image
|
|
15
|
+
from spine_segment.outputs import OutputPaths, build_output_paths
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class SegmentRunResult:
|
|
20
|
+
input_path: Path
|
|
21
|
+
output_paths: OutputPaths
|
|
22
|
+
device: str
|
|
23
|
+
metadata: dict[str, Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def segment_file(
|
|
27
|
+
input_path: str | Path,
|
|
28
|
+
output_dir: str | Path,
|
|
29
|
+
*,
|
|
30
|
+
backend: SpineSegmentBackend,
|
|
31
|
+
device: str = "auto",
|
|
32
|
+
overwrite: bool = False,
|
|
33
|
+
cort_trab_config: CortTrabConfig | None = None,
|
|
34
|
+
level_only: bool = False,
|
|
35
|
+
localization_only: bool = False,
|
|
36
|
+
) -> SegmentRunResult:
|
|
37
|
+
source_path = Path(input_path)
|
|
38
|
+
image = read_image(source_path, pixel_type=sitk.sitkFloat32)
|
|
39
|
+
selected_device = resolve_device(device)
|
|
40
|
+
output_paths = build_output_paths(source_path, output_dir)
|
|
41
|
+
if localization_only:
|
|
42
|
+
localization = backend.localize(
|
|
43
|
+
image=image,
|
|
44
|
+
source_path=source_path,
|
|
45
|
+
device=selected_device,
|
|
46
|
+
)
|
|
47
|
+
centroids = localization.centroids
|
|
48
|
+
metadata = dict(localization.metadata or {})
|
|
49
|
+
metadata["device"] = selected_device
|
|
50
|
+
metadata["localization_only"] = True
|
|
51
|
+
write_json(
|
|
52
|
+
{
|
|
53
|
+
"input": str(source_path),
|
|
54
|
+
"coordinate_system": "voxel_xyz",
|
|
55
|
+
"centroids": centroids,
|
|
56
|
+
"metadata": metadata,
|
|
57
|
+
},
|
|
58
|
+
output_paths.centroids,
|
|
59
|
+
overwrite=overwrite,
|
|
60
|
+
)
|
|
61
|
+
return SegmentRunResult(
|
|
62
|
+
input_path=source_path,
|
|
63
|
+
output_paths=output_paths,
|
|
64
|
+
device=selected_device,
|
|
65
|
+
metadata=metadata,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
result = backend.segment(
|
|
69
|
+
image=image,
|
|
70
|
+
source_path=source_path,
|
|
71
|
+
device=selected_device,
|
|
72
|
+
level_only=level_only,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
write_image(result.vertebral_level, output_paths.vertebral_level, overwrite=overwrite)
|
|
76
|
+
centroids = centroids_from_labelmap(result.vertebral_level)
|
|
77
|
+
if level_only:
|
|
78
|
+
metadata = dict(result.metadata or {})
|
|
79
|
+
metadata["device"] = selected_device
|
|
80
|
+
metadata["level_only"] = True
|
|
81
|
+
write_centroids_json(
|
|
82
|
+
input_path=source_path,
|
|
83
|
+
centroids=centroids,
|
|
84
|
+
metadata=metadata,
|
|
85
|
+
path=output_paths.centroids,
|
|
86
|
+
overwrite=overwrite,
|
|
87
|
+
)
|
|
88
|
+
return SegmentRunResult(
|
|
89
|
+
input_path=source_path,
|
|
90
|
+
output_paths=output_paths,
|
|
91
|
+
device=selected_device,
|
|
92
|
+
metadata=metadata,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if result.process_body is None:
|
|
96
|
+
raise SpineSegmentBackendError(
|
|
97
|
+
"Standalone spine backend did not return a process/body labelmap."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
cort_trab = result.cort_trab or derive_cort_trab_labels(
|
|
101
|
+
image=image,
|
|
102
|
+
process_body=result.process_body,
|
|
103
|
+
vertebral_level=result.vertebral_level,
|
|
104
|
+
config=cort_trab_config,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
write_image(result.process_body, output_paths.process_body, overwrite=overwrite)
|
|
108
|
+
write_image(cort_trab, output_paths.cort_trab, overwrite=overwrite)
|
|
109
|
+
|
|
110
|
+
metadata = dict(result.metadata or {})
|
|
111
|
+
metadata["device"] = selected_device
|
|
112
|
+
write_centroids_json(
|
|
113
|
+
input_path=source_path,
|
|
114
|
+
centroids=centroids,
|
|
115
|
+
metadata=metadata,
|
|
116
|
+
path=output_paths.centroids,
|
|
117
|
+
overwrite=overwrite,
|
|
118
|
+
)
|
|
119
|
+
return SegmentRunResult(
|
|
120
|
+
input_path=source_path,
|
|
121
|
+
output_paths=output_paths,
|
|
122
|
+
device=selected_device,
|
|
123
|
+
metadata=metadata,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def segment_files(
|
|
128
|
+
input_paths: list[str | Path],
|
|
129
|
+
output_dir: str | Path,
|
|
130
|
+
*,
|
|
131
|
+
backend: SpineSegmentBackend,
|
|
132
|
+
device: str = "auto",
|
|
133
|
+
overwrite: bool = False,
|
|
134
|
+
cort_trab_config: CortTrabConfig | None = None,
|
|
135
|
+
level_only: bool = False,
|
|
136
|
+
localization_only: bool = False,
|
|
137
|
+
) -> list[SegmentRunResult]:
|
|
138
|
+
return [
|
|
139
|
+
segment_file(
|
|
140
|
+
path,
|
|
141
|
+
output_dir,
|
|
142
|
+
backend=backend,
|
|
143
|
+
device=device,
|
|
144
|
+
overwrite=overwrite,
|
|
145
|
+
cort_trab_config=cort_trab_config,
|
|
146
|
+
level_only=level_only,
|
|
147
|
+
localization_only=localization_only,
|
|
148
|
+
)
|
|
149
|
+
for path in input_paths
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_json(payload: dict[str, Any], path: str | Path, *, overwrite: bool = False) -> Path:
|
|
154
|
+
output_path = Path(path)
|
|
155
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
if output_path.exists() and not overwrite:
|
|
157
|
+
raise FileExistsError(
|
|
158
|
+
f"Refusing to overwrite existing output without --overwrite: {output_path}"
|
|
159
|
+
)
|
|
160
|
+
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
161
|
+
return output_path
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def write_centroids_json(
|
|
165
|
+
*,
|
|
166
|
+
input_path: Path,
|
|
167
|
+
centroids: dict[str, dict[str, Any]],
|
|
168
|
+
metadata: dict[str, Any],
|
|
169
|
+
path: str | Path,
|
|
170
|
+
overwrite: bool,
|
|
171
|
+
) -> Path:
|
|
172
|
+
return write_json(
|
|
173
|
+
{
|
|
174
|
+
"input": str(input_path),
|
|
175
|
+
"coordinate_system": "voxel_xyz",
|
|
176
|
+
"centroids": centroids,
|
|
177
|
+
"metadata": metadata,
|
|
178
|
+
},
|
|
179
|
+
path,
|
|
180
|
+
overwrite=overwrite,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def centroids_from_labelmap(labelmap: sitk.Image) -> dict[str, dict[str, Any]]:
|
|
185
|
+
labels = sitk.GetArrayFromImage(labelmap)
|
|
186
|
+
centroids: dict[str, dict[str, Any]] = {}
|
|
187
|
+
for raw_label in sorted(int(value) for value in np.unique(labels) if int(value) != 0):
|
|
188
|
+
coords_zyx = np.argwhere(labels == raw_label)
|
|
189
|
+
if coords_zyx.size == 0:
|
|
190
|
+
continue
|
|
191
|
+
centroid_zyx = coords_zyx.mean(axis=0)
|
|
192
|
+
voxel_xyz = (
|
|
193
|
+
float(centroid_zyx[2]),
|
|
194
|
+
float(centroid_zyx[1]),
|
|
195
|
+
float(centroid_zyx[0]),
|
|
196
|
+
)
|
|
197
|
+
physical_xyz = labelmap.TransformContinuousIndexToPhysicalPoint(voxel_xyz)
|
|
198
|
+
centroids[str(raw_label)] = {
|
|
199
|
+
"label": raw_label,
|
|
200
|
+
"index": _verse_index_from_label(raw_label),
|
|
201
|
+
"voxel_xyz": [float(value) for value in voxel_xyz],
|
|
202
|
+
"physical_xyz": [float(value) for value in physical_xyz],
|
|
203
|
+
"voxel_count": int(coords_zyx.shape[0]),
|
|
204
|
+
"source": "segmentation",
|
|
205
|
+
}
|
|
206
|
+
return centroids
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _verse_index_from_label(label: int) -> int | None:
|
|
210
|
+
if 1 <= int(label) <= 25:
|
|
211
|
+
return int(label) - 1
|
|
212
|
+
if int(label) == 28:
|
|
213
|
+
return 25
|
|
214
|
+
return None
|
spine_segment/backend.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
import SimpleITK as sitk
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpineSegmentBackendError(RuntimeError):
|
|
12
|
+
"""Raised when the standalone spine backend cannot run."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class SegmentationResult:
|
|
17
|
+
vertebral_level: sitk.Image
|
|
18
|
+
process_body: sitk.Image | None = None
|
|
19
|
+
cort_trab: sitk.Image | None = None
|
|
20
|
+
metadata: dict[str, Any] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class LocalizationResult:
|
|
25
|
+
centroids: dict[str, dict[str, Any]]
|
|
26
|
+
metadata: dict[str, Any] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class SpineSegmentBackend(Protocol):
|
|
31
|
+
def segment(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
image: sitk.Image,
|
|
35
|
+
source_path: Path,
|
|
36
|
+
device: str,
|
|
37
|
+
level_only: bool = False,
|
|
38
|
+
) -> SegmentationResult: ...
|
|
39
|
+
|
|
40
|
+
def localize(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
image: sitk.Image,
|
|
44
|
+
source_path: Path,
|
|
45
|
+
device: str,
|
|
46
|
+
) -> LocalizationResult: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnavailableBackend:
|
|
50
|
+
def segment(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
image: sitk.Image,
|
|
54
|
+
source_path: Path,
|
|
55
|
+
device: str,
|
|
56
|
+
level_only: bool = False,
|
|
57
|
+
) -> SegmentationResult:
|
|
58
|
+
raise SpineSegmentBackendError(
|
|
59
|
+
"No standalone spine backend is wired yet. "
|
|
60
|
+
"Provide --backend module_path:factory or set SPINE_SEGMENT_BACKEND "
|
|
61
|
+
"to the PyTorch runtime entrypoint for the vertebral-level and "
|
|
62
|
+
"process/body models."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def localize(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
image: sitk.Image,
|
|
69
|
+
source_path: Path,
|
|
70
|
+
device: str,
|
|
71
|
+
) -> LocalizationResult:
|
|
72
|
+
raise SpineSegmentBackendError(
|
|
73
|
+
"No standalone spine backend is wired yet. "
|
|
74
|
+
"Provide --backend module_path:factory or set SPINE_SEGMENT_BACKEND."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_backend(spec: str | None) -> SpineSegmentBackend:
|
|
79
|
+
if spec is None or not str(spec).strip():
|
|
80
|
+
return UnavailableBackend()
|
|
81
|
+
|
|
82
|
+
module_name, separator, attr_name = str(spec).partition(":")
|
|
83
|
+
module = import_module(module_name)
|
|
84
|
+
target_name = attr_name if separator else "create_backend"
|
|
85
|
+
target = getattr(module, target_name)
|
|
86
|
+
instance = target() if callable(target) else target
|
|
87
|
+
|
|
88
|
+
if not isinstance(instance, SpineSegmentBackend) and not hasattr(instance, "segment"):
|
|
89
|
+
raise SpineSegmentBackendError(
|
|
90
|
+
f"Backend '{spec}' does not expose a segment(...) method."
|
|
91
|
+
)
|
|
92
|
+
return instance
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import SimpleITK as sitk
|
|
6
|
+
|
|
7
|
+
from spine_segment.backend import SegmentationResult, SpineSegmentBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExampleBackend(SpineSegmentBackend):
|
|
11
|
+
"""
|
|
12
|
+
Replace this class with the real PyTorch runtime.
|
|
13
|
+
|
|
14
|
+
The backend must return:
|
|
15
|
+
- vertebral_level: integer vertebral labelmap in scan space
|
|
16
|
+
- process_body: integer process/body labelmap in scan space
|
|
17
|
+
- cort_trab: optional integer cort/trab labelmap in scan space
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def segment(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
image: sitk.Image,
|
|
24
|
+
source_path: Path,
|
|
25
|
+
device: str,
|
|
26
|
+
) -> SegmentationResult:
|
|
27
|
+
raise NotImplementedError(
|
|
28
|
+
"Replace spine_segment.backend_template.ExampleBackend with the "
|
|
29
|
+
"PyTorch vertebral segmentation runtime."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_backend() -> ExampleBackend:
|
|
34
|
+
return ExampleBackend()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class MappingRule:
|
|
9
|
+
pattern: re.Pattern[str]
|
|
10
|
+
replacement: str
|
|
11
|
+
tensor_kind: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ConversionCoverage:
|
|
16
|
+
converted_count: int
|
|
17
|
+
ignored_source_count: int
|
|
18
|
+
missing_target_keys: tuple[str, ...]
|
|
19
|
+
unmapped_source_names: tuple[str, ...]
|
|
20
|
+
shape_mismatches: tuple[dict[str, object], ...]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_VARIABLE_VALUE_SUFFIX = "/.ATTRIBUTES/VARIABLE_VALUE"
|
|
24
|
+
_IGNORED_EXACT_NAMES = {
|
|
25
|
+
"_CHECKPOINTABLE_OBJECT_GRAPH",
|
|
26
|
+
"save_counter",
|
|
27
|
+
"sigmas",
|
|
28
|
+
}
|
|
29
|
+
_IGNORED_PREFIXES = (
|
|
30
|
+
"optimizer/",
|
|
31
|
+
"save_counter/",
|
|
32
|
+
)
|
|
33
|
+
_IGNORED_SUBSTRINGS = (
|
|
34
|
+
"/.OPTIMIZER_SLOT/",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_UNET_BLOCK_RULE = re.compile(
|
|
38
|
+
r"^model/(?P<prefix>unet|scnet_local|scnet_spatial)/"
|
|
39
|
+
r"(?P<block>contracting_layers|expanding_layers)/level(?P<level>\d+)/"
|
|
40
|
+
r"layers/(?P<layer_index>\d+)/(?P<var>kernel|bias)$"
|
|
41
|
+
)
|
|
42
|
+
_PREDICTION_RULE = re.compile(
|
|
43
|
+
r"^model/prediction/layers/0/(?P<var>kernel|bias)$"
|
|
44
|
+
)
|
|
45
|
+
_LOCAL_HEATMAP_RULE = re.compile(
|
|
46
|
+
r"^model/local_heatmaps/layers/0/(?P<var>kernel|bias)$"
|
|
47
|
+
)
|
|
48
|
+
_SPATIAL_HEATMAP_RULE = re.compile(
|
|
49
|
+
r"^model/spatial_heatmaps/(?P<var>kernel|bias)$"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_tf_variable_name(variable_name: str) -> str:
|
|
54
|
+
normalized = str(variable_name).strip()
|
|
55
|
+
if normalized.endswith(_VARIABLE_VALUE_SUFFIX):
|
|
56
|
+
normalized = normalized[: -len(_VARIABLE_VALUE_SUFFIX)]
|
|
57
|
+
return normalized
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def should_ignore_tf_variable(variable_name: str) -> bool:
|
|
61
|
+
normalized = normalize_tf_variable_name(variable_name)
|
|
62
|
+
if normalized in _IGNORED_EXACT_NAMES:
|
|
63
|
+
return True
|
|
64
|
+
if any(token in normalized for token in _IGNORED_SUBSTRINGS):
|
|
65
|
+
return True
|
|
66
|
+
return normalized.startswith(_IGNORED_PREFIXES)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def map_tf_variable_to_torch_key(variable_name: str) -> str | None:
|
|
70
|
+
if should_ignore_tf_variable(variable_name):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
normalized = normalize_tf_variable_name(variable_name)
|
|
74
|
+
match = _UNET_BLOCK_RULE.match(normalized)
|
|
75
|
+
if match:
|
|
76
|
+
prefix = match.group("prefix")
|
|
77
|
+
block = match.group("block")
|
|
78
|
+
level = int(match.group("level"))
|
|
79
|
+
layer_index = int(match.group("layer_index"))
|
|
80
|
+
variable = "weight" if match.group("var") == "kernel" else "bias"
|
|
81
|
+
conv_index = layer_index // 2
|
|
82
|
+
pytorch_conv_index = conv_index * 2
|
|
83
|
+
|
|
84
|
+
if prefix == "unet":
|
|
85
|
+
base = "unet"
|
|
86
|
+
elif prefix == "scnet_local":
|
|
87
|
+
base = "scnet_local"
|
|
88
|
+
elif prefix == "scnet_spatial":
|
|
89
|
+
base = "scnet_spatial"
|
|
90
|
+
else:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
block_name = "contracting" if block == "contracting_layers" else "expanding"
|
|
94
|
+
return f"{base}.{block_name}.{level}.{pytorch_conv_index}.{variable}"
|
|
95
|
+
|
|
96
|
+
match = _PREDICTION_RULE.match(normalized)
|
|
97
|
+
if match:
|
|
98
|
+
variable = "weight" if match.group("var") == "kernel" else "bias"
|
|
99
|
+
return f"prediction.{variable}"
|
|
100
|
+
|
|
101
|
+
match = _LOCAL_HEATMAP_RULE.match(normalized)
|
|
102
|
+
if match:
|
|
103
|
+
variable = "weight" if match.group("var") == "kernel" else "bias"
|
|
104
|
+
return f"local_heatmaps.0.{variable}"
|
|
105
|
+
|
|
106
|
+
match = _SPATIAL_HEATMAP_RULE.match(normalized)
|
|
107
|
+
if match:
|
|
108
|
+
variable = "weight" if match.group("var") == "kernel" else "bias"
|
|
109
|
+
return f"spatial_heatmaps.{variable}"
|
|
110
|
+
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def needs_conv_kernel_transpose(variable_name: str) -> bool:
|
|
115
|
+
return normalize_tf_variable_name(variable_name).endswith("/kernel")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_conversion_coverage(
|
|
119
|
+
*,
|
|
120
|
+
converted_keys: set[str],
|
|
121
|
+
target_keys: set[str],
|
|
122
|
+
ignored_source_names: list[str],
|
|
123
|
+
unmapped_source_names: list[str],
|
|
124
|
+
shape_mismatches: list[dict[str, object]],
|
|
125
|
+
) -> ConversionCoverage:
|
|
126
|
+
return ConversionCoverage(
|
|
127
|
+
converted_count=len(converted_keys),
|
|
128
|
+
ignored_source_count=len(ignored_source_names),
|
|
129
|
+
missing_target_keys=tuple(sorted(target_keys - converted_keys)),
|
|
130
|
+
unmapped_source_names=tuple(sorted(unmapped_source_names)),
|
|
131
|
+
shape_mismatches=tuple(shape_mismatches),
|
|
132
|
+
)
|
spine_segment/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from spine_segment.api import segment_files
|
|
8
|
+
from spine_segment.backend import SpineSegmentBackendError, load_backend
|
|
9
|
+
from spine_segment.compartments import CortTrabConfig
|
|
10
|
+
from spine_segment.io import expand_input_paths
|
|
11
|
+
from spine_segment.native_torch_backend import create_backend as create_native_backend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="spine-segment",
|
|
17
|
+
description="Standalone vertebral segmentation CLI.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("inputs", nargs="+", help="Input CT volumes (.nii or .nii.gz).")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--output",
|
|
22
|
+
required=True,
|
|
23
|
+
help="Directory that will receive outputs.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--device",
|
|
27
|
+
default="auto",
|
|
28
|
+
choices=("auto", "cuda", "mps", "cpu"),
|
|
29
|
+
help="Runtime device preference.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--backend",
|
|
33
|
+
default=os.environ.get("SPINE_SEGMENT_BACKEND", ""),
|
|
34
|
+
help="Backend factory in the form module_path:factory. "
|
|
35
|
+
"Defaults to the native PyTorch backend.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--model-bundle",
|
|
39
|
+
default=os.environ.get("SPINE_SEGMENT_MODEL_BUNDLE", ""),
|
|
40
|
+
help="Path to a spine-segment model bundle. Defaults to "
|
|
41
|
+
"SPINE_SEGMENT_MODEL_BUNDLE, ./model-bundle, ./build/model-bundle-pytorch, "
|
|
42
|
+
"./build/model-bundle, or an automatically downloaded cached bundle.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--no-model-download",
|
|
46
|
+
action="store_true",
|
|
47
|
+
default=os.environ.get("SPINE_SEGMENT_NO_DOWNLOAD", "").strip().lower()
|
|
48
|
+
not in ("", "0", "false", "no", "off"),
|
|
49
|
+
help="Disable automatic first-run model bundle download.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--overwrite",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Overwrite existing outputs.",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--level-only",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="Write *_vertebral-level.nii.gz and *_centroids.json; skip process/body plus cort/trab stages.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--localization-only",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Write only *_centroids.json with detected vertebral centroids in original scan voxel coordinates.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--vertebra-batch-size",
|
|
68
|
+
type=int,
|
|
69
|
+
default=1,
|
|
70
|
+
help="Number of MDAT vertebra segmentation patches to run per PyTorch batch.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--process-body-batch-size",
|
|
74
|
+
type=int,
|
|
75
|
+
default=4,
|
|
76
|
+
help="Number of process/body relabel crops to run per PyTorch batch.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--cortical-threshold-hu",
|
|
80
|
+
type=float,
|
|
81
|
+
default=500.0,
|
|
82
|
+
help="HU threshold used for connected cortical assignment within the outer shell.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--cort-trab-isotropic-spacing-mm",
|
|
86
|
+
type=float,
|
|
87
|
+
default=1.0,
|
|
88
|
+
help="Isotropic spacing used internally for cort/trab shell generation.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--cortical-max-thickness-mm",
|
|
92
|
+
type=float,
|
|
93
|
+
default=6.0,
|
|
94
|
+
help="Maximum distance from the vertebral surface considered for threshold-based cortical assignment.",
|
|
95
|
+
)
|
|
96
|
+
return parser
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: list[str] | None = None) -> int:
|
|
100
|
+
parser = build_parser()
|
|
101
|
+
args = parser.parse_args(argv)
|
|
102
|
+
inputs = expand_input_paths(list(args.inputs))
|
|
103
|
+
if not inputs:
|
|
104
|
+
parser.error("No input images were resolved.")
|
|
105
|
+
|
|
106
|
+
missing = [path for path in inputs if not path.exists()]
|
|
107
|
+
if missing:
|
|
108
|
+
parser.error(f"Input does not exist: {missing[0]}")
|
|
109
|
+
|
|
110
|
+
if args.backend:
|
|
111
|
+
backend = load_backend(args.backend)
|
|
112
|
+
else:
|
|
113
|
+
backend = create_native_backend(
|
|
114
|
+
args.model_bundle or "./build/model-bundle",
|
|
115
|
+
allow_download=not bool(args.no_model_download),
|
|
116
|
+
)
|
|
117
|
+
if hasattr(backend, "vertebra_segmentation_batch_size"):
|
|
118
|
+
backend.vertebra_segmentation_batch_size = max(1, int(args.vertebra_batch_size))
|
|
119
|
+
if hasattr(backend, "process_body_batch_size"):
|
|
120
|
+
backend.process_body_batch_size = max(1, int(args.process_body_batch_size))
|
|
121
|
+
cort_trab_config = CortTrabConfig(
|
|
122
|
+
cortical_threshold_hu=float(args.cortical_threshold_hu),
|
|
123
|
+
isotropic_spacing_mm=float(args.cort_trab_isotropic_spacing_mm),
|
|
124
|
+
cortical_max_thickness_mm=float(args.cortical_max_thickness_mm),
|
|
125
|
+
)
|
|
126
|
+
if args.localization_only and args.level_only:
|
|
127
|
+
parser.error("--localization-only and --level-only are mutually exclusive.")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
results = segment_files(
|
|
131
|
+
input_paths=inputs,
|
|
132
|
+
output_dir=Path(args.output),
|
|
133
|
+
backend=backend,
|
|
134
|
+
device=args.device,
|
|
135
|
+
overwrite=bool(args.overwrite),
|
|
136
|
+
cort_trab_config=cort_trab_config,
|
|
137
|
+
level_only=bool(args.level_only),
|
|
138
|
+
localization_only=bool(args.localization_only),
|
|
139
|
+
)
|
|
140
|
+
except SpineSegmentBackendError as exc:
|
|
141
|
+
print(f"[spine-segment] backend error: {exc}")
|
|
142
|
+
return 2
|
|
143
|
+
except FileExistsError as exc:
|
|
144
|
+
print(f"[spine-segment] output exists: {exc}")
|
|
145
|
+
return 2
|
|
146
|
+
|
|
147
|
+
for result in results:
|
|
148
|
+
fields = [
|
|
149
|
+
f"input={result.input_path}",
|
|
150
|
+
f"device={result.device}",
|
|
151
|
+
]
|
|
152
|
+
if args.localization_only:
|
|
153
|
+
fields.append(f"centroids={result.output_paths.centroids}")
|
|
154
|
+
else:
|
|
155
|
+
fields.append(f"vertebral_level={result.output_paths.vertebral_level}")
|
|
156
|
+
fields.append(f"centroids={result.output_paths.centroids}")
|
|
157
|
+
if not args.level_only:
|
|
158
|
+
fields.append(f"process_body={result.output_paths.process_body}")
|
|
159
|
+
fields.append(f"cort_trab={result.output_paths.cort_trab}")
|
|
160
|
+
print("[spine-segment] " + " ".join(fields))
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
raise SystemExit(main())
|