Move engine code to impact_reg_konfai.models; keep only config + weights
Browse filesEach preset's Prediction.yml now points its classpath at
impact_reg_konfai.models.<engine>:RegistrationNet instead of a bundled Model.py,
and declares impact_reg_konfai>=1.6.1. The duplicated engine code (Model.py, plus
elastix_engine.py/install.py) is removed; the elastix parameter-map .txt stay, as
they are per-preset config. Requires impact_reg_konfai>=1.6.1 (ships the models
subpackage).
This view is limited to 50 files because it contains too many changes. See raw diff
- CBCT_CT_HeadNeck/Model.py +0 -301
- CBCT_CT_HeadNeck/Prediction.yml +1 -1
- CBCT_CT_HeadNeck/elastix_engine.py +0 -386
- CBCT_CT_HeadNeck/install.py +0 -325
- CBCT_CT_HeadNeck/requirements.txt +1 -0
- CBCT_CT_MRSeg/Model.py +0 -301
- CBCT_CT_MRSeg/Prediction.yml +1 -1
- CBCT_CT_MRSeg/elastix_engine.py +0 -386
- CBCT_CT_MRSeg/install.py +0 -325
- CBCT_CT_MRSeg/requirements.txt +1 -0
- ConvexAdam_Coarse/Model.py +0 -549
- ConvexAdam_Coarse/Prediction.yml +1 -1
- ConvexAdam_Coarse/requirements.txt +1 -0
- ConvexAdam_Composite/Model.py +0 -549
- ConvexAdam_Composite/Prediction.yml +1 -1
- ConvexAdam_Composite/requirements.txt +1 -0
- ConvexAdam_Fine/Model.py +0 -549
- ConvexAdam_Fine/Prediction.yml +1 -1
- ConvexAdam_Fine/requirements.txt +1 -0
- FireANTs_Affine/Model.py +0 -669
- FireANTs_Affine/Prediction.yml +1 -1
- FireANTs_Affine/requirements.txt +1 -0
- FireANTs_IMPACT/Model.py +0 -669
- FireANTs_IMPACT/Prediction.yml +1 -1
- FireANTs_IMPACT/requirements.txt +1 -0
- FireANTs_SyN/Model.py +0 -669
- FireANTs_SyN/Prediction.yml +1 -1
- FireANTs_SyN/requirements.txt +1 -0
- Generic_Rigid/Model.py +0 -301
- Generic_Rigid/Prediction.yml +1 -1
- Generic_Rigid/elastix_engine.py +0 -386
- Generic_Rigid/install.py +0 -325
- Generic_Rigid/requirements.txt +1 -0
- Generic_Rigid_BSpline/Model.py +0 -301
- Generic_Rigid_BSpline/Prediction.yml +1 -1
- Generic_Rigid_BSpline/elastix_engine.py +0 -386
- Generic_Rigid_BSpline/install.py +0 -325
- Generic_Rigid_BSpline/requirements.txt +1 -0
- MR_CT_HeadNeck/Model.py +0 -301
- MR_CT_HeadNeck/Prediction.yml +1 -1
- MR_CT_HeadNeck/elastix_engine.py +0 -386
- MR_CT_HeadNeck/install.py +0 -325
- MR_CT_HeadNeck/requirements.txt +1 -0
- MR_CT_MRSeg/Model.py +0 -301
- MR_CT_MRSeg/Prediction.yml +1 -1
- MR_CT_MRSeg/elastix_engine.py +0 -386
- MR_CT_MRSeg/install.py +0 -325
- MR_CT_MRSeg/requirements.txt +1 -0
- MR_CT_TS/Model.py +0 -301
- MR_CT_TS/Prediction.yml +1 -1
CBCT_CT_HeadNeck/Model.py
DELETED
|
@@ -1,301 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
|
| 18 |
-
|
| 19 |
-
``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
|
| 20 |
-
2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
|
| 21 |
-
the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
|
| 22 |
-
the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
|
| 23 |
-
subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
|
| 24 |
-
|
| 25 |
-
A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
|
| 26 |
-
``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
|
| 27 |
-
|
| 28 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
|
| 29 |
-
(``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import os
|
| 34 |
-
import re
|
| 35 |
-
from dataclasses import dataclass, field
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import Annotated, Literal
|
| 38 |
-
|
| 39 |
-
import torch
|
| 40 |
-
from huggingface_hub import hf_hub_download
|
| 41 |
-
from konfai.network import network
|
| 42 |
-
from konfai.utils.config import Choices, Range
|
| 43 |
-
|
| 44 |
-
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 45 |
-
# A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
|
| 46 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
|
| 47 |
-
# iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
|
| 48 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 49 |
-
|
| 50 |
-
# ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
|
| 51 |
-
# Static anyway; in Jacobian we clamp ``l`` to this plateau.
|
| 52 |
-
_FOV_RAMP_MAX_LAYER = 6
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry_choices() -> list[str]:
|
| 56 |
-
"""The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
|
| 57 |
-
(offline-first). A user may still point ``ref`` at a local model."""
|
| 58 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 59 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _num(x: object) -> str:
|
| 63 |
-
"""Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
|
| 64 |
-
return "%g" % float(x)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
@dataclass
|
| 68 |
-
class ModelSpec:
|
| 69 |
-
"""One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
|
| 70 |
-
rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
|
| 71 |
-
(``models.json``) keyed by ``ref`` — never tuned."""
|
| 72 |
-
|
| 73 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 74 |
-
voxel_size: list[float] = field(default_factory=list)
|
| 75 |
-
layers_weight: list[float] = field(default_factory=lambda: [1.0])
|
| 76 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0
|
| 77 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 78 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 79 |
-
layers_mask: str = ""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
@dataclass
|
| 83 |
-
class ResolutionSpec:
|
| 84 |
-
"""One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
|
| 85 |
-
|
| 86 |
-
max_iterations: Annotated[int, Range(1, 100000)]
|
| 87 |
-
models: dict[str, ModelSpec]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 91 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order."""
|
| 92 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 96 |
-
"""Load models.json (the fixed params per model) from the model repo on Hugging Face.
|
| 97 |
-
|
| 98 |
-
The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
|
| 99 |
-
dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
|
| 100 |
-
"""
|
| 101 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 102 |
-
if local:
|
| 103 |
-
path = Path(local)
|
| 104 |
-
elif ":" in ref:
|
| 105 |
-
repo, filename = ref.split(":", 1)
|
| 106 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(
|
| 109 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
|
| 110 |
-
f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 111 |
-
)
|
| 112 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _model_key(ref: str) -> str:
|
| 116 |
-
"""Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
|
| 117 |
-
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _deepest_active_layer(layers_mask: str) -> int:
|
| 121 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
|
| 122 |
-
|
| 123 |
-
A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
|
| 124 |
-
== ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
|
| 125 |
-
receptive field, so the FOV is governed by the rightmost ``'1'``.
|
| 126 |
-
"""
|
| 127 |
-
mask = layers_mask.strip().strip('"')
|
| 128 |
-
active = [i for i, char in enumerate(mask) if char == "1"]
|
| 129 |
-
if not active:
|
| 130 |
-
raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
|
| 131 |
-
return max(active)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 135 |
-
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 136 |
-
|
| 137 |
-
Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
|
| 138 |
-
``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
|
| 139 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
|
| 140 |
-
to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
|
| 141 |
-
a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
|
| 142 |
-
``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
|
| 143 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut.
|
| 144 |
-
"""
|
| 145 |
-
formula = str(fov.get("formula", "")).strip()
|
| 146 |
-
key = re.sub(r"\s+", "", formula).lower()
|
| 147 |
-
if key.isdigit():
|
| 148 |
-
return int(key)
|
| 149 |
-
if key == "2*r*d+1":
|
| 150 |
-
return 2 * int(fov["r"]) * int(fov["d"]) + 1
|
| 151 |
-
if key == "2^l+3":
|
| 152 |
-
return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
|
| 153 |
-
if "global" in key:
|
| 154 |
-
raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
|
| 155 |
-
if fov.get("value") is not None:
|
| 156 |
-
return int(fov["value"])
|
| 157 |
-
raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 161 |
-
"""PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
|
| 162 |
-
image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
|
| 163 |
-
e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
|
| 164 |
-
dim = int(entry.get("dimension", 3))
|
| 165 |
-
if mode.strip().strip('"').lower() != "jacobian":
|
| 166 |
-
return " ".join(["0"] * dim)
|
| 167 |
-
fov = _fov_value(entry.get("fov", {}), layers_mask)
|
| 168 |
-
return " ".join([str(fov)] * dim)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
|
| 172 |
-
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 173 |
-
|
| 174 |
-
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 175 |
-
ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
|
| 176 |
-
is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
|
| 177 |
-
from the registry formula and the cell's ``layers_mask``.
|
| 178 |
-
"""
|
| 179 |
-
res = _sorted_specs(resolutions)
|
| 180 |
-
n = len(res)
|
| 181 |
-
mode_clean = mode.strip().strip('"') or "Static"
|
| 182 |
-
|
| 183 |
-
impact: list[str] = []
|
| 184 |
-
for k, r in enumerate(res):
|
| 185 |
-
models = _sorted_specs(r.models)
|
| 186 |
-
entries = [registry[_model_key(m.ref)] for m in models]
|
| 187 |
-
|
| 188 |
-
def row(stem: str, values: list[str]) -> None:
|
| 189 |
-
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 190 |
-
|
| 191 |
-
# From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
|
| 192 |
-
# model FOV); everything else is a per-model knob taken straight from the cell.
|
| 193 |
-
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 194 |
-
row("Dimension", [e["dimension"] for e in entries])
|
| 195 |
-
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
| 196 |
-
row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
|
| 197 |
-
row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
|
| 198 |
-
row("LayersMask", [f'"{m.layers_mask}"' for m in models])
|
| 199 |
-
row("SubsetFeatures", [str(m.subset_features) for m in models])
|
| 200 |
-
row("PCA", [str(m.pca) for m in models])
|
| 201 |
-
row("Distance", [f'"{m.distance}"' for m in models])
|
| 202 |
-
row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
|
| 203 |
-
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 204 |
-
|
| 205 |
-
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 206 |
-
# (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
|
| 207 |
-
lines = template_text.splitlines()
|
| 208 |
-
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 209 |
-
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
| 210 |
-
block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
|
| 211 |
-
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for i, (m, line) in enumerate(indexed):
|
| 214 |
-
key = m.group(1) if m else None
|
| 215 |
-
if block_lo <= i <= block_hi:
|
| 216 |
-
if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
|
| 217 |
-
out.extend(impact[:-1])
|
| 218 |
-
elif key == "MaximumNumberOfIterations":
|
| 219 |
-
out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
|
| 220 |
-
elif key == "NumberOfResolutions":
|
| 221 |
-
out.append(f"(NumberOfResolutions {n})")
|
| 222 |
-
elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
|
| 223 |
-
out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
|
| 224 |
-
elif key == "ImpactMode":
|
| 225 |
-
out.append(f'(ImpactMode "{mode_clean}")')
|
| 226 |
-
else:
|
| 227 |
-
out.append(line)
|
| 228 |
-
return "\n".join(out)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class ChannelSelect(torch.nn.Module):
|
| 232 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 235 |
-
super().__init__()
|
| 236 |
-
self._start = start
|
| 237 |
-
self._stop = stop
|
| 238 |
-
|
| 239 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 240 |
-
return tensor[:, self._start : self._stop]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
class RegistrationNet(network.Network):
|
| 244 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
|
| 245 |
-
moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 246 |
-
|
| 247 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 248 |
-
(the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
|
| 249 |
-
``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
|
| 250 |
-
``same_as_group: Volume_0:Fixed``.
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
def __init__(
|
| 254 |
-
self,
|
| 255 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 256 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 257 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 258 |
-
},
|
| 259 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 260 |
-
engine: str = "elastix",
|
| 261 |
-
parameter_maps: list[str] = [],
|
| 262 |
-
max_iterations: Annotated[int, Range(0, 100000)] = 0,
|
| 263 |
-
final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
|
| 264 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0,
|
| 265 |
-
spatial_samples: Annotated[int, Range(0, 100000)] = 0,
|
| 266 |
-
parameter_overrides: list[str] = [],
|
| 267 |
-
resolutions: dict[str, ResolutionSpec] = {},
|
| 268 |
-
mode: Literal["Static", "Jacobian"] = "Static",
|
| 269 |
-
) -> None:
|
| 270 |
-
# The registration is fully described by ``resolutions`` (config = source of truth): each resolution
|
| 271 |
-
# lists its self-configured models; the download list is derived from the cells. Global knobs override
|
| 272 |
-
# the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
|
| 273 |
-
# NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
|
| 274 |
-
# preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
|
| 275 |
-
from elastix_engine import ElastixRegistration
|
| 276 |
-
|
| 277 |
-
super().__init__(
|
| 278 |
-
in_channels=1,
|
| 279 |
-
optimizer=optimizer,
|
| 280 |
-
schedulers=schedulers,
|
| 281 |
-
outputs_criterions=outputs_criterions,
|
| 282 |
-
dim=3,
|
| 283 |
-
)
|
| 284 |
-
self.add_module(
|
| 285 |
-
"Registration",
|
| 286 |
-
ElastixRegistration(
|
| 287 |
-
engine,
|
| 288 |
-
parameter_maps,
|
| 289 |
-
max_iterations,
|
| 290 |
-
final_grid_spacing,
|
| 291 |
-
subset_features,
|
| 292 |
-
spatial_samples,
|
| 293 |
-
parameter_overrides,
|
| 294 |
-
resolutions,
|
| 295 |
-
mode,
|
| 296 |
-
),
|
| 297 |
-
in_branch=[0, 1, 2, 3],
|
| 298 |
-
out_branch=["registration"],
|
| 299 |
-
)
|
| 300 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 301 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CBCT_CT_HeadNeck/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.elastix:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
CBCT_CT_HeadNeck/elastix_engine.py
DELETED
|
@@ -1,386 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Elastix-IMPACT runtime for the registration bundle.
|
| 18 |
-
|
| 19 |
-
``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
|
| 20 |
-
parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
|
| 21 |
-
``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
|
| 22 |
-
images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
import os
|
| 26 |
-
import re
|
| 27 |
-
import shutil
|
| 28 |
-
import subprocess # nosec B404
|
| 29 |
-
import tempfile
|
| 30 |
-
from pathlib import Path
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
import SimpleITK as sitk
|
| 34 |
-
import torch
|
| 35 |
-
import tqdm
|
| 36 |
-
from huggingface_hub import hf_hub_download
|
| 37 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 38 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 39 |
-
|
| 40 |
-
from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
|
| 41 |
-
|
| 42 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 43 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 44 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 48 |
-
"""True only for a mask that actually restricts the metric region — some voxels in, some out. An
|
| 49 |
-
absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
|
| 50 |
-
is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
|
| 51 |
-
the whole image) instead of paying for a mask that restricts nothing."""
|
| 52 |
-
if mask is None:
|
| 53 |
-
return False
|
| 54 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 55 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class ElastixEngine:
|
| 59 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 60 |
-
|
| 61 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 62 |
-
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 63 |
-
"""
|
| 64 |
-
|
| 65 |
-
def __init__(
|
| 66 |
-
self,
|
| 67 |
-
parameter_maps: list[str],
|
| 68 |
-
max_iterations: int = 0,
|
| 69 |
-
final_grid_spacing: float = 0.0,
|
| 70 |
-
subset_features: int = 0,
|
| 71 |
-
spatial_samples: int = 0,
|
| 72 |
-
parameter_overrides: list[str] = [],
|
| 73 |
-
resolutions: dict = {},
|
| 74 |
-
mode: str = "Static",
|
| 75 |
-
) -> None:
|
| 76 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 77 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 78 |
-
self._max_iterations = max_iterations
|
| 79 |
-
self._final_grid_spacing = final_grid_spacing
|
| 80 |
-
self._subset_features = subset_features
|
| 81 |
-
self._spatial_samples = spatial_samples
|
| 82 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 83 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 84 |
-
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 85 |
-
self._mode = mode
|
| 86 |
-
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 87 |
-
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 88 |
-
self._resolutions = resolutions
|
| 89 |
-
self._registry = load_models_registry() if resolutions else {}
|
| 90 |
-
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 91 |
-
models: list[str] = []
|
| 92 |
-
for res in _sorted_specs(resolutions):
|
| 93 |
-
for model in _sorted_specs(res.models):
|
| 94 |
-
if model.ref not in models:
|
| 95 |
-
models.append(model.ref)
|
| 96 |
-
self._models = models
|
| 97 |
-
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 98 |
-
self._iterations = self._total_iterations()
|
| 99 |
-
self._elastix_bin = self._ensure_binary()
|
| 100 |
-
self._local_models = self._download_models()
|
| 101 |
-
|
| 102 |
-
def _total_iterations(self) -> int:
|
| 103 |
-
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 104 |
-
if self._resolutions:
|
| 105 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 106 |
-
total = 0
|
| 107 |
-
for src in self._parameter_maps:
|
| 108 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 109 |
-
if match:
|
| 110 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 111 |
-
return total
|
| 112 |
-
|
| 113 |
-
def _ensure_binary(self) -> Path:
|
| 114 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 115 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 116 |
-
if override:
|
| 117 |
-
try_elastix(Path(override))
|
| 118 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 119 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 120 |
-
try:
|
| 121 |
-
try_elastix(ELASTIX_CACHE)
|
| 122 |
-
except Exception:
|
| 123 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 124 |
-
try_elastix(ELASTIX_CACHE)
|
| 125 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 126 |
-
|
| 127 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 128 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 129 |
-
models = []
|
| 130 |
-
for ref in self._models:
|
| 131 |
-
repo, filename = ref.split(":", 1)
|
| 132 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 133 |
-
models.append((filename, local))
|
| 134 |
-
return models
|
| 135 |
-
|
| 136 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 137 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 138 |
-
|
| 139 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 140 |
-
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 141 |
-
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 142 |
-
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 143 |
-
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 144 |
-
"""
|
| 145 |
-
per_token: dict[str, str] = {}
|
| 146 |
-
if not global_only and self._max_iterations > 0:
|
| 147 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 148 |
-
if self._final_grid_spacing > 0:
|
| 149 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 150 |
-
if not global_only and self._subset_features > 0:
|
| 151 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 152 |
-
if self._spatial_samples > 0:
|
| 153 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 154 |
-
exact: list[tuple[str, str]] = []
|
| 155 |
-
for entry in self._parameter_overrides:
|
| 156 |
-
key, sep, value = entry.partition("=")
|
| 157 |
-
if not sep or not key.strip():
|
| 158 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 159 |
-
exact.append((key.strip(), value.strip()))
|
| 160 |
-
return per_token, exact
|
| 161 |
-
|
| 162 |
-
@staticmethod
|
| 163 |
-
def _apply_map_overrides(
|
| 164 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 165 |
-
) -> str:
|
| 166 |
-
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 167 |
-
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 168 |
-
"""
|
| 169 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 170 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 171 |
-
seen: set[str] = set()
|
| 172 |
-
lines = []
|
| 173 |
-
for line in text.splitlines():
|
| 174 |
-
match = entry_pattern.match(line)
|
| 175 |
-
if match:
|
| 176 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 177 |
-
if key == "ImpactGPU":
|
| 178 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 179 |
-
else:
|
| 180 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 181 |
-
if exact_value is not None:
|
| 182 |
-
seen.add(key)
|
| 183 |
-
line = f"{indent}({key} {exact_value})"
|
| 184 |
-
else:
|
| 185 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 186 |
-
if token_key in per_token:
|
| 187 |
-
seen.add(token_key)
|
| 188 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 189 |
-
line = f"{indent}({key} {replaced})"
|
| 190 |
-
lines.append(line)
|
| 191 |
-
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 192 |
-
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 193 |
-
for key in sorted(requested - seen):
|
| 194 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 195 |
-
return "\n".join(lines)
|
| 196 |
-
|
| 197 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 198 |
-
"""Stage the parameter maps into ``work``.
|
| 199 |
-
|
| 200 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 201 |
-
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 202 |
-
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 203 |
-
"""
|
| 204 |
-
staged = []
|
| 205 |
-
for src in self._parameter_maps:
|
| 206 |
-
if self._resolutions:
|
| 207 |
-
text = generate_impact_parameter_map(
|
| 208 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 209 |
-
)
|
| 210 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 211 |
-
else:
|
| 212 |
-
text = src.read_text(encoding="utf-8")
|
| 213 |
-
per_token, exact = self._parameter_map_overrides()
|
| 214 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 215 |
-
dst = work / src.name
|
| 216 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 217 |
-
staged.append(dst)
|
| 218 |
-
return staged
|
| 219 |
-
|
| 220 |
-
def register(
|
| 221 |
-
self,
|
| 222 |
-
fixed: sitk.Image,
|
| 223 |
-
moving: sitk.Image,
|
| 224 |
-
device_index: int,
|
| 225 |
-
fixed_mask: sitk.Image | None = None,
|
| 226 |
-
moving_mask: sitk.Image | None = None,
|
| 227 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 228 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 229 |
-
|
| 230 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 231 |
-
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 232 |
-
"""
|
| 233 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 234 |
-
try:
|
| 235 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 236 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 237 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 238 |
-
|
| 239 |
-
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 240 |
-
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 241 |
-
for rel_name, model_path in self._local_models:
|
| 242 |
-
dst = work / rel_name
|
| 243 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 244 |
-
if not dst.exists():
|
| 245 |
-
dst.symlink_to(model_path)
|
| 246 |
-
|
| 247 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 248 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 249 |
-
if _is_partial_mask(mask):
|
| 250 |
-
mask_path = work / name
|
| 251 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 252 |
-
args += [flag, str(mask_path)]
|
| 253 |
-
args += ["-out", str(work)]
|
| 254 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 255 |
-
args += ["-p", str(pmap)]
|
| 256 |
-
|
| 257 |
-
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 258 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 259 |
-
env = os.environ.copy()
|
| 260 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 261 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 262 |
-
proc = subprocess.Popen( # nosec B603
|
| 263 |
-
args,
|
| 264 |
-
cwd=str(work),
|
| 265 |
-
stdout=subprocess.PIPE,
|
| 266 |
-
stderr=subprocess.STDOUT,
|
| 267 |
-
text=True,
|
| 268 |
-
bufsize=1,
|
| 269 |
-
env=env,
|
| 270 |
-
)
|
| 271 |
-
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 272 |
-
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 273 |
-
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 274 |
-
captured: list[str] = []
|
| 275 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 276 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 277 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 278 |
-
assert proc.stdout is not None
|
| 279 |
-
resolution = 0
|
| 280 |
-
for line in proc.stdout:
|
| 281 |
-
captured.append(line)
|
| 282 |
-
stripped = line.strip()
|
| 283 |
-
if stripped.startswith("Resolution:"):
|
| 284 |
-
try:
|
| 285 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 286 |
-
except ValueError:
|
| 287 |
-
pass
|
| 288 |
-
elif iteration_line.match(line):
|
| 289 |
-
progress.update(1)
|
| 290 |
-
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 291 |
-
if len(columns) > 1:
|
| 292 |
-
try:
|
| 293 |
-
progress.set_description(
|
| 294 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 295 |
-
)
|
| 296 |
-
except ValueError:
|
| 297 |
-
pass
|
| 298 |
-
progress.close()
|
| 299 |
-
returncode = proc.wait()
|
| 300 |
-
if returncode != 0:
|
| 301 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 302 |
-
|
| 303 |
-
transforms = sorted(
|
| 304 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 305 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 306 |
-
)
|
| 307 |
-
if not transforms:
|
| 308 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 309 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 310 |
-
|
| 311 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 312 |
-
dvf = sitk.TransformToDisplacementField(
|
| 313 |
-
transform,
|
| 314 |
-
sitk.sitkVectorFloat64,
|
| 315 |
-
fixed.GetSize(),
|
| 316 |
-
fixed.GetOrigin(),
|
| 317 |
-
fixed.GetSpacing(),
|
| 318 |
-
fixed.GetDirection(),
|
| 319 |
-
)
|
| 320 |
-
moved_np, _ = image_to_data(moved)
|
| 321 |
-
dvf_np, _ = image_to_data(dvf)
|
| 322 |
-
return moved_np, dvf_np
|
| 323 |
-
finally:
|
| 324 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
class ElastixRegistration(torch.nn.Module):
|
| 328 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 329 |
-
|
| 330 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 331 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 332 |
-
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 333 |
-
"""
|
| 334 |
-
|
| 335 |
-
accepts_attributes = True
|
| 336 |
-
|
| 337 |
-
def __init__(
|
| 338 |
-
self,
|
| 339 |
-
engine: str,
|
| 340 |
-
parameter_maps: list[str],
|
| 341 |
-
max_iterations: int = 0,
|
| 342 |
-
final_grid_spacing: float = 0.0,
|
| 343 |
-
subset_features: int = 0,
|
| 344 |
-
spatial_samples: int = 0,
|
| 345 |
-
parameter_overrides: list[str] = [],
|
| 346 |
-
resolutions: dict = {},
|
| 347 |
-
mode: str = "Static",
|
| 348 |
-
) -> None:
|
| 349 |
-
super().__init__()
|
| 350 |
-
if engine != "elastix":
|
| 351 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 352 |
-
self._engine = ElastixEngine(
|
| 353 |
-
parameter_maps,
|
| 354 |
-
max_iterations,
|
| 355 |
-
final_grid_spacing,
|
| 356 |
-
subset_features,
|
| 357 |
-
spatial_samples,
|
| 358 |
-
parameter_overrides,
|
| 359 |
-
resolutions,
|
| 360 |
-
mode,
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
def forward(
|
| 364 |
-
self,
|
| 365 |
-
fixed: torch.Tensor,
|
| 366 |
-
moving: torch.Tensor,
|
| 367 |
-
fixed_mask: torch.Tensor,
|
| 368 |
-
moving_mask: torch.Tensor,
|
| 369 |
-
attributes: list[list[Attribute]],
|
| 370 |
-
) -> torch.Tensor:
|
| 371 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 372 |
-
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 373 |
-
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 374 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 375 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 376 |
-
combined = []
|
| 377 |
-
for b in range(fixed.shape[0]):
|
| 378 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 379 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 380 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 381 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 382 |
-
moved_np, dvf_np = self._engine.register(
|
| 383 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 384 |
-
)
|
| 385 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 386 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CBCT_CT_HeadNeck/install.py
DELETED
|
@@ -1,325 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
import argparse
|
| 18 |
-
import platform
|
| 19 |
-
import re
|
| 20 |
-
import shutil
|
| 21 |
-
import stat
|
| 22 |
-
import subprocess # nosec B404
|
| 23 |
-
import zipfile
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
|
| 26 |
-
import requests
|
| 27 |
-
from tqdm import tqdm
|
| 28 |
-
|
| 29 |
-
# -----------------------------------------------------------------------------
|
| 30 |
-
# Elastix + IMPACT binary assets hosted on GitHub Releases.
|
| 31 |
-
#
|
| 32 |
-
# Key format: (OS, ARCH, FLAVOR)
|
| 33 |
-
# - OS : platform.system() -> "Linux", "Windows", "Darwin"
|
| 34 |
-
# - ARCH : normalized architecture -> "x86_64"
|
| 35 |
-
# - FLAVOR : "cpu" or "cu128"
|
| 36 |
-
#
|
| 37 |
-
# CPU assets are standalone (LibTorch CPU bundled).
|
| 38 |
-
# CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
|
| 39 |
-
# -----------------------------------------------------------------------------
|
| 40 |
-
ELX_ASSET_TEMPLATE = {
|
| 41 |
-
("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
|
| 42 |
-
("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
|
| 43 |
-
("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
|
| 44 |
-
("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
|
| 45 |
-
("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
# -----------------------------------------------------------------------------
|
| 49 |
-
# Official LibTorch downloads (PyTorch).
|
| 50 |
-
#
|
| 51 |
-
# IMPORTANT:
|
| 52 |
-
# - Version MUST match the one used at build time (ABI compatibility).
|
| 53 |
-
# - Using "shared-with-deps" ensures CUDA runtime libraries are included
|
| 54 |
-
# (except the NVIDIA driver, which must be installed system-wide).
|
| 55 |
-
# -----------------------------------------------------------------------------
|
| 56 |
-
|
| 57 |
-
LIBTORCH_URL = {
|
| 58 |
-
(
|
| 59 |
-
"Linux",
|
| 60 |
-
"x86_64",
|
| 61 |
-
"cpu",
|
| 62 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 63 |
-
(
|
| 64 |
-
"Windows",
|
| 65 |
-
"x86_64",
|
| 66 |
-
"cpu",
|
| 67 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 68 |
-
(
|
| 69 |
-
"Darwin",
|
| 70 |
-
"arm64",
|
| 71 |
-
"cpu",
|
| 72 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 73 |
-
(
|
| 74 |
-
"Linux",
|
| 75 |
-
"x86_64",
|
| 76 |
-
"cu128",
|
| 77 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 78 |
-
(
|
| 79 |
-
"Windows",
|
| 80 |
-
"x86_64",
|
| 81 |
-
"cu128",
|
| 82 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
# -----------------------------------------------------------------------------
|
| 86 |
-
# Minimum NVIDIA driver versions required for CUDA 12.8.
|
| 87 |
-
#
|
| 88 |
-
# The CUDA Toolkit itself is NOT required.
|
| 89 |
-
# Only a sufficiently recent NVIDIA driver must be installed.
|
| 90 |
-
# -----------------------------------------------------------------------------
|
| 91 |
-
CUDA128_MIN_DRIVER_LINUX = (570, 26)
|
| 92 |
-
CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
|
| 93 |
-
|
| 94 |
-
GITHUB_OWNER = "vboussot"
|
| 95 |
-
GITHUB_REPO = "ImpactElastix"
|
| 96 |
-
GITHUB_TAG = "1.0.0"
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def run_cmd(cmd: list[str]) -> str:
|
| 103 |
-
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
|
| 107 |
-
"""
|
| 108 |
-
Detect presence of an NVIDIA GPU and extract the driver version.
|
| 109 |
-
Returns:
|
| 110 |
-
(True, (major, minor)) if detected
|
| 111 |
-
(False, None) if nvidia-smi is not available
|
| 112 |
-
"""
|
| 113 |
-
try:
|
| 114 |
-
nvidia_smi = shutil.which("nvidia-smi")
|
| 115 |
-
if nvidia_smi is None:
|
| 116 |
-
raise RuntimeError("nvidia-smi not found in PATH")
|
| 117 |
-
|
| 118 |
-
out = (
|
| 119 |
-
subprocess.check_output(
|
| 120 |
-
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
|
| 121 |
-
) # nosec B603
|
| 122 |
-
.decode("utf-8")
|
| 123 |
-
.strip()
|
| 124 |
-
)
|
| 125 |
-
except Exception:
|
| 126 |
-
return (False, None)
|
| 127 |
-
|
| 128 |
-
# Exemple: "575.64"
|
| 129 |
-
m = re.match(r"([0-9]+)\.([0-9]+)", out)
|
| 130 |
-
if not m:
|
| 131 |
-
return (True, None)
|
| 132 |
-
|
| 133 |
-
return (True, (int(m.group(1)), int(m.group(2))))
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
|
| 137 |
-
"""
|
| 138 |
-
Check whether the detected NVIDIA driver satisfies the minimum
|
| 139 |
-
requirement for CUDA 12.8 on the given operating system.
|
| 140 |
-
"""
|
| 141 |
-
if drv is None:
|
| 142 |
-
return False
|
| 143 |
-
if os_name == "Linux":
|
| 144 |
-
return drv >= CUDA128_MIN_DRIVER_LINUX
|
| 145 |
-
if os_name == "Windows":
|
| 146 |
-
return drv >= CUDA128_MIN_DRIVER_WINDOWS
|
| 147 |
-
return False
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def normalize_arch(machine: str) -> str:
|
| 151 |
-
"""
|
| 152 |
-
Normalize platform.machine() output across operating systems.
|
| 153 |
-
|
| 154 |
-
Examples:
|
| 155 |
-
AMD64 -> x86_64
|
| 156 |
-
aarch64 -> arm64
|
| 157 |
-
"""
|
| 158 |
-
m = machine.lower()
|
| 159 |
-
if m in ("x86_64", "amd64"):
|
| 160 |
-
return "x86_64"
|
| 161 |
-
if m in ("aarch64", "arm64"):
|
| 162 |
-
return "arm64"
|
| 163 |
-
return machine
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def download_file(url: str, dst: Path) -> None:
|
| 167 |
-
"""
|
| 168 |
-
Download a file from the given URL with a progress indicator.
|
| 169 |
-
"""
|
| 170 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
-
print(f"Downloading: {url}", flush=True)
|
| 172 |
-
print(f" to: {dst}", flush=True)
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
with requests.get(url, stream=True, timeout=60) as r:
|
| 176 |
-
r.raise_for_status()
|
| 177 |
-
total = int(r.headers.get("content-length", 0))
|
| 178 |
-
with open(dst, "wb") as f:
|
| 179 |
-
with tqdm(
|
| 180 |
-
total=total,
|
| 181 |
-
unit="B",
|
| 182 |
-
unit_scale=True,
|
| 183 |
-
desc=f"Downloading {dst.name}",
|
| 184 |
-
) as pbar:
|
| 185 |
-
for chunk in r.iter_content(chunk_size=8192):
|
| 186 |
-
f.write(chunk)
|
| 187 |
-
pbar.update(len(chunk))
|
| 188 |
-
except Exception as e:
|
| 189 |
-
raise e
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def extract_archive(archive: Path, dst_dir: Path) -> None:
|
| 193 |
-
"""
|
| 194 |
-
Extract a ZIP archive to the destination directory.
|
| 195 |
-
"""
|
| 196 |
-
dst_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
-
print(f"Extracting: {archive} -> {dst_dir}", flush=True)
|
| 198 |
-
with zipfile.ZipFile(archive, "r") as z:
|
| 199 |
-
z.extractall(dst_dir)
|
| 200 |
-
archive.unlink()
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
|
| 204 |
-
os_name = platform.system()
|
| 205 |
-
arch = normalize_arch(platform.machine())
|
| 206 |
-
has_nvidia, drv = detect_nvidia_driver()
|
| 207 |
-
|
| 208 |
-
if os_name not in ("Linux", "Windows", "Darwin"):
|
| 209 |
-
raise NameError(f"Unsupported OS: {os_name}")
|
| 210 |
-
|
| 211 |
-
if arch not in ("x86_64", "arm64"):
|
| 212 |
-
raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
|
| 213 |
-
|
| 214 |
-
flavor = "cpu"
|
| 215 |
-
if force_cuda:
|
| 216 |
-
if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
|
| 217 |
-
raise NameError(
|
| 218 |
-
"CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
flavor = "cu128"
|
| 222 |
-
elif not force_cpu:
|
| 223 |
-
if has_nvidia and driver_ok_for_cuda(os_name, drv):
|
| 224 |
-
flavor = "cu128"
|
| 225 |
-
else:
|
| 226 |
-
flavor = "cpu"
|
| 227 |
-
|
| 228 |
-
print(f"System: {os_name} {arch}", flush=True)
|
| 229 |
-
print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
|
| 230 |
-
print(f"Selected flavor: {flavor}", flush=True)
|
| 231 |
-
|
| 232 |
-
key = (os_name, arch, flavor)
|
| 233 |
-
if key not in ELX_ASSET_TEMPLATE:
|
| 234 |
-
raise NameError(f"No elastix asset configured for {key}")
|
| 235 |
-
if flavor != "cpu" and key not in LIBTORCH_URL:
|
| 236 |
-
raise NameError(f"No libtorch url configured for {key}")
|
| 237 |
-
|
| 238 |
-
install_path = install_path.resolve()
|
| 239 |
-
install_path.mkdir(parents=True, exist_ok=True)
|
| 240 |
-
|
| 241 |
-
elx_asset = ELX_ASSET_TEMPLATE[key]
|
| 242 |
-
elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
|
| 243 |
-
elx_archive = install_path / elx_asset
|
| 244 |
-
download_file(elx_url, elx_archive)
|
| 245 |
-
extract_archive(elx_archive, install_path)
|
| 246 |
-
|
| 247 |
-
# -------------------------------------------------------------------------
|
| 248 |
-
# ZIP archives may drop executable permissions.
|
| 249 |
-
# Ensure elastix and transformix are executable on Unix platforms.
|
| 250 |
-
# -------------------------------------------------------------------------
|
| 251 |
-
if os_name in ("Linux", "Darwin"):
|
| 252 |
-
for exe in ("elastix", "transformix"):
|
| 253 |
-
p = install_path / "bin" / exe
|
| 254 |
-
if p.exists():
|
| 255 |
-
p.chmod(p.stat().st_mode | stat.S_IEXEC)
|
| 256 |
-
|
| 257 |
-
# ---------------------------------------------------------------------
|
| 258 |
-
# - Download matching LibTorch 12.8 with or without CUDA 12.8
|
| 259 |
-
# - Extract it
|
| 260 |
-
# - Move runtime libraries to a location visible to the dynamic loader
|
| 261 |
-
#
|
| 262 |
-
# Linux / macOS : prefix/lib
|
| 263 |
-
# Windows : prefix (next to elastix.exe)
|
| 264 |
-
# ---------------------------------------------------------------------
|
| 265 |
-
lt_url = LIBTORCH_URL[key]
|
| 266 |
-
lt_archive = install_path / Path(lt_url).name
|
| 267 |
-
download_file(lt_url, lt_archive)
|
| 268 |
-
extract_archive(lt_archive, install_path)
|
| 269 |
-
for p in (install_path / "libtorch" / "lib").iterdir():
|
| 270 |
-
if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
|
| 271 |
-
shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
|
| 272 |
-
|
| 273 |
-
shutil.rmtree(install_path / "libtorch")
|
| 274 |
-
if os_name == "Linux" and flavor == "cu128":
|
| 275 |
-
shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def get_elastix_bin(install_path: Path) -> Path:
|
| 279 |
-
return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def try_elastix(install_path: Path) -> None:
|
| 283 |
-
try:
|
| 284 |
-
subprocess.run(
|
| 285 |
-
[str(get_elastix_bin(install_path)), "-h"],
|
| 286 |
-
capture_output=True,
|
| 287 |
-
text=True,
|
| 288 |
-
check=True,
|
| 289 |
-
) # nosec B603
|
| 290 |
-
except subprocess.CalledProcessError as e:
|
| 291 |
-
msg = "Elastix execution failed.\n\n"
|
| 292 |
-
|
| 293 |
-
msg += f"Command:\n{' '.join(e.cmd)}\n"
|
| 294 |
-
msg += f"Return code: {e.returncode}\n\n"
|
| 295 |
-
|
| 296 |
-
if e.stderr:
|
| 297 |
-
msg += "Error output:\n"
|
| 298 |
-
msg += e.stderr.strip()
|
| 299 |
-
raise NameError(msg)
|
| 300 |
-
|
| 301 |
-
except OSError as e:
|
| 302 |
-
msg = (
|
| 303 |
-
"Elastix could not be started.\n\n"
|
| 304 |
-
"This is usually caused by missing shared libraries "
|
| 305 |
-
"(e.g. LibTorch or CUDA runtime).\n\n"
|
| 306 |
-
f"System error:\n{str(e)}"
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
raise NameError(msg)
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def main() -> None:
|
| 313 |
-
ap = argparse.ArgumentParser()
|
| 314 |
-
ap.add_argument(
|
| 315 |
-
"--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
|
| 316 |
-
)
|
| 317 |
-
group = ap.add_mutually_exclusive_group()
|
| 318 |
-
group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
|
| 319 |
-
group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
|
| 320 |
-
args = vars(ap.parse_args())
|
| 321 |
-
install_elastix_impact(**args)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if __name__ == "__main__":
|
| 325 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CBCT_CT_HeadNeck/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
impact_reg_konfai>=1.6.1
|
CBCT_CT_MRSeg/Model.py
DELETED
|
@@ -1,301 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
|
| 18 |
-
|
| 19 |
-
``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
|
| 20 |
-
2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
|
| 21 |
-
the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
|
| 22 |
-
the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
|
| 23 |
-
subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
|
| 24 |
-
|
| 25 |
-
A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
|
| 26 |
-
``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
|
| 27 |
-
|
| 28 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
|
| 29 |
-
(``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import os
|
| 34 |
-
import re
|
| 35 |
-
from dataclasses import dataclass, field
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import Annotated, Literal
|
| 38 |
-
|
| 39 |
-
import torch
|
| 40 |
-
from huggingface_hub import hf_hub_download
|
| 41 |
-
from konfai.network import network
|
| 42 |
-
from konfai.utils.config import Choices, Range
|
| 43 |
-
|
| 44 |
-
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 45 |
-
# A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
|
| 46 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
|
| 47 |
-
# iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
|
| 48 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 49 |
-
|
| 50 |
-
# ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
|
| 51 |
-
# Static anyway; in Jacobian we clamp ``l`` to this plateau.
|
| 52 |
-
_FOV_RAMP_MAX_LAYER = 6
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry_choices() -> list[str]:
|
| 56 |
-
"""The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
|
| 57 |
-
(offline-first). A user may still point ``ref`` at a local model."""
|
| 58 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 59 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _num(x: object) -> str:
|
| 63 |
-
"""Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
|
| 64 |
-
return "%g" % float(x)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
@dataclass
|
| 68 |
-
class ModelSpec:
|
| 69 |
-
"""One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
|
| 70 |
-
rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
|
| 71 |
-
(``models.json``) keyed by ``ref`` — never tuned."""
|
| 72 |
-
|
| 73 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 74 |
-
voxel_size: list[float] = field(default_factory=list)
|
| 75 |
-
layers_weight: list[float] = field(default_factory=lambda: [1.0])
|
| 76 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0
|
| 77 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 78 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 79 |
-
layers_mask: str = ""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
@dataclass
|
| 83 |
-
class ResolutionSpec:
|
| 84 |
-
"""One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
|
| 85 |
-
|
| 86 |
-
max_iterations: Annotated[int, Range(1, 100000)]
|
| 87 |
-
models: dict[str, ModelSpec]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 91 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order."""
|
| 92 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 96 |
-
"""Load models.json (the fixed params per model) from the model repo on Hugging Face.
|
| 97 |
-
|
| 98 |
-
The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
|
| 99 |
-
dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
|
| 100 |
-
"""
|
| 101 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 102 |
-
if local:
|
| 103 |
-
path = Path(local)
|
| 104 |
-
elif ":" in ref:
|
| 105 |
-
repo, filename = ref.split(":", 1)
|
| 106 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(
|
| 109 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
|
| 110 |
-
f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 111 |
-
)
|
| 112 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _model_key(ref: str) -> str:
|
| 116 |
-
"""Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
|
| 117 |
-
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _deepest_active_layer(layers_mask: str) -> int:
|
| 121 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
|
| 122 |
-
|
| 123 |
-
A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
|
| 124 |
-
== ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
|
| 125 |
-
receptive field, so the FOV is governed by the rightmost ``'1'``.
|
| 126 |
-
"""
|
| 127 |
-
mask = layers_mask.strip().strip('"')
|
| 128 |
-
active = [i for i, char in enumerate(mask) if char == "1"]
|
| 129 |
-
if not active:
|
| 130 |
-
raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
|
| 131 |
-
return max(active)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 135 |
-
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 136 |
-
|
| 137 |
-
Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
|
| 138 |
-
``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
|
| 139 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
|
| 140 |
-
to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
|
| 141 |
-
a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
|
| 142 |
-
``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
|
| 143 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut.
|
| 144 |
-
"""
|
| 145 |
-
formula = str(fov.get("formula", "")).strip()
|
| 146 |
-
key = re.sub(r"\s+", "", formula).lower()
|
| 147 |
-
if key.isdigit():
|
| 148 |
-
return int(key)
|
| 149 |
-
if key == "2*r*d+1":
|
| 150 |
-
return 2 * int(fov["r"]) * int(fov["d"]) + 1
|
| 151 |
-
if key == "2^l+3":
|
| 152 |
-
return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
|
| 153 |
-
if "global" in key:
|
| 154 |
-
raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
|
| 155 |
-
if fov.get("value") is not None:
|
| 156 |
-
return int(fov["value"])
|
| 157 |
-
raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 161 |
-
"""PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
|
| 162 |
-
image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
|
| 163 |
-
e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
|
| 164 |
-
dim = int(entry.get("dimension", 3))
|
| 165 |
-
if mode.strip().strip('"').lower() != "jacobian":
|
| 166 |
-
return " ".join(["0"] * dim)
|
| 167 |
-
fov = _fov_value(entry.get("fov", {}), layers_mask)
|
| 168 |
-
return " ".join([str(fov)] * dim)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
|
| 172 |
-
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 173 |
-
|
| 174 |
-
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 175 |
-
ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
|
| 176 |
-
is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
|
| 177 |
-
from the registry formula and the cell's ``layers_mask``.
|
| 178 |
-
"""
|
| 179 |
-
res = _sorted_specs(resolutions)
|
| 180 |
-
n = len(res)
|
| 181 |
-
mode_clean = mode.strip().strip('"') or "Static"
|
| 182 |
-
|
| 183 |
-
impact: list[str] = []
|
| 184 |
-
for k, r in enumerate(res):
|
| 185 |
-
models = _sorted_specs(r.models)
|
| 186 |
-
entries = [registry[_model_key(m.ref)] for m in models]
|
| 187 |
-
|
| 188 |
-
def row(stem: str, values: list[str]) -> None:
|
| 189 |
-
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 190 |
-
|
| 191 |
-
# From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
|
| 192 |
-
# model FOV); everything else is a per-model knob taken straight from the cell.
|
| 193 |
-
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 194 |
-
row("Dimension", [e["dimension"] for e in entries])
|
| 195 |
-
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
| 196 |
-
row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
|
| 197 |
-
row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
|
| 198 |
-
row("LayersMask", [f'"{m.layers_mask}"' for m in models])
|
| 199 |
-
row("SubsetFeatures", [str(m.subset_features) for m in models])
|
| 200 |
-
row("PCA", [str(m.pca) for m in models])
|
| 201 |
-
row("Distance", [f'"{m.distance}"' for m in models])
|
| 202 |
-
row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
|
| 203 |
-
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 204 |
-
|
| 205 |
-
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 206 |
-
# (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
|
| 207 |
-
lines = template_text.splitlines()
|
| 208 |
-
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 209 |
-
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
| 210 |
-
block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
|
| 211 |
-
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for i, (m, line) in enumerate(indexed):
|
| 214 |
-
key = m.group(1) if m else None
|
| 215 |
-
if block_lo <= i <= block_hi:
|
| 216 |
-
if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
|
| 217 |
-
out.extend(impact[:-1])
|
| 218 |
-
elif key == "MaximumNumberOfIterations":
|
| 219 |
-
out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
|
| 220 |
-
elif key == "NumberOfResolutions":
|
| 221 |
-
out.append(f"(NumberOfResolutions {n})")
|
| 222 |
-
elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
|
| 223 |
-
out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
|
| 224 |
-
elif key == "ImpactMode":
|
| 225 |
-
out.append(f'(ImpactMode "{mode_clean}")')
|
| 226 |
-
else:
|
| 227 |
-
out.append(line)
|
| 228 |
-
return "\n".join(out)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class ChannelSelect(torch.nn.Module):
|
| 232 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 235 |
-
super().__init__()
|
| 236 |
-
self._start = start
|
| 237 |
-
self._stop = stop
|
| 238 |
-
|
| 239 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 240 |
-
return tensor[:, self._start : self._stop]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
class RegistrationNet(network.Network):
|
| 244 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
|
| 245 |
-
moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 246 |
-
|
| 247 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 248 |
-
(the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
|
| 249 |
-
``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
|
| 250 |
-
``same_as_group: Volume_0:Fixed``.
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
def __init__(
|
| 254 |
-
self,
|
| 255 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 256 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 257 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 258 |
-
},
|
| 259 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 260 |
-
engine: str = "elastix",
|
| 261 |
-
parameter_maps: list[str] = [],
|
| 262 |
-
max_iterations: Annotated[int, Range(0, 100000)] = 0,
|
| 263 |
-
final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
|
| 264 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0,
|
| 265 |
-
spatial_samples: Annotated[int, Range(0, 100000)] = 0,
|
| 266 |
-
parameter_overrides: list[str] = [],
|
| 267 |
-
resolutions: dict[str, ResolutionSpec] = {},
|
| 268 |
-
mode: Literal["Static", "Jacobian"] = "Static",
|
| 269 |
-
) -> None:
|
| 270 |
-
# The registration is fully described by ``resolutions`` (config = source of truth): each resolution
|
| 271 |
-
# lists its self-configured models; the download list is derived from the cells. Global knobs override
|
| 272 |
-
# the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
|
| 273 |
-
# NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
|
| 274 |
-
# preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
|
| 275 |
-
from elastix_engine import ElastixRegistration
|
| 276 |
-
|
| 277 |
-
super().__init__(
|
| 278 |
-
in_channels=1,
|
| 279 |
-
optimizer=optimizer,
|
| 280 |
-
schedulers=schedulers,
|
| 281 |
-
outputs_criterions=outputs_criterions,
|
| 282 |
-
dim=3,
|
| 283 |
-
)
|
| 284 |
-
self.add_module(
|
| 285 |
-
"Registration",
|
| 286 |
-
ElastixRegistration(
|
| 287 |
-
engine,
|
| 288 |
-
parameter_maps,
|
| 289 |
-
max_iterations,
|
| 290 |
-
final_grid_spacing,
|
| 291 |
-
subset_features,
|
| 292 |
-
spatial_samples,
|
| 293 |
-
parameter_overrides,
|
| 294 |
-
resolutions,
|
| 295 |
-
mode,
|
| 296 |
-
),
|
| 297 |
-
in_branch=[0, 1, 2, 3],
|
| 298 |
-
out_branch=["registration"],
|
| 299 |
-
)
|
| 300 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 301 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CBCT_CT_MRSeg/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.elastix:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
CBCT_CT_MRSeg/elastix_engine.py
DELETED
|
@@ -1,386 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Elastix-IMPACT runtime for the registration bundle.
|
| 18 |
-
|
| 19 |
-
``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
|
| 20 |
-
parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
|
| 21 |
-
``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
|
| 22 |
-
images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
import os
|
| 26 |
-
import re
|
| 27 |
-
import shutil
|
| 28 |
-
import subprocess # nosec B404
|
| 29 |
-
import tempfile
|
| 30 |
-
from pathlib import Path
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
import SimpleITK as sitk
|
| 34 |
-
import torch
|
| 35 |
-
import tqdm
|
| 36 |
-
from huggingface_hub import hf_hub_download
|
| 37 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 38 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 39 |
-
|
| 40 |
-
from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
|
| 41 |
-
|
| 42 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 43 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 44 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 48 |
-
"""True only for a mask that actually restricts the metric region — some voxels in, some out. An
|
| 49 |
-
absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
|
| 50 |
-
is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
|
| 51 |
-
the whole image) instead of paying for a mask that restricts nothing."""
|
| 52 |
-
if mask is None:
|
| 53 |
-
return False
|
| 54 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 55 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class ElastixEngine:
|
| 59 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 60 |
-
|
| 61 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 62 |
-
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 63 |
-
"""
|
| 64 |
-
|
| 65 |
-
def __init__(
|
| 66 |
-
self,
|
| 67 |
-
parameter_maps: list[str],
|
| 68 |
-
max_iterations: int = 0,
|
| 69 |
-
final_grid_spacing: float = 0.0,
|
| 70 |
-
subset_features: int = 0,
|
| 71 |
-
spatial_samples: int = 0,
|
| 72 |
-
parameter_overrides: list[str] = [],
|
| 73 |
-
resolutions: dict = {},
|
| 74 |
-
mode: str = "Static",
|
| 75 |
-
) -> None:
|
| 76 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 77 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 78 |
-
self._max_iterations = max_iterations
|
| 79 |
-
self._final_grid_spacing = final_grid_spacing
|
| 80 |
-
self._subset_features = subset_features
|
| 81 |
-
self._spatial_samples = spatial_samples
|
| 82 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 83 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 84 |
-
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 85 |
-
self._mode = mode
|
| 86 |
-
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 87 |
-
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 88 |
-
self._resolutions = resolutions
|
| 89 |
-
self._registry = load_models_registry() if resolutions else {}
|
| 90 |
-
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 91 |
-
models: list[str] = []
|
| 92 |
-
for res in _sorted_specs(resolutions):
|
| 93 |
-
for model in _sorted_specs(res.models):
|
| 94 |
-
if model.ref not in models:
|
| 95 |
-
models.append(model.ref)
|
| 96 |
-
self._models = models
|
| 97 |
-
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 98 |
-
self._iterations = self._total_iterations()
|
| 99 |
-
self._elastix_bin = self._ensure_binary()
|
| 100 |
-
self._local_models = self._download_models()
|
| 101 |
-
|
| 102 |
-
def _total_iterations(self) -> int:
|
| 103 |
-
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 104 |
-
if self._resolutions:
|
| 105 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 106 |
-
total = 0
|
| 107 |
-
for src in self._parameter_maps:
|
| 108 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 109 |
-
if match:
|
| 110 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 111 |
-
return total
|
| 112 |
-
|
| 113 |
-
def _ensure_binary(self) -> Path:
|
| 114 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 115 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 116 |
-
if override:
|
| 117 |
-
try_elastix(Path(override))
|
| 118 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 119 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 120 |
-
try:
|
| 121 |
-
try_elastix(ELASTIX_CACHE)
|
| 122 |
-
except Exception:
|
| 123 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 124 |
-
try_elastix(ELASTIX_CACHE)
|
| 125 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 126 |
-
|
| 127 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 128 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 129 |
-
models = []
|
| 130 |
-
for ref in self._models:
|
| 131 |
-
repo, filename = ref.split(":", 1)
|
| 132 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 133 |
-
models.append((filename, local))
|
| 134 |
-
return models
|
| 135 |
-
|
| 136 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 137 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 138 |
-
|
| 139 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 140 |
-
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 141 |
-
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 142 |
-
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 143 |
-
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 144 |
-
"""
|
| 145 |
-
per_token: dict[str, str] = {}
|
| 146 |
-
if not global_only and self._max_iterations > 0:
|
| 147 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 148 |
-
if self._final_grid_spacing > 0:
|
| 149 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 150 |
-
if not global_only and self._subset_features > 0:
|
| 151 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 152 |
-
if self._spatial_samples > 0:
|
| 153 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 154 |
-
exact: list[tuple[str, str]] = []
|
| 155 |
-
for entry in self._parameter_overrides:
|
| 156 |
-
key, sep, value = entry.partition("=")
|
| 157 |
-
if not sep or not key.strip():
|
| 158 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 159 |
-
exact.append((key.strip(), value.strip()))
|
| 160 |
-
return per_token, exact
|
| 161 |
-
|
| 162 |
-
@staticmethod
|
| 163 |
-
def _apply_map_overrides(
|
| 164 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 165 |
-
) -> str:
|
| 166 |
-
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 167 |
-
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 168 |
-
"""
|
| 169 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 170 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 171 |
-
seen: set[str] = set()
|
| 172 |
-
lines = []
|
| 173 |
-
for line in text.splitlines():
|
| 174 |
-
match = entry_pattern.match(line)
|
| 175 |
-
if match:
|
| 176 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 177 |
-
if key == "ImpactGPU":
|
| 178 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 179 |
-
else:
|
| 180 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 181 |
-
if exact_value is not None:
|
| 182 |
-
seen.add(key)
|
| 183 |
-
line = f"{indent}({key} {exact_value})"
|
| 184 |
-
else:
|
| 185 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 186 |
-
if token_key in per_token:
|
| 187 |
-
seen.add(token_key)
|
| 188 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 189 |
-
line = f"{indent}({key} {replaced})"
|
| 190 |
-
lines.append(line)
|
| 191 |
-
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 192 |
-
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 193 |
-
for key in sorted(requested - seen):
|
| 194 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 195 |
-
return "\n".join(lines)
|
| 196 |
-
|
| 197 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 198 |
-
"""Stage the parameter maps into ``work``.
|
| 199 |
-
|
| 200 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 201 |
-
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 202 |
-
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 203 |
-
"""
|
| 204 |
-
staged = []
|
| 205 |
-
for src in self._parameter_maps:
|
| 206 |
-
if self._resolutions:
|
| 207 |
-
text = generate_impact_parameter_map(
|
| 208 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 209 |
-
)
|
| 210 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 211 |
-
else:
|
| 212 |
-
text = src.read_text(encoding="utf-8")
|
| 213 |
-
per_token, exact = self._parameter_map_overrides()
|
| 214 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 215 |
-
dst = work / src.name
|
| 216 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 217 |
-
staged.append(dst)
|
| 218 |
-
return staged
|
| 219 |
-
|
| 220 |
-
def register(
|
| 221 |
-
self,
|
| 222 |
-
fixed: sitk.Image,
|
| 223 |
-
moving: sitk.Image,
|
| 224 |
-
device_index: int,
|
| 225 |
-
fixed_mask: sitk.Image | None = None,
|
| 226 |
-
moving_mask: sitk.Image | None = None,
|
| 227 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 228 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 229 |
-
|
| 230 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 231 |
-
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 232 |
-
"""
|
| 233 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 234 |
-
try:
|
| 235 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 236 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 237 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 238 |
-
|
| 239 |
-
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 240 |
-
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 241 |
-
for rel_name, model_path in self._local_models:
|
| 242 |
-
dst = work / rel_name
|
| 243 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 244 |
-
if not dst.exists():
|
| 245 |
-
dst.symlink_to(model_path)
|
| 246 |
-
|
| 247 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 248 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 249 |
-
if _is_partial_mask(mask):
|
| 250 |
-
mask_path = work / name
|
| 251 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 252 |
-
args += [flag, str(mask_path)]
|
| 253 |
-
args += ["-out", str(work)]
|
| 254 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 255 |
-
args += ["-p", str(pmap)]
|
| 256 |
-
|
| 257 |
-
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 258 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 259 |
-
env = os.environ.copy()
|
| 260 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 261 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 262 |
-
proc = subprocess.Popen( # nosec B603
|
| 263 |
-
args,
|
| 264 |
-
cwd=str(work),
|
| 265 |
-
stdout=subprocess.PIPE,
|
| 266 |
-
stderr=subprocess.STDOUT,
|
| 267 |
-
text=True,
|
| 268 |
-
bufsize=1,
|
| 269 |
-
env=env,
|
| 270 |
-
)
|
| 271 |
-
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 272 |
-
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 273 |
-
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 274 |
-
captured: list[str] = []
|
| 275 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 276 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 277 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 278 |
-
assert proc.stdout is not None
|
| 279 |
-
resolution = 0
|
| 280 |
-
for line in proc.stdout:
|
| 281 |
-
captured.append(line)
|
| 282 |
-
stripped = line.strip()
|
| 283 |
-
if stripped.startswith("Resolution:"):
|
| 284 |
-
try:
|
| 285 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 286 |
-
except ValueError:
|
| 287 |
-
pass
|
| 288 |
-
elif iteration_line.match(line):
|
| 289 |
-
progress.update(1)
|
| 290 |
-
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 291 |
-
if len(columns) > 1:
|
| 292 |
-
try:
|
| 293 |
-
progress.set_description(
|
| 294 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 295 |
-
)
|
| 296 |
-
except ValueError:
|
| 297 |
-
pass
|
| 298 |
-
progress.close()
|
| 299 |
-
returncode = proc.wait()
|
| 300 |
-
if returncode != 0:
|
| 301 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 302 |
-
|
| 303 |
-
transforms = sorted(
|
| 304 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 305 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 306 |
-
)
|
| 307 |
-
if not transforms:
|
| 308 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 309 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 310 |
-
|
| 311 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 312 |
-
dvf = sitk.TransformToDisplacementField(
|
| 313 |
-
transform,
|
| 314 |
-
sitk.sitkVectorFloat64,
|
| 315 |
-
fixed.GetSize(),
|
| 316 |
-
fixed.GetOrigin(),
|
| 317 |
-
fixed.GetSpacing(),
|
| 318 |
-
fixed.GetDirection(),
|
| 319 |
-
)
|
| 320 |
-
moved_np, _ = image_to_data(moved)
|
| 321 |
-
dvf_np, _ = image_to_data(dvf)
|
| 322 |
-
return moved_np, dvf_np
|
| 323 |
-
finally:
|
| 324 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
class ElastixRegistration(torch.nn.Module):
|
| 328 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 329 |
-
|
| 330 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 331 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 332 |
-
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 333 |
-
"""
|
| 334 |
-
|
| 335 |
-
accepts_attributes = True
|
| 336 |
-
|
| 337 |
-
def __init__(
|
| 338 |
-
self,
|
| 339 |
-
engine: str,
|
| 340 |
-
parameter_maps: list[str],
|
| 341 |
-
max_iterations: int = 0,
|
| 342 |
-
final_grid_spacing: float = 0.0,
|
| 343 |
-
subset_features: int = 0,
|
| 344 |
-
spatial_samples: int = 0,
|
| 345 |
-
parameter_overrides: list[str] = [],
|
| 346 |
-
resolutions: dict = {},
|
| 347 |
-
mode: str = "Static",
|
| 348 |
-
) -> None:
|
| 349 |
-
super().__init__()
|
| 350 |
-
if engine != "elastix":
|
| 351 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 352 |
-
self._engine = ElastixEngine(
|
| 353 |
-
parameter_maps,
|
| 354 |
-
max_iterations,
|
| 355 |
-
final_grid_spacing,
|
| 356 |
-
subset_features,
|
| 357 |
-
spatial_samples,
|
| 358 |
-
parameter_overrides,
|
| 359 |
-
resolutions,
|
| 360 |
-
mode,
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
def forward(
|
| 364 |
-
self,
|
| 365 |
-
fixed: torch.Tensor,
|
| 366 |
-
moving: torch.Tensor,
|
| 367 |
-
fixed_mask: torch.Tensor,
|
| 368 |
-
moving_mask: torch.Tensor,
|
| 369 |
-
attributes: list[list[Attribute]],
|
| 370 |
-
) -> torch.Tensor:
|
| 371 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 372 |
-
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 373 |
-
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 374 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 375 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 376 |
-
combined = []
|
| 377 |
-
for b in range(fixed.shape[0]):
|
| 378 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 379 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 380 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 381 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 382 |
-
moved_np, dvf_np = self._engine.register(
|
| 383 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 384 |
-
)
|
| 385 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 386 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CBCT_CT_MRSeg/install.py
DELETED
|
@@ -1,325 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
import argparse
|
| 18 |
-
import platform
|
| 19 |
-
import re
|
| 20 |
-
import shutil
|
| 21 |
-
import stat
|
| 22 |
-
import subprocess # nosec B404
|
| 23 |
-
import zipfile
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
|
| 26 |
-
import requests
|
| 27 |
-
from tqdm import tqdm
|
| 28 |
-
|
| 29 |
-
# -----------------------------------------------------------------------------
|
| 30 |
-
# Elastix + IMPACT binary assets hosted on GitHub Releases.
|
| 31 |
-
#
|
| 32 |
-
# Key format: (OS, ARCH, FLAVOR)
|
| 33 |
-
# - OS : platform.system() -> "Linux", "Windows", "Darwin"
|
| 34 |
-
# - ARCH : normalized architecture -> "x86_64"
|
| 35 |
-
# - FLAVOR : "cpu" or "cu128"
|
| 36 |
-
#
|
| 37 |
-
# CPU assets are standalone (LibTorch CPU bundled).
|
| 38 |
-
# CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
|
| 39 |
-
# -----------------------------------------------------------------------------
|
| 40 |
-
ELX_ASSET_TEMPLATE = {
|
| 41 |
-
("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
|
| 42 |
-
("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
|
| 43 |
-
("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
|
| 44 |
-
("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
|
| 45 |
-
("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
# -----------------------------------------------------------------------------
|
| 49 |
-
# Official LibTorch downloads (PyTorch).
|
| 50 |
-
#
|
| 51 |
-
# IMPORTANT:
|
| 52 |
-
# - Version MUST match the one used at build time (ABI compatibility).
|
| 53 |
-
# - Using "shared-with-deps" ensures CUDA runtime libraries are included
|
| 54 |
-
# (except the NVIDIA driver, which must be installed system-wide).
|
| 55 |
-
# -----------------------------------------------------------------------------
|
| 56 |
-
|
| 57 |
-
LIBTORCH_URL = {
|
| 58 |
-
(
|
| 59 |
-
"Linux",
|
| 60 |
-
"x86_64",
|
| 61 |
-
"cpu",
|
| 62 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 63 |
-
(
|
| 64 |
-
"Windows",
|
| 65 |
-
"x86_64",
|
| 66 |
-
"cpu",
|
| 67 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 68 |
-
(
|
| 69 |
-
"Darwin",
|
| 70 |
-
"arm64",
|
| 71 |
-
"cpu",
|
| 72 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 73 |
-
(
|
| 74 |
-
"Linux",
|
| 75 |
-
"x86_64",
|
| 76 |
-
"cu128",
|
| 77 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 78 |
-
(
|
| 79 |
-
"Windows",
|
| 80 |
-
"x86_64",
|
| 81 |
-
"cu128",
|
| 82 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
# -----------------------------------------------------------------------------
|
| 86 |
-
# Minimum NVIDIA driver versions required for CUDA 12.8.
|
| 87 |
-
#
|
| 88 |
-
# The CUDA Toolkit itself is NOT required.
|
| 89 |
-
# Only a sufficiently recent NVIDIA driver must be installed.
|
| 90 |
-
# -----------------------------------------------------------------------------
|
| 91 |
-
CUDA128_MIN_DRIVER_LINUX = (570, 26)
|
| 92 |
-
CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
|
| 93 |
-
|
| 94 |
-
GITHUB_OWNER = "vboussot"
|
| 95 |
-
GITHUB_REPO = "ImpactElastix"
|
| 96 |
-
GITHUB_TAG = "1.0.0"
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def run_cmd(cmd: list[str]) -> str:
|
| 103 |
-
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
|
| 107 |
-
"""
|
| 108 |
-
Detect presence of an NVIDIA GPU and extract the driver version.
|
| 109 |
-
Returns:
|
| 110 |
-
(True, (major, minor)) if detected
|
| 111 |
-
(False, None) if nvidia-smi is not available
|
| 112 |
-
"""
|
| 113 |
-
try:
|
| 114 |
-
nvidia_smi = shutil.which("nvidia-smi")
|
| 115 |
-
if nvidia_smi is None:
|
| 116 |
-
raise RuntimeError("nvidia-smi not found in PATH")
|
| 117 |
-
|
| 118 |
-
out = (
|
| 119 |
-
subprocess.check_output(
|
| 120 |
-
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
|
| 121 |
-
) # nosec B603
|
| 122 |
-
.decode("utf-8")
|
| 123 |
-
.strip()
|
| 124 |
-
)
|
| 125 |
-
except Exception:
|
| 126 |
-
return (False, None)
|
| 127 |
-
|
| 128 |
-
# Exemple: "575.64"
|
| 129 |
-
m = re.match(r"([0-9]+)\.([0-9]+)", out)
|
| 130 |
-
if not m:
|
| 131 |
-
return (True, None)
|
| 132 |
-
|
| 133 |
-
return (True, (int(m.group(1)), int(m.group(2))))
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
|
| 137 |
-
"""
|
| 138 |
-
Check whether the detected NVIDIA driver satisfies the minimum
|
| 139 |
-
requirement for CUDA 12.8 on the given operating system.
|
| 140 |
-
"""
|
| 141 |
-
if drv is None:
|
| 142 |
-
return False
|
| 143 |
-
if os_name == "Linux":
|
| 144 |
-
return drv >= CUDA128_MIN_DRIVER_LINUX
|
| 145 |
-
if os_name == "Windows":
|
| 146 |
-
return drv >= CUDA128_MIN_DRIVER_WINDOWS
|
| 147 |
-
return False
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def normalize_arch(machine: str) -> str:
|
| 151 |
-
"""
|
| 152 |
-
Normalize platform.machine() output across operating systems.
|
| 153 |
-
|
| 154 |
-
Examples:
|
| 155 |
-
AMD64 -> x86_64
|
| 156 |
-
aarch64 -> arm64
|
| 157 |
-
"""
|
| 158 |
-
m = machine.lower()
|
| 159 |
-
if m in ("x86_64", "amd64"):
|
| 160 |
-
return "x86_64"
|
| 161 |
-
if m in ("aarch64", "arm64"):
|
| 162 |
-
return "arm64"
|
| 163 |
-
return machine
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def download_file(url: str, dst: Path) -> None:
|
| 167 |
-
"""
|
| 168 |
-
Download a file from the given URL with a progress indicator.
|
| 169 |
-
"""
|
| 170 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
-
print(f"Downloading: {url}", flush=True)
|
| 172 |
-
print(f" to: {dst}", flush=True)
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
with requests.get(url, stream=True, timeout=60) as r:
|
| 176 |
-
r.raise_for_status()
|
| 177 |
-
total = int(r.headers.get("content-length", 0))
|
| 178 |
-
with open(dst, "wb") as f:
|
| 179 |
-
with tqdm(
|
| 180 |
-
total=total,
|
| 181 |
-
unit="B",
|
| 182 |
-
unit_scale=True,
|
| 183 |
-
desc=f"Downloading {dst.name}",
|
| 184 |
-
) as pbar:
|
| 185 |
-
for chunk in r.iter_content(chunk_size=8192):
|
| 186 |
-
f.write(chunk)
|
| 187 |
-
pbar.update(len(chunk))
|
| 188 |
-
except Exception as e:
|
| 189 |
-
raise e
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def extract_archive(archive: Path, dst_dir: Path) -> None:
|
| 193 |
-
"""
|
| 194 |
-
Extract a ZIP archive to the destination directory.
|
| 195 |
-
"""
|
| 196 |
-
dst_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
-
print(f"Extracting: {archive} -> {dst_dir}", flush=True)
|
| 198 |
-
with zipfile.ZipFile(archive, "r") as z:
|
| 199 |
-
z.extractall(dst_dir)
|
| 200 |
-
archive.unlink()
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
|
| 204 |
-
os_name = platform.system()
|
| 205 |
-
arch = normalize_arch(platform.machine())
|
| 206 |
-
has_nvidia, drv = detect_nvidia_driver()
|
| 207 |
-
|
| 208 |
-
if os_name not in ("Linux", "Windows", "Darwin"):
|
| 209 |
-
raise NameError(f"Unsupported OS: {os_name}")
|
| 210 |
-
|
| 211 |
-
if arch not in ("x86_64", "arm64"):
|
| 212 |
-
raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
|
| 213 |
-
|
| 214 |
-
flavor = "cpu"
|
| 215 |
-
if force_cuda:
|
| 216 |
-
if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
|
| 217 |
-
raise NameError(
|
| 218 |
-
"CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
flavor = "cu128"
|
| 222 |
-
elif not force_cpu:
|
| 223 |
-
if has_nvidia and driver_ok_for_cuda(os_name, drv):
|
| 224 |
-
flavor = "cu128"
|
| 225 |
-
else:
|
| 226 |
-
flavor = "cpu"
|
| 227 |
-
|
| 228 |
-
print(f"System: {os_name} {arch}", flush=True)
|
| 229 |
-
print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
|
| 230 |
-
print(f"Selected flavor: {flavor}", flush=True)
|
| 231 |
-
|
| 232 |
-
key = (os_name, arch, flavor)
|
| 233 |
-
if key not in ELX_ASSET_TEMPLATE:
|
| 234 |
-
raise NameError(f"No elastix asset configured for {key}")
|
| 235 |
-
if flavor != "cpu" and key not in LIBTORCH_URL:
|
| 236 |
-
raise NameError(f"No libtorch url configured for {key}")
|
| 237 |
-
|
| 238 |
-
install_path = install_path.resolve()
|
| 239 |
-
install_path.mkdir(parents=True, exist_ok=True)
|
| 240 |
-
|
| 241 |
-
elx_asset = ELX_ASSET_TEMPLATE[key]
|
| 242 |
-
elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
|
| 243 |
-
elx_archive = install_path / elx_asset
|
| 244 |
-
download_file(elx_url, elx_archive)
|
| 245 |
-
extract_archive(elx_archive, install_path)
|
| 246 |
-
|
| 247 |
-
# -------------------------------------------------------------------------
|
| 248 |
-
# ZIP archives may drop executable permissions.
|
| 249 |
-
# Ensure elastix and transformix are executable on Unix platforms.
|
| 250 |
-
# -------------------------------------------------------------------------
|
| 251 |
-
if os_name in ("Linux", "Darwin"):
|
| 252 |
-
for exe in ("elastix", "transformix"):
|
| 253 |
-
p = install_path / "bin" / exe
|
| 254 |
-
if p.exists():
|
| 255 |
-
p.chmod(p.stat().st_mode | stat.S_IEXEC)
|
| 256 |
-
|
| 257 |
-
# ---------------------------------------------------------------------
|
| 258 |
-
# - Download matching LibTorch 12.8 with or without CUDA 12.8
|
| 259 |
-
# - Extract it
|
| 260 |
-
# - Move runtime libraries to a location visible to the dynamic loader
|
| 261 |
-
#
|
| 262 |
-
# Linux / macOS : prefix/lib
|
| 263 |
-
# Windows : prefix (next to elastix.exe)
|
| 264 |
-
# ---------------------------------------------------------------------
|
| 265 |
-
lt_url = LIBTORCH_URL[key]
|
| 266 |
-
lt_archive = install_path / Path(lt_url).name
|
| 267 |
-
download_file(lt_url, lt_archive)
|
| 268 |
-
extract_archive(lt_archive, install_path)
|
| 269 |
-
for p in (install_path / "libtorch" / "lib").iterdir():
|
| 270 |
-
if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
|
| 271 |
-
shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
|
| 272 |
-
|
| 273 |
-
shutil.rmtree(install_path / "libtorch")
|
| 274 |
-
if os_name == "Linux" and flavor == "cu128":
|
| 275 |
-
shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def get_elastix_bin(install_path: Path) -> Path:
|
| 279 |
-
return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def try_elastix(install_path: Path) -> None:
|
| 283 |
-
try:
|
| 284 |
-
subprocess.run(
|
| 285 |
-
[str(get_elastix_bin(install_path)), "-h"],
|
| 286 |
-
capture_output=True,
|
| 287 |
-
text=True,
|
| 288 |
-
check=True,
|
| 289 |
-
) # nosec B603
|
| 290 |
-
except subprocess.CalledProcessError as e:
|
| 291 |
-
msg = "Elastix execution failed.\n\n"
|
| 292 |
-
|
| 293 |
-
msg += f"Command:\n{' '.join(e.cmd)}\n"
|
| 294 |
-
msg += f"Return code: {e.returncode}\n\n"
|
| 295 |
-
|
| 296 |
-
if e.stderr:
|
| 297 |
-
msg += "Error output:\n"
|
| 298 |
-
msg += e.stderr.strip()
|
| 299 |
-
raise NameError(msg)
|
| 300 |
-
|
| 301 |
-
except OSError as e:
|
| 302 |
-
msg = (
|
| 303 |
-
"Elastix could not be started.\n\n"
|
| 304 |
-
"This is usually caused by missing shared libraries "
|
| 305 |
-
"(e.g. LibTorch or CUDA runtime).\n\n"
|
| 306 |
-
f"System error:\n{str(e)}"
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
raise NameError(msg)
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def main() -> None:
|
| 313 |
-
ap = argparse.ArgumentParser()
|
| 314 |
-
ap.add_argument(
|
| 315 |
-
"--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
|
| 316 |
-
)
|
| 317 |
-
group = ap.add_mutually_exclusive_group()
|
| 318 |
-
group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
|
| 319 |
-
group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
|
| 320 |
-
args = vars(ap.parse_args())
|
| 321 |
-
install_elastix_impact(**args)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if __name__ == "__main__":
|
| 325 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CBCT_CT_MRSeg/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
impact_reg_konfai>=1.6.1
|
ConvexAdam_Coarse/Model.py
DELETED
|
@@ -1,549 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
|
| 18 |
-
|
| 19 |
-
Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
|
| 20 |
-
(``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
|
| 21 |
-
so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
|
| 22 |
-
the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
|
| 23 |
-
the elastix binary:
|
| 24 |
-
|
| 25 |
-
(optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
|
| 26 |
-
-> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
|
| 27 |
-
-> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
|
| 28 |
-
|
| 29 |
-
The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
|
| 30 |
-
and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
|
| 31 |
-
|
| 32 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
|
| 33 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
-
"""
|
| 35 |
-
|
| 36 |
-
import contextlib
|
| 37 |
-
import json
|
| 38 |
-
import os
|
| 39 |
-
from dataclasses import dataclass, field
|
| 40 |
-
from pathlib import Path
|
| 41 |
-
from typing import Annotated, Literal
|
| 42 |
-
|
| 43 |
-
import itk
|
| 44 |
-
import numpy as np
|
| 45 |
-
import SimpleITK as sitk
|
| 46 |
-
import torch
|
| 47 |
-
import tqdm
|
| 48 |
-
from huggingface_hub import hf_hub_download
|
| 49 |
-
from konfai.network import network
|
| 50 |
-
from konfai.utils.config import Choices, Range
|
| 51 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 52 |
-
|
| 53 |
-
DIM = 3
|
| 54 |
-
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 55 |
-
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 56 |
-
NUM_CHANNELS = 1
|
| 57 |
-
|
| 58 |
-
# A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__`` and ``ModelSpec``:
|
| 59 |
-
# ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` / ``Annotated[str, Choices]`` a dropdown.
|
| 60 |
-
# ``models`` is a dict-of-objects (one ``ModelSpec`` per feature model) — the same shape as the elastix presets,
|
| 61 |
-
# so SlicerKonfAI renders each model as a repeatable block with a ``ref`` / ``distance`` combo box.
|
| 62 |
-
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 63 |
-
|
| 64 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def registry_choices() -> list[str]:
|
| 68 |
-
"""The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry the
|
| 69 |
-
engine already fetches (offline-first). A user may still point ``ref`` at a local model path."""
|
| 70 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 71 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 75 |
-
"""Load ``models.json`` (the available feature models) from the model repo on Hugging Face. The registry is
|
| 76 |
-
NOT bundled: ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for dev/offline; otherwise ``ref`` must be
|
| 77 |
-
a ``repo:file`` Hugging Face reference."""
|
| 78 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 79 |
-
if local:
|
| 80 |
-
path = Path(local)
|
| 81 |
-
elif ":" in ref:
|
| 82 |
-
repo, filename = ref.split(":", 1)
|
| 83 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 84 |
-
else:
|
| 85 |
-
raise ValueError(
|
| 86 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (fetched from HF, not "
|
| 87 |
-
"bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 88 |
-
)
|
| 89 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 93 |
-
"""A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
|
| 94 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
@dataclass
|
| 98 |
-
class ModelSpec:
|
| 99 |
-
"""One feature model in the ConvexAdam multi-feature fusion. ``ref`` picks the model; the rest are its
|
| 100 |
-
per-model knobs (all models are compared jointly by the IMPACT metric). Same idea as the elastix ``ModelSpec``."""
|
| 101 |
-
|
| 102 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 103 |
-
voxel_size: list[float] = field(default_factory=lambda: [3.0, 3.0, 3.0])
|
| 104 |
-
layers_mask: str = "1" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
|
| 105 |
-
layers_weight: float = 1.0
|
| 106 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 107 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
@contextlib.contextmanager
|
| 111 |
-
def _no_texpr_fuser():
|
| 112 |
-
"""Disable ONLY torch's TensorExpr (NNC) fuser for the block. The TS feature-model graphs have a direction/
|
| 113 |
-
orientation branch whose shape ops (aten::dim / aten::size) crash the fuser's alias analysis under itk-impact
|
| 114 |
-
("INTERNAL ASSERT ... We don't have an op for aten::size" in FuseTensorExprs). It cannot be fixed model-side:
|
| 115 |
-
itk-impact passes an *undefined* direction, so guarding it needs a shape op — the very fuser trigger. The
|
| 116 |
-
modern profiling executor stays on (NOT the legacy executor); measured cost ~1% of a registration (the fuser
|
| 117 |
-
only touches the few feature forwards, not the C++ optimisation loop). The caller's setting is restored."""
|
| 118 |
-
prev = torch._C._jit_texpr_fuser_enabled()
|
| 119 |
-
torch._C._jit_set_texpr_fuser_enabled(False)
|
| 120 |
-
try:
|
| 121 |
-
yield
|
| 122 |
-
finally:
|
| 123 |
-
torch._C._jit_set_texpr_fuser_enabled(prev)
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
def _coarse_registration_type():
|
| 127 |
-
"""The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
|
| 128 |
-
cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
|
| 129 |
-
if cls is None:
|
| 130 |
-
raise RuntimeError(
|
| 131 |
-
"itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
|
| 132 |
-
"install a build with the ConvexAdam registration filters."
|
| 133 |
-
)
|
| 134 |
-
return cls[_IMAGE_F, _IMAGE_F]
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def _fine_registration_type():
|
| 138 |
-
"""The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
|
| 139 |
-
cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
|
| 140 |
-
if cls is None:
|
| 141 |
-
raise RuntimeError(
|
| 142 |
-
"itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
|
| 143 |
-
"install a build with the ConvexAdam registration filters."
|
| 144 |
-
)
|
| 145 |
-
return cls[_IMAGE_F, _IMAGE_F]
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
|
| 149 |
-
"""Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
|
| 150 |
-
itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
|
| 151 |
-
itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
|
| 152 |
-
itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
|
| 153 |
-
itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
|
| 154 |
-
return itk_image
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
|
| 158 |
-
"""Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
|
| 159 |
-
array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
|
| 160 |
-
sitk_field = sitk.GetImageFromArray(array, isVector=True)
|
| 161 |
-
sitk_field.CopyInformation(reference)
|
| 162 |
-
return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
|
| 166 |
-
"""Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
|
| 167 |
-
sitk_affine = sitk.AffineTransform(DIM)
|
| 168 |
-
sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
|
| 169 |
-
sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
|
| 170 |
-
sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
|
| 171 |
-
return sitk_affine
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
class ConvexAdamEngine:
|
| 175 |
-
"""Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
|
| 176 |
-
|
| 177 |
-
The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
|
| 178 |
-
Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
|
| 179 |
-
filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
|
| 180 |
-
"""
|
| 181 |
-
|
| 182 |
-
def __init__(
|
| 183 |
-
self,
|
| 184 |
-
models: list[str],
|
| 185 |
-
voxel_sizes: list[list[float]],
|
| 186 |
-
overlap: int,
|
| 187 |
-
layers_masks: list[list[bool]],
|
| 188 |
-
mixed_precision: bool,
|
| 189 |
-
grid_spacing: int,
|
| 190 |
-
displacement_half_width: int,
|
| 191 |
-
iterations: int,
|
| 192 |
-
learning_rate: float,
|
| 193 |
-
regularization_weight: float,
|
| 194 |
-
grid_shrink: int,
|
| 195 |
-
distance: list[str],
|
| 196 |
-
layers_weight: list[float],
|
| 197 |
-
subset_features: list[int],
|
| 198 |
-
pca: list[int],
|
| 199 |
-
stages: list[str],
|
| 200 |
-
linear: bool,
|
| 201 |
-
linear_iterations: int,
|
| 202 |
-
seed: int,
|
| 203 |
-
) -> None:
|
| 204 |
-
self._stages = stages
|
| 205 |
-
self._model_paths = self._download_models(models)
|
| 206 |
-
# Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
|
| 207 |
-
# from disk in C++, so build the list once and reuse it across both stages and every case.
|
| 208 |
-
self._configurations: "list[itk.ModelConfiguration] | None" = None
|
| 209 |
-
self._voxel_sizes = voxel_sizes
|
| 210 |
-
self._overlap = overlap
|
| 211 |
-
self._layers_masks = layers_masks
|
| 212 |
-
self._mixed_precision = mixed_precision
|
| 213 |
-
self._grid_spacing = grid_spacing
|
| 214 |
-
self._displacement_half_width = displacement_half_width
|
| 215 |
-
self._iterations = iterations
|
| 216 |
-
self._learning_rate = learning_rate
|
| 217 |
-
self._regularization_weight = regularization_weight
|
| 218 |
-
self._grid_shrink = grid_shrink
|
| 219 |
-
self._distance = distance
|
| 220 |
-
self._layers_weight = layers_weight
|
| 221 |
-
self._subset_features = subset_features
|
| 222 |
-
self._pca = pca
|
| 223 |
-
self._linear = linear
|
| 224 |
-
self._linear_iterations = linear_iterations
|
| 225 |
-
self._seed = seed
|
| 226 |
-
|
| 227 |
-
@staticmethod
|
| 228 |
-
def _download_models(models: list[str]) -> list[str]:
|
| 229 |
-
"""Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
|
| 230 |
-
paths = []
|
| 231 |
-
for ref in models:
|
| 232 |
-
repo, filename = ref.split(":", 1)
|
| 233 |
-
paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
|
| 234 |
-
return paths
|
| 235 |
-
|
| 236 |
-
def _model_configurations(self) -> list["itk.ModelConfiguration"]:
|
| 237 |
-
"""Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
|
| 238 |
-
|
| 239 |
-
Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
|
| 240 |
-
it is built lazily and cached. The coarse/fine filters copy each configuration by value in
|
| 241 |
-
``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
|
| 242 |
-
``shared_ptr`` — so a single build is reused everywhere without any reload.
|
| 243 |
-
"""
|
| 244 |
-
if self._configurations is None:
|
| 245 |
-
self._configurations = [
|
| 246 |
-
itk.ModelConfiguration(
|
| 247 |
-
path,
|
| 248 |
-
DIM,
|
| 249 |
-
NUM_CHANNELS,
|
| 250 |
-
[0, 0, 0],
|
| 251 |
-
[float(v) for v in voxel_size],
|
| 252 |
-
self._overlap,
|
| 253 |
-
list(layers_mask),
|
| 254 |
-
self._mixed_precision,
|
| 255 |
-
)
|
| 256 |
-
for path, voxel_size, layers_mask in zip(self._model_paths, self._voxel_sizes, self._layers_masks)
|
| 257 |
-
]
|
| 258 |
-
return self._configurations
|
| 259 |
-
|
| 260 |
-
def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
|
| 261 |
-
"""Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
|
| 262 |
-
rigid = itk.VersorRigid3DTransform[itk.D].New()
|
| 263 |
-
initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
|
| 264 |
-
Transform=rigid, FixedImage=fixed, MovingImage=moving
|
| 265 |
-
)
|
| 266 |
-
initializer.MomentsOn()
|
| 267 |
-
initializer.InitializeTransform()
|
| 268 |
-
|
| 269 |
-
affine = itk.AffineTransform[itk.D, DIM].New()
|
| 270 |
-
affine.SetCenter(rigid.GetCenter())
|
| 271 |
-
affine.SetMatrix(rigid.GetMatrix())
|
| 272 |
-
affine.SetOffset(rigid.GetOffset())
|
| 273 |
-
levels = 3
|
| 274 |
-
metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
|
| 275 |
-
metric = metric_type.New()
|
| 276 |
-
metric.SetNumberOfHistogramBins(32)
|
| 277 |
-
optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
|
| 278 |
-
optimizer.SetNumberOfIterations(self._linear_iterations)
|
| 279 |
-
optimizer.SetLearningRate(1.0)
|
| 280 |
-
optimizer.SetMinimumStepLength(1e-5)
|
| 281 |
-
optimizer.SetRelaxationFactor(0.6)
|
| 282 |
-
scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
|
| 283 |
-
scales.SetMetric(metric)
|
| 284 |
-
optimizer.SetScalesEstimator(scales)
|
| 285 |
-
registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
|
| 286 |
-
FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
|
| 287 |
-
)
|
| 288 |
-
registration.SetNumberOfLevels(levels)
|
| 289 |
-
registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
|
| 290 |
-
registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
|
| 291 |
-
registration.InPlaceOn()
|
| 292 |
-
registration.Update()
|
| 293 |
-
return affine
|
| 294 |
-
|
| 295 |
-
def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
|
| 296 |
-
"""ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
|
| 297 |
-
coarse = _coarse_registration_type().New()
|
| 298 |
-
coarse.SetFixedImage(fixed)
|
| 299 |
-
coarse.SetMovingImage(moving)
|
| 300 |
-
for configuration in self._model_configurations():
|
| 301 |
-
coarse.AddModelConfiguration(configuration)
|
| 302 |
-
coarse.SetGridSpacing(self._grid_spacing)
|
| 303 |
-
coarse.SetDisplacementHalfWidth(self._displacement_half_width)
|
| 304 |
-
coarse.SetDevice(device)
|
| 305 |
-
coarse.SetSeed(self._seed)
|
| 306 |
-
coarse.Update()
|
| 307 |
-
field = coarse.GetOutput()
|
| 308 |
-
field.DisconnectPipeline()
|
| 309 |
-
return field
|
| 310 |
-
|
| 311 |
-
def _fine(
|
| 312 |
-
self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
|
| 313 |
-
) -> "itk.Image":
|
| 314 |
-
"""Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
|
| 315 |
-
fine = _fine_registration_type().New()
|
| 316 |
-
fine.SetFixedImage(fixed)
|
| 317 |
-
fine.SetMovingImage(moving)
|
| 318 |
-
fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
|
| 319 |
-
for configuration in self._model_configurations():
|
| 320 |
-
fine.AddModelConfiguration(configuration)
|
| 321 |
-
fine.SetDistance(list(self._distance))
|
| 322 |
-
fine.SetLayersWeight([float(v) for v in self._layers_weight])
|
| 323 |
-
fine.SetSubsetFeatures([int(v) for v in self._subset_features])
|
| 324 |
-
fine.SetPCA([int(v) for v in self._pca])
|
| 325 |
-
fine.SetNumberOfIterations(self._iterations)
|
| 326 |
-
fine.SetLearningRate(self._learning_rate)
|
| 327 |
-
fine.SetRegularizationWeight(self._regularization_weight)
|
| 328 |
-
fine.SetGridShrinkFactor(self._grid_shrink)
|
| 329 |
-
fine.SetDevice(device)
|
| 330 |
-
fine.SetSeed(self._seed)
|
| 331 |
-
|
| 332 |
-
# Optional terminal progress over the Adam iterations, driven from the metric trace. ``disable=None``
|
| 333 |
-
# auto-hides it when stderr is not a TTY (e.g. under KonfAI/Slicer, where the outer "Prediction" bar
|
| 334 |
-
# already reports progress), so captured logs stay clean; ``leave=False`` avoids stacking one bar per
|
| 335 |
-
# patch. The observer is best-effort — if the filter emits no IterationEvent the bar just fills at the end.
|
| 336 |
-
progress = tqdm.tqdm(
|
| 337 |
-
total=self._iterations or None, desc="Registration", ncols=0, leave=False, disable=None
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
def _update(*_: object) -> None:
|
| 341 |
-
values = list(fine.GetMetricValuesPerIteration())
|
| 342 |
-
progress.n = min(len(values), self._iterations)
|
| 343 |
-
if values:
|
| 344 |
-
progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
|
| 345 |
-
progress.refresh()
|
| 346 |
-
|
| 347 |
-
try:
|
| 348 |
-
fine.AddObserver(itk.IterationEvent(), _update)
|
| 349 |
-
except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
|
| 350 |
-
pass
|
| 351 |
-
fine.Update()
|
| 352 |
-
progress.n = progress.total or self._iterations # show completion even if no IterationEvent fired
|
| 353 |
-
progress.refresh()
|
| 354 |
-
progress.close()
|
| 355 |
-
field = fine.GetDisplacementField()
|
| 356 |
-
field.DisconnectPipeline()
|
| 357 |
-
return field
|
| 358 |
-
|
| 359 |
-
@staticmethod
|
| 360 |
-
def _zero_field(reference: "itk.Image") -> "itk.Image":
|
| 361 |
-
"""An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
|
| 362 |
-
field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
|
| 363 |
-
field.CopyInformation(reference)
|
| 364 |
-
field.SetRegions(reference.GetLargestPossibleRegion())
|
| 365 |
-
field.Allocate()
|
| 366 |
-
zero = itk.Vector[itk.F, DIM]()
|
| 367 |
-
zero.Fill(0) # itk::Vector default ctor does not zero-initialise
|
| 368 |
-
field.FillBuffer(zero)
|
| 369 |
-
return field
|
| 370 |
-
|
| 371 |
-
def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
|
| 372 |
-
"""Run the configured coarse/fine chain; each fine warm-starts from the running field.
|
| 373 |
-
|
| 374 |
-
``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
|
| 375 |
-
coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
|
| 376 |
-
(the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
|
| 377 |
-
"""
|
| 378 |
-
field: "itk.Image | None" = None
|
| 379 |
-
for stage in self._stages:
|
| 380 |
-
if stage == "coarse":
|
| 381 |
-
field = self._coarse(fixed, moving, device)
|
| 382 |
-
elif stage == "fine":
|
| 383 |
-
field = self._fine(fixed, moving, field, device)
|
| 384 |
-
else:
|
| 385 |
-
raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
|
| 386 |
-
return field
|
| 387 |
-
|
| 388 |
-
def register(
|
| 389 |
-
self,
|
| 390 |
-
fixed: sitk.Image,
|
| 391 |
-
moving: sitk.Image,
|
| 392 |
-
device_index: int,
|
| 393 |
-
fixed_mask: sitk.Image | None = None,
|
| 394 |
-
moving_mask: sitk.Image | None = None,
|
| 395 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 396 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
|
| 397 |
-
device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
|
| 398 |
-
fixed_itk = _sitk_to_itk(fixed)
|
| 399 |
-
moving_itk = _sitk_to_itk(moving)
|
| 400 |
-
|
| 401 |
-
# Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
|
| 402 |
-
affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
|
| 403 |
-
resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
|
| 404 |
-
Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
|
| 405 |
-
)
|
| 406 |
-
resampler.UseReferenceImageOn()
|
| 407 |
-
resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
|
| 408 |
-
resampler.Update()
|
| 409 |
-
moving_linear = resampler.GetOutput()
|
| 410 |
-
|
| 411 |
-
field = self._run_stages(fixed_itk, moving_linear, device)
|
| 412 |
-
|
| 413 |
-
# One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
|
| 414 |
-
# ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
|
| 415 |
-
# moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
|
| 416 |
-
chain = [_itk_affine_to_sitk(affine)]
|
| 417 |
-
if field is not None:
|
| 418 |
-
chain.append(_itk_field_to_sitk_transform(field, fixed))
|
| 419 |
-
composite = sitk.CompositeTransform(chain)
|
| 420 |
-
moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 421 |
-
dvf = sitk.TransformToDisplacementField(
|
| 422 |
-
composite,
|
| 423 |
-
sitk.sitkVectorFloat64,
|
| 424 |
-
fixed.GetSize(),
|
| 425 |
-
fixed.GetOrigin(),
|
| 426 |
-
fixed.GetSpacing(),
|
| 427 |
-
fixed.GetDirection(),
|
| 428 |
-
)
|
| 429 |
-
moved_np, _ = image_to_data(moved)
|
| 430 |
-
dvf_np, _ = image_to_data(dvf)
|
| 431 |
-
return moved_np, dvf_np
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
class ConvexAdamRegistration(torch.nn.Module):
|
| 435 |
-
"""Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
|
| 436 |
-
|
| 437 |
-
``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
|
| 438 |
-
the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
|
| 439 |
-
"""
|
| 440 |
-
|
| 441 |
-
accepts_attributes = True
|
| 442 |
-
|
| 443 |
-
def __init__(self, engine: ConvexAdamEngine) -> None:
|
| 444 |
-
super().__init__()
|
| 445 |
-
self._engine = engine
|
| 446 |
-
|
| 447 |
-
def forward(
|
| 448 |
-
self,
|
| 449 |
-
fixed: torch.Tensor,
|
| 450 |
-
moving: torch.Tensor,
|
| 451 |
-
fixed_mask: torch.Tensor,
|
| 452 |
-
moving_mask: torch.Tensor,
|
| 453 |
-
attributes: list[list[Attribute]],
|
| 454 |
-
) -> torch.Tensor:
|
| 455 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
|
| 456 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
|
| 457 |
-
# channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
|
| 458 |
-
fixed_attrs, moving_attrs, _, _ = attributes
|
| 459 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 460 |
-
combined = []
|
| 461 |
-
# ConvexAdam runs a gradient-based instance optimisation (Adam over the field) inside itk-impact's
|
| 462 |
-
# .Update(); the predictor calls forward under torch.inference_mode(), which forbids autograd. The
|
| 463 |
-
# image tensors have already crossed to numpy/ITK here, so re-enable grad for the optimisation.
|
| 464 |
-
with torch.inference_mode(False), torch.enable_grad(), _no_texpr_fuser():
|
| 465 |
-
for b in range(fixed.shape[0]):
|
| 466 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 467 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 468 |
-
moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
|
| 469 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 470 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
class ChannelSelect(torch.nn.Module):
|
| 474 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 475 |
-
|
| 476 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 477 |
-
super().__init__()
|
| 478 |
-
self._start = start
|
| 479 |
-
self._stop = stop
|
| 480 |
-
|
| 481 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 482 |
-
return tensor[:, self._start : self._stop]
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
class RegistrationNet(network.Network):
|
| 486 |
-
"""Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
|
| 487 |
-
the mask branches 2/3 are accepted but unused by this engine).
|
| 488 |
-
|
| 489 |
-
Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
|
| 490 |
-
DIM-component displacement field, in mm). Geometry is attached by the predictor via
|
| 491 |
-
``same_as_group: Volume_0:Fixed``.
|
| 492 |
-
"""
|
| 493 |
-
|
| 494 |
-
def __init__(
|
| 495 |
-
self,
|
| 496 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 497 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 498 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 499 |
-
},
|
| 500 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 501 |
-
models: dict[str, ModelSpec] = {},
|
| 502 |
-
overlap: Annotated[int, Range(1, 128)] = 2,
|
| 503 |
-
mixed_precision: bool = False,
|
| 504 |
-
grid_spacing: Annotated[int, Range(1, 512)] = 4,
|
| 505 |
-
displacement_half_width: Annotated[int, Range(1, 512)] = 6,
|
| 506 |
-
iterations: Annotated[int, Range(0, 100000)] = 150,
|
| 507 |
-
learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
|
| 508 |
-
regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
|
| 509 |
-
grid_shrink: Annotated[int, Range(1, 128)] = 4,
|
| 510 |
-
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 511 |
-
stages: list[str] = ["coarse", "fine"],
|
| 512 |
-
linear: bool = True,
|
| 513 |
-
linear_iterations: Annotated[int, Range(0, 100000)] = 200,
|
| 514 |
-
seed: int = 42,
|
| 515 |
-
) -> None:
|
| 516 |
-
super().__init__(
|
| 517 |
-
in_channels=1,
|
| 518 |
-
optimizer=optimizer,
|
| 519 |
-
schedulers=schedulers,
|
| 520 |
-
outputs_criterions=outputs_criterions,
|
| 521 |
-
dim=3,
|
| 522 |
-
)
|
| 523 |
-
specs = _sorted_specs(models)
|
| 524 |
-
engine = ConvexAdamEngine(
|
| 525 |
-
[spec.ref for spec in specs],
|
| 526 |
-
[list(spec.voxel_size) for spec in specs],
|
| 527 |
-
overlap,
|
| 528 |
-
[[c == "1" for c in spec.layers_mask] for spec in specs],
|
| 529 |
-
mixed_precision,
|
| 530 |
-
grid_spacing,
|
| 531 |
-
displacement_half_width,
|
| 532 |
-
iterations,
|
| 533 |
-
learning_rate,
|
| 534 |
-
regularization_weight,
|
| 535 |
-
grid_shrink,
|
| 536 |
-
[spec.distance for spec in specs],
|
| 537 |
-
[float(spec.layers_weight) for spec in specs],
|
| 538 |
-
subset_features,
|
| 539 |
-
[int(spec.pca) for spec in specs],
|
| 540 |
-
stages,
|
| 541 |
-
linear,
|
| 542 |
-
linear_iterations,
|
| 543 |
-
seed,
|
| 544 |
-
)
|
| 545 |
-
self.add_module(
|
| 546 |
-
"Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
|
| 547 |
-
)
|
| 548 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 549 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ConvexAdam_Coarse/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
models:
|
| 6 |
'0':
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.convexadam:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
models:
|
| 6 |
'0':
|
ConvexAdam_Coarse/requirements.txt
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
itk-impact
|
|
|
|
|
|
| 1 |
itk-impact
|
| 2 |
+
impact_reg_konfai>=1.6.1
|
ConvexAdam_Composite/Model.py
DELETED
|
@@ -1,549 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
|
| 18 |
-
|
| 19 |
-
Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
|
| 20 |
-
(``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
|
| 21 |
-
so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
|
| 22 |
-
the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
|
| 23 |
-
the elastix binary:
|
| 24 |
-
|
| 25 |
-
(optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
|
| 26 |
-
-> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
|
| 27 |
-
-> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
|
| 28 |
-
|
| 29 |
-
The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
|
| 30 |
-
and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
|
| 31 |
-
|
| 32 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
|
| 33 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
-
"""
|
| 35 |
-
|
| 36 |
-
import contextlib
|
| 37 |
-
import json
|
| 38 |
-
import os
|
| 39 |
-
from dataclasses import dataclass, field
|
| 40 |
-
from pathlib import Path
|
| 41 |
-
from typing import Annotated, Literal
|
| 42 |
-
|
| 43 |
-
import itk
|
| 44 |
-
import numpy as np
|
| 45 |
-
import SimpleITK as sitk
|
| 46 |
-
import torch
|
| 47 |
-
import tqdm
|
| 48 |
-
from huggingface_hub import hf_hub_download
|
| 49 |
-
from konfai.network import network
|
| 50 |
-
from konfai.utils.config import Choices, Range
|
| 51 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 52 |
-
|
| 53 |
-
DIM = 3
|
| 54 |
-
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 55 |
-
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 56 |
-
NUM_CHANNELS = 1
|
| 57 |
-
|
| 58 |
-
# A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__`` and ``ModelSpec``:
|
| 59 |
-
# ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` / ``Annotated[str, Choices]`` a dropdown.
|
| 60 |
-
# ``models`` is a dict-of-objects (one ``ModelSpec`` per feature model) — the same shape as the elastix presets,
|
| 61 |
-
# so SlicerKonfAI renders each model as a repeatable block with a ``ref`` / ``distance`` combo box.
|
| 62 |
-
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 63 |
-
|
| 64 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def registry_choices() -> list[str]:
|
| 68 |
-
"""The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry the
|
| 69 |
-
engine already fetches (offline-first). A user may still point ``ref`` at a local model path."""
|
| 70 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 71 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 75 |
-
"""Load ``models.json`` (the available feature models) from the model repo on Hugging Face. The registry is
|
| 76 |
-
NOT bundled: ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for dev/offline; otherwise ``ref`` must be
|
| 77 |
-
a ``repo:file`` Hugging Face reference."""
|
| 78 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 79 |
-
if local:
|
| 80 |
-
path = Path(local)
|
| 81 |
-
elif ":" in ref:
|
| 82 |
-
repo, filename = ref.split(":", 1)
|
| 83 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 84 |
-
else:
|
| 85 |
-
raise ValueError(
|
| 86 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (fetched from HF, not "
|
| 87 |
-
"bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 88 |
-
)
|
| 89 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 93 |
-
"""A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
|
| 94 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
@dataclass
|
| 98 |
-
class ModelSpec:
|
| 99 |
-
"""One feature model in the ConvexAdam multi-feature fusion. ``ref`` picks the model; the rest are its
|
| 100 |
-
per-model knobs (all models are compared jointly by the IMPACT metric). Same idea as the elastix ``ModelSpec``."""
|
| 101 |
-
|
| 102 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 103 |
-
voxel_size: list[float] = field(default_factory=lambda: [3.0, 3.0, 3.0])
|
| 104 |
-
layers_mask: str = "1" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
|
| 105 |
-
layers_weight: float = 1.0
|
| 106 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 107 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
@contextlib.contextmanager
|
| 111 |
-
def _no_texpr_fuser():
|
| 112 |
-
"""Disable ONLY torch's TensorExpr (NNC) fuser for the block. The TS feature-model graphs have a direction/
|
| 113 |
-
orientation branch whose shape ops (aten::dim / aten::size) crash the fuser's alias analysis under itk-impact
|
| 114 |
-
("INTERNAL ASSERT ... We don't have an op for aten::size" in FuseTensorExprs). It cannot be fixed model-side:
|
| 115 |
-
itk-impact passes an *undefined* direction, so guarding it needs a shape op — the very fuser trigger. The
|
| 116 |
-
modern profiling executor stays on (NOT the legacy executor); measured cost ~1% of a registration (the fuser
|
| 117 |
-
only touches the few feature forwards, not the C++ optimisation loop). The caller's setting is restored."""
|
| 118 |
-
prev = torch._C._jit_texpr_fuser_enabled()
|
| 119 |
-
torch._C._jit_set_texpr_fuser_enabled(False)
|
| 120 |
-
try:
|
| 121 |
-
yield
|
| 122 |
-
finally:
|
| 123 |
-
torch._C._jit_set_texpr_fuser_enabled(prev)
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
def _coarse_registration_type():
|
| 127 |
-
"""The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
|
| 128 |
-
cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
|
| 129 |
-
if cls is None:
|
| 130 |
-
raise RuntimeError(
|
| 131 |
-
"itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
|
| 132 |
-
"install a build with the ConvexAdam registration filters."
|
| 133 |
-
)
|
| 134 |
-
return cls[_IMAGE_F, _IMAGE_F]
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def _fine_registration_type():
|
| 138 |
-
"""The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
|
| 139 |
-
cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
|
| 140 |
-
if cls is None:
|
| 141 |
-
raise RuntimeError(
|
| 142 |
-
"itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
|
| 143 |
-
"install a build with the ConvexAdam registration filters."
|
| 144 |
-
)
|
| 145 |
-
return cls[_IMAGE_F, _IMAGE_F]
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
|
| 149 |
-
"""Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
|
| 150 |
-
itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
|
| 151 |
-
itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
|
| 152 |
-
itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
|
| 153 |
-
itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
|
| 154 |
-
return itk_image
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
|
| 158 |
-
"""Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
|
| 159 |
-
array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
|
| 160 |
-
sitk_field = sitk.GetImageFromArray(array, isVector=True)
|
| 161 |
-
sitk_field.CopyInformation(reference)
|
| 162 |
-
return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
|
| 166 |
-
"""Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
|
| 167 |
-
sitk_affine = sitk.AffineTransform(DIM)
|
| 168 |
-
sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
|
| 169 |
-
sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
|
| 170 |
-
sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
|
| 171 |
-
return sitk_affine
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
class ConvexAdamEngine:
|
| 175 |
-
"""Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
|
| 176 |
-
|
| 177 |
-
The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
|
| 178 |
-
Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
|
| 179 |
-
filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
|
| 180 |
-
"""
|
| 181 |
-
|
| 182 |
-
def __init__(
|
| 183 |
-
self,
|
| 184 |
-
models: list[str],
|
| 185 |
-
voxel_sizes: list[list[float]],
|
| 186 |
-
overlap: int,
|
| 187 |
-
layers_masks: list[list[bool]],
|
| 188 |
-
mixed_precision: bool,
|
| 189 |
-
grid_spacing: int,
|
| 190 |
-
displacement_half_width: int,
|
| 191 |
-
iterations: int,
|
| 192 |
-
learning_rate: float,
|
| 193 |
-
regularization_weight: float,
|
| 194 |
-
grid_shrink: int,
|
| 195 |
-
distance: list[str],
|
| 196 |
-
layers_weight: list[float],
|
| 197 |
-
subset_features: list[int],
|
| 198 |
-
pca: list[int],
|
| 199 |
-
stages: list[str],
|
| 200 |
-
linear: bool,
|
| 201 |
-
linear_iterations: int,
|
| 202 |
-
seed: int,
|
| 203 |
-
) -> None:
|
| 204 |
-
self._stages = stages
|
| 205 |
-
self._model_paths = self._download_models(models)
|
| 206 |
-
# Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
|
| 207 |
-
# from disk in C++, so build the list once and reuse it across both stages and every case.
|
| 208 |
-
self._configurations: "list[itk.ModelConfiguration] | None" = None
|
| 209 |
-
self._voxel_sizes = voxel_sizes
|
| 210 |
-
self._overlap = overlap
|
| 211 |
-
self._layers_masks = layers_masks
|
| 212 |
-
self._mixed_precision = mixed_precision
|
| 213 |
-
self._grid_spacing = grid_spacing
|
| 214 |
-
self._displacement_half_width = displacement_half_width
|
| 215 |
-
self._iterations = iterations
|
| 216 |
-
self._learning_rate = learning_rate
|
| 217 |
-
self._regularization_weight = regularization_weight
|
| 218 |
-
self._grid_shrink = grid_shrink
|
| 219 |
-
self._distance = distance
|
| 220 |
-
self._layers_weight = layers_weight
|
| 221 |
-
self._subset_features = subset_features
|
| 222 |
-
self._pca = pca
|
| 223 |
-
self._linear = linear
|
| 224 |
-
self._linear_iterations = linear_iterations
|
| 225 |
-
self._seed = seed
|
| 226 |
-
|
| 227 |
-
@staticmethod
|
| 228 |
-
def _download_models(models: list[str]) -> list[str]:
|
| 229 |
-
"""Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
|
| 230 |
-
paths = []
|
| 231 |
-
for ref in models:
|
| 232 |
-
repo, filename = ref.split(":", 1)
|
| 233 |
-
paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
|
| 234 |
-
return paths
|
| 235 |
-
|
| 236 |
-
def _model_configurations(self) -> list["itk.ModelConfiguration"]:
|
| 237 |
-
"""Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
|
| 238 |
-
|
| 239 |
-
Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
|
| 240 |
-
it is built lazily and cached. The coarse/fine filters copy each configuration by value in
|
| 241 |
-
``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
|
| 242 |
-
``shared_ptr`` — so a single build is reused everywhere without any reload.
|
| 243 |
-
"""
|
| 244 |
-
if self._configurations is None:
|
| 245 |
-
self._configurations = [
|
| 246 |
-
itk.ModelConfiguration(
|
| 247 |
-
path,
|
| 248 |
-
DIM,
|
| 249 |
-
NUM_CHANNELS,
|
| 250 |
-
[0, 0, 0],
|
| 251 |
-
[float(v) for v in voxel_size],
|
| 252 |
-
self._overlap,
|
| 253 |
-
list(layers_mask),
|
| 254 |
-
self._mixed_precision,
|
| 255 |
-
)
|
| 256 |
-
for path, voxel_size, layers_mask in zip(self._model_paths, self._voxel_sizes, self._layers_masks)
|
| 257 |
-
]
|
| 258 |
-
return self._configurations
|
| 259 |
-
|
| 260 |
-
def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
|
| 261 |
-
"""Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
|
| 262 |
-
rigid = itk.VersorRigid3DTransform[itk.D].New()
|
| 263 |
-
initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
|
| 264 |
-
Transform=rigid, FixedImage=fixed, MovingImage=moving
|
| 265 |
-
)
|
| 266 |
-
initializer.MomentsOn()
|
| 267 |
-
initializer.InitializeTransform()
|
| 268 |
-
|
| 269 |
-
affine = itk.AffineTransform[itk.D, DIM].New()
|
| 270 |
-
affine.SetCenter(rigid.GetCenter())
|
| 271 |
-
affine.SetMatrix(rigid.GetMatrix())
|
| 272 |
-
affine.SetOffset(rigid.GetOffset())
|
| 273 |
-
levels = 3
|
| 274 |
-
metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
|
| 275 |
-
metric = metric_type.New()
|
| 276 |
-
metric.SetNumberOfHistogramBins(32)
|
| 277 |
-
optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
|
| 278 |
-
optimizer.SetNumberOfIterations(self._linear_iterations)
|
| 279 |
-
optimizer.SetLearningRate(1.0)
|
| 280 |
-
optimizer.SetMinimumStepLength(1e-5)
|
| 281 |
-
optimizer.SetRelaxationFactor(0.6)
|
| 282 |
-
scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
|
| 283 |
-
scales.SetMetric(metric)
|
| 284 |
-
optimizer.SetScalesEstimator(scales)
|
| 285 |
-
registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
|
| 286 |
-
FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
|
| 287 |
-
)
|
| 288 |
-
registration.SetNumberOfLevels(levels)
|
| 289 |
-
registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
|
| 290 |
-
registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
|
| 291 |
-
registration.InPlaceOn()
|
| 292 |
-
registration.Update()
|
| 293 |
-
return affine
|
| 294 |
-
|
| 295 |
-
def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
|
| 296 |
-
"""ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
|
| 297 |
-
coarse = _coarse_registration_type().New()
|
| 298 |
-
coarse.SetFixedImage(fixed)
|
| 299 |
-
coarse.SetMovingImage(moving)
|
| 300 |
-
for configuration in self._model_configurations():
|
| 301 |
-
coarse.AddModelConfiguration(configuration)
|
| 302 |
-
coarse.SetGridSpacing(self._grid_spacing)
|
| 303 |
-
coarse.SetDisplacementHalfWidth(self._displacement_half_width)
|
| 304 |
-
coarse.SetDevice(device)
|
| 305 |
-
coarse.SetSeed(self._seed)
|
| 306 |
-
coarse.Update()
|
| 307 |
-
field = coarse.GetOutput()
|
| 308 |
-
field.DisconnectPipeline()
|
| 309 |
-
return field
|
| 310 |
-
|
| 311 |
-
def _fine(
|
| 312 |
-
self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
|
| 313 |
-
) -> "itk.Image":
|
| 314 |
-
"""Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
|
| 315 |
-
fine = _fine_registration_type().New()
|
| 316 |
-
fine.SetFixedImage(fixed)
|
| 317 |
-
fine.SetMovingImage(moving)
|
| 318 |
-
fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
|
| 319 |
-
for configuration in self._model_configurations():
|
| 320 |
-
fine.AddModelConfiguration(configuration)
|
| 321 |
-
fine.SetDistance(list(self._distance))
|
| 322 |
-
fine.SetLayersWeight([float(v) for v in self._layers_weight])
|
| 323 |
-
fine.SetSubsetFeatures([int(v) for v in self._subset_features])
|
| 324 |
-
fine.SetPCA([int(v) for v in self._pca])
|
| 325 |
-
fine.SetNumberOfIterations(self._iterations)
|
| 326 |
-
fine.SetLearningRate(self._learning_rate)
|
| 327 |
-
fine.SetRegularizationWeight(self._regularization_weight)
|
| 328 |
-
fine.SetGridShrinkFactor(self._grid_shrink)
|
| 329 |
-
fine.SetDevice(device)
|
| 330 |
-
fine.SetSeed(self._seed)
|
| 331 |
-
|
| 332 |
-
# Optional terminal progress over the Adam iterations, driven from the metric trace. ``disable=None``
|
| 333 |
-
# auto-hides it when stderr is not a TTY (e.g. under KonfAI/Slicer, where the outer "Prediction" bar
|
| 334 |
-
# already reports progress), so captured logs stay clean; ``leave=False`` avoids stacking one bar per
|
| 335 |
-
# patch. The observer is best-effort — if the filter emits no IterationEvent the bar just fills at the end.
|
| 336 |
-
progress = tqdm.tqdm(
|
| 337 |
-
total=self._iterations or None, desc="Registration", ncols=0, leave=False, disable=None
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
def _update(*_: object) -> None:
|
| 341 |
-
values = list(fine.GetMetricValuesPerIteration())
|
| 342 |
-
progress.n = min(len(values), self._iterations)
|
| 343 |
-
if values:
|
| 344 |
-
progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
|
| 345 |
-
progress.refresh()
|
| 346 |
-
|
| 347 |
-
try:
|
| 348 |
-
fine.AddObserver(itk.IterationEvent(), _update)
|
| 349 |
-
except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
|
| 350 |
-
pass
|
| 351 |
-
fine.Update()
|
| 352 |
-
progress.n = progress.total or self._iterations # show completion even if no IterationEvent fired
|
| 353 |
-
progress.refresh()
|
| 354 |
-
progress.close()
|
| 355 |
-
field = fine.GetDisplacementField()
|
| 356 |
-
field.DisconnectPipeline()
|
| 357 |
-
return field
|
| 358 |
-
|
| 359 |
-
@staticmethod
|
| 360 |
-
def _zero_field(reference: "itk.Image") -> "itk.Image":
|
| 361 |
-
"""An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
|
| 362 |
-
field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
|
| 363 |
-
field.CopyInformation(reference)
|
| 364 |
-
field.SetRegions(reference.GetLargestPossibleRegion())
|
| 365 |
-
field.Allocate()
|
| 366 |
-
zero = itk.Vector[itk.F, DIM]()
|
| 367 |
-
zero.Fill(0) # itk::Vector default ctor does not zero-initialise
|
| 368 |
-
field.FillBuffer(zero)
|
| 369 |
-
return field
|
| 370 |
-
|
| 371 |
-
def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
|
| 372 |
-
"""Run the configured coarse/fine chain; each fine warm-starts from the running field.
|
| 373 |
-
|
| 374 |
-
``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
|
| 375 |
-
coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
|
| 376 |
-
(the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
|
| 377 |
-
"""
|
| 378 |
-
field: "itk.Image | None" = None
|
| 379 |
-
for stage in self._stages:
|
| 380 |
-
if stage == "coarse":
|
| 381 |
-
field = self._coarse(fixed, moving, device)
|
| 382 |
-
elif stage == "fine":
|
| 383 |
-
field = self._fine(fixed, moving, field, device)
|
| 384 |
-
else:
|
| 385 |
-
raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
|
| 386 |
-
return field
|
| 387 |
-
|
| 388 |
-
def register(
|
| 389 |
-
self,
|
| 390 |
-
fixed: sitk.Image,
|
| 391 |
-
moving: sitk.Image,
|
| 392 |
-
device_index: int,
|
| 393 |
-
fixed_mask: sitk.Image | None = None,
|
| 394 |
-
moving_mask: sitk.Image | None = None,
|
| 395 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 396 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
|
| 397 |
-
device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
|
| 398 |
-
fixed_itk = _sitk_to_itk(fixed)
|
| 399 |
-
moving_itk = _sitk_to_itk(moving)
|
| 400 |
-
|
| 401 |
-
# Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
|
| 402 |
-
affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
|
| 403 |
-
resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
|
| 404 |
-
Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
|
| 405 |
-
)
|
| 406 |
-
resampler.UseReferenceImageOn()
|
| 407 |
-
resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
|
| 408 |
-
resampler.Update()
|
| 409 |
-
moving_linear = resampler.GetOutput()
|
| 410 |
-
|
| 411 |
-
field = self._run_stages(fixed_itk, moving_linear, device)
|
| 412 |
-
|
| 413 |
-
# One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
|
| 414 |
-
# ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
|
| 415 |
-
# moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
|
| 416 |
-
chain = [_itk_affine_to_sitk(affine)]
|
| 417 |
-
if field is not None:
|
| 418 |
-
chain.append(_itk_field_to_sitk_transform(field, fixed))
|
| 419 |
-
composite = sitk.CompositeTransform(chain)
|
| 420 |
-
moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 421 |
-
dvf = sitk.TransformToDisplacementField(
|
| 422 |
-
composite,
|
| 423 |
-
sitk.sitkVectorFloat64,
|
| 424 |
-
fixed.GetSize(),
|
| 425 |
-
fixed.GetOrigin(),
|
| 426 |
-
fixed.GetSpacing(),
|
| 427 |
-
fixed.GetDirection(),
|
| 428 |
-
)
|
| 429 |
-
moved_np, _ = image_to_data(moved)
|
| 430 |
-
dvf_np, _ = image_to_data(dvf)
|
| 431 |
-
return moved_np, dvf_np
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
class ConvexAdamRegistration(torch.nn.Module):
|
| 435 |
-
"""Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
|
| 436 |
-
|
| 437 |
-
``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
|
| 438 |
-
the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
|
| 439 |
-
"""
|
| 440 |
-
|
| 441 |
-
accepts_attributes = True
|
| 442 |
-
|
| 443 |
-
def __init__(self, engine: ConvexAdamEngine) -> None:
|
| 444 |
-
super().__init__()
|
| 445 |
-
self._engine = engine
|
| 446 |
-
|
| 447 |
-
def forward(
|
| 448 |
-
self,
|
| 449 |
-
fixed: torch.Tensor,
|
| 450 |
-
moving: torch.Tensor,
|
| 451 |
-
fixed_mask: torch.Tensor,
|
| 452 |
-
moving_mask: torch.Tensor,
|
| 453 |
-
attributes: list[list[Attribute]],
|
| 454 |
-
) -> torch.Tensor:
|
| 455 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
|
| 456 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
|
| 457 |
-
# channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
|
| 458 |
-
fixed_attrs, moving_attrs, _, _ = attributes
|
| 459 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 460 |
-
combined = []
|
| 461 |
-
# ConvexAdam runs a gradient-based instance optimisation (Adam over the field) inside itk-impact's
|
| 462 |
-
# .Update(); the predictor calls forward under torch.inference_mode(), which forbids autograd. The
|
| 463 |
-
# image tensors have already crossed to numpy/ITK here, so re-enable grad for the optimisation.
|
| 464 |
-
with torch.inference_mode(False), torch.enable_grad(), _no_texpr_fuser():
|
| 465 |
-
for b in range(fixed.shape[0]):
|
| 466 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 467 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 468 |
-
moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
|
| 469 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 470 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
class ChannelSelect(torch.nn.Module):
|
| 474 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 475 |
-
|
| 476 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 477 |
-
super().__init__()
|
| 478 |
-
self._start = start
|
| 479 |
-
self._stop = stop
|
| 480 |
-
|
| 481 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 482 |
-
return tensor[:, self._start : self._stop]
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
class RegistrationNet(network.Network):
|
| 486 |
-
"""Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
|
| 487 |
-
the mask branches 2/3 are accepted but unused by this engine).
|
| 488 |
-
|
| 489 |
-
Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
|
| 490 |
-
DIM-component displacement field, in mm). Geometry is attached by the predictor via
|
| 491 |
-
``same_as_group: Volume_0:Fixed``.
|
| 492 |
-
"""
|
| 493 |
-
|
| 494 |
-
def __init__(
|
| 495 |
-
self,
|
| 496 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 497 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 498 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 499 |
-
},
|
| 500 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 501 |
-
models: dict[str, ModelSpec] = {},
|
| 502 |
-
overlap: Annotated[int, Range(1, 128)] = 2,
|
| 503 |
-
mixed_precision: bool = False,
|
| 504 |
-
grid_spacing: Annotated[int, Range(1, 512)] = 4,
|
| 505 |
-
displacement_half_width: Annotated[int, Range(1, 512)] = 6,
|
| 506 |
-
iterations: Annotated[int, Range(0, 100000)] = 150,
|
| 507 |
-
learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
|
| 508 |
-
regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
|
| 509 |
-
grid_shrink: Annotated[int, Range(1, 128)] = 4,
|
| 510 |
-
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 511 |
-
stages: list[str] = ["coarse", "fine"],
|
| 512 |
-
linear: bool = True,
|
| 513 |
-
linear_iterations: Annotated[int, Range(0, 100000)] = 200,
|
| 514 |
-
seed: int = 42,
|
| 515 |
-
) -> None:
|
| 516 |
-
super().__init__(
|
| 517 |
-
in_channels=1,
|
| 518 |
-
optimizer=optimizer,
|
| 519 |
-
schedulers=schedulers,
|
| 520 |
-
outputs_criterions=outputs_criterions,
|
| 521 |
-
dim=3,
|
| 522 |
-
)
|
| 523 |
-
specs = _sorted_specs(models)
|
| 524 |
-
engine = ConvexAdamEngine(
|
| 525 |
-
[spec.ref for spec in specs],
|
| 526 |
-
[list(spec.voxel_size) for spec in specs],
|
| 527 |
-
overlap,
|
| 528 |
-
[[c == "1" for c in spec.layers_mask] for spec in specs],
|
| 529 |
-
mixed_precision,
|
| 530 |
-
grid_spacing,
|
| 531 |
-
displacement_half_width,
|
| 532 |
-
iterations,
|
| 533 |
-
learning_rate,
|
| 534 |
-
regularization_weight,
|
| 535 |
-
grid_shrink,
|
| 536 |
-
[spec.distance for spec in specs],
|
| 537 |
-
[float(spec.layers_weight) for spec in specs],
|
| 538 |
-
subset_features,
|
| 539 |
-
[int(spec.pca) for spec in specs],
|
| 540 |
-
stages,
|
| 541 |
-
linear,
|
| 542 |
-
linear_iterations,
|
| 543 |
-
seed,
|
| 544 |
-
)
|
| 545 |
-
self.add_module(
|
| 546 |
-
"Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
|
| 547 |
-
)
|
| 548 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 549 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ConvexAdam_Composite/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
models:
|
| 6 |
'0':
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.convexadam:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
models:
|
| 6 |
'0':
|
ConvexAdam_Composite/requirements.txt
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
itk-impact
|
|
|
|
|
|
| 1 |
itk-impact
|
| 2 |
+
impact_reg_konfai>=1.6.1
|
ConvexAdam_Fine/Model.py
DELETED
|
@@ -1,549 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
|
| 18 |
-
|
| 19 |
-
Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
|
| 20 |
-
(``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
|
| 21 |
-
so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
|
| 22 |
-
the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
|
| 23 |
-
the elastix binary:
|
| 24 |
-
|
| 25 |
-
(optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
|
| 26 |
-
-> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
|
| 27 |
-
-> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
|
| 28 |
-
|
| 29 |
-
The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
|
| 30 |
-
and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
|
| 31 |
-
|
| 32 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
|
| 33 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
-
"""
|
| 35 |
-
|
| 36 |
-
import contextlib
|
| 37 |
-
import json
|
| 38 |
-
import os
|
| 39 |
-
from dataclasses import dataclass, field
|
| 40 |
-
from pathlib import Path
|
| 41 |
-
from typing import Annotated, Literal
|
| 42 |
-
|
| 43 |
-
import itk
|
| 44 |
-
import numpy as np
|
| 45 |
-
import SimpleITK as sitk
|
| 46 |
-
import torch
|
| 47 |
-
import tqdm
|
| 48 |
-
from huggingface_hub import hf_hub_download
|
| 49 |
-
from konfai.network import network
|
| 50 |
-
from konfai.utils.config import Choices, Range
|
| 51 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 52 |
-
|
| 53 |
-
DIM = 3
|
| 54 |
-
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 55 |
-
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 56 |
-
NUM_CHANNELS = 1
|
| 57 |
-
|
| 58 |
-
# A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__`` and ``ModelSpec``:
|
| 59 |
-
# ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` / ``Annotated[str, Choices]`` a dropdown.
|
| 60 |
-
# ``models`` is a dict-of-objects (one ``ModelSpec`` per feature model) — the same shape as the elastix presets,
|
| 61 |
-
# so SlicerKonfAI renders each model as a repeatable block with a ``ref`` / ``distance`` combo box.
|
| 62 |
-
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 63 |
-
|
| 64 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def registry_choices() -> list[str]:
|
| 68 |
-
"""The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry the
|
| 69 |
-
engine already fetches (offline-first). A user may still point ``ref`` at a local model path."""
|
| 70 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 71 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 75 |
-
"""Load ``models.json`` (the available feature models) from the model repo on Hugging Face. The registry is
|
| 76 |
-
NOT bundled: ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for dev/offline; otherwise ``ref`` must be
|
| 77 |
-
a ``repo:file`` Hugging Face reference."""
|
| 78 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 79 |
-
if local:
|
| 80 |
-
path = Path(local)
|
| 81 |
-
elif ":" in ref:
|
| 82 |
-
repo, filename = ref.split(":", 1)
|
| 83 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 84 |
-
else:
|
| 85 |
-
raise ValueError(
|
| 86 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (fetched from HF, not "
|
| 87 |
-
"bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 88 |
-
)
|
| 89 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 93 |
-
"""A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
|
| 94 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
@dataclass
|
| 98 |
-
class ModelSpec:
|
| 99 |
-
"""One feature model in the ConvexAdam multi-feature fusion. ``ref`` picks the model; the rest are its
|
| 100 |
-
per-model knobs (all models are compared jointly by the IMPACT metric). Same idea as the elastix ``ModelSpec``."""
|
| 101 |
-
|
| 102 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 103 |
-
voxel_size: list[float] = field(default_factory=lambda: [3.0, 3.0, 3.0])
|
| 104 |
-
layers_mask: str = "1" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
|
| 105 |
-
layers_weight: float = 1.0
|
| 106 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 107 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
@contextlib.contextmanager
|
| 111 |
-
def _no_texpr_fuser():
|
| 112 |
-
"""Disable ONLY torch's TensorExpr (NNC) fuser for the block. The TS feature-model graphs have a direction/
|
| 113 |
-
orientation branch whose shape ops (aten::dim / aten::size) crash the fuser's alias analysis under itk-impact
|
| 114 |
-
("INTERNAL ASSERT ... We don't have an op for aten::size" in FuseTensorExprs). It cannot be fixed model-side:
|
| 115 |
-
itk-impact passes an *undefined* direction, so guarding it needs a shape op — the very fuser trigger. The
|
| 116 |
-
modern profiling executor stays on (NOT the legacy executor); measured cost ~1% of a registration (the fuser
|
| 117 |
-
only touches the few feature forwards, not the C++ optimisation loop). The caller's setting is restored."""
|
| 118 |
-
prev = torch._C._jit_texpr_fuser_enabled()
|
| 119 |
-
torch._C._jit_set_texpr_fuser_enabled(False)
|
| 120 |
-
try:
|
| 121 |
-
yield
|
| 122 |
-
finally:
|
| 123 |
-
torch._C._jit_set_texpr_fuser_enabled(prev)
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
def _coarse_registration_type():
|
| 127 |
-
"""The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
|
| 128 |
-
cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
|
| 129 |
-
if cls is None:
|
| 130 |
-
raise RuntimeError(
|
| 131 |
-
"itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
|
| 132 |
-
"install a build with the ConvexAdam registration filters."
|
| 133 |
-
)
|
| 134 |
-
return cls[_IMAGE_F, _IMAGE_F]
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def _fine_registration_type():
|
| 138 |
-
"""The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
|
| 139 |
-
cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
|
| 140 |
-
if cls is None:
|
| 141 |
-
raise RuntimeError(
|
| 142 |
-
"itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
|
| 143 |
-
"install a build with the ConvexAdam registration filters."
|
| 144 |
-
)
|
| 145 |
-
return cls[_IMAGE_F, _IMAGE_F]
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
|
| 149 |
-
"""Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
|
| 150 |
-
itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
|
| 151 |
-
itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
|
| 152 |
-
itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
|
| 153 |
-
itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
|
| 154 |
-
return itk_image
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
|
| 158 |
-
"""Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
|
| 159 |
-
array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
|
| 160 |
-
sitk_field = sitk.GetImageFromArray(array, isVector=True)
|
| 161 |
-
sitk_field.CopyInformation(reference)
|
| 162 |
-
return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
|
| 166 |
-
"""Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
|
| 167 |
-
sitk_affine = sitk.AffineTransform(DIM)
|
| 168 |
-
sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
|
| 169 |
-
sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
|
| 170 |
-
sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
|
| 171 |
-
return sitk_affine
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
class ConvexAdamEngine:
|
| 175 |
-
"""Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
|
| 176 |
-
|
| 177 |
-
The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
|
| 178 |
-
Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
|
| 179 |
-
filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
|
| 180 |
-
"""
|
| 181 |
-
|
| 182 |
-
def __init__(
|
| 183 |
-
self,
|
| 184 |
-
models: list[str],
|
| 185 |
-
voxel_sizes: list[list[float]],
|
| 186 |
-
overlap: int,
|
| 187 |
-
layers_masks: list[list[bool]],
|
| 188 |
-
mixed_precision: bool,
|
| 189 |
-
grid_spacing: int,
|
| 190 |
-
displacement_half_width: int,
|
| 191 |
-
iterations: int,
|
| 192 |
-
learning_rate: float,
|
| 193 |
-
regularization_weight: float,
|
| 194 |
-
grid_shrink: int,
|
| 195 |
-
distance: list[str],
|
| 196 |
-
layers_weight: list[float],
|
| 197 |
-
subset_features: list[int],
|
| 198 |
-
pca: list[int],
|
| 199 |
-
stages: list[str],
|
| 200 |
-
linear: bool,
|
| 201 |
-
linear_iterations: int,
|
| 202 |
-
seed: int,
|
| 203 |
-
) -> None:
|
| 204 |
-
self._stages = stages
|
| 205 |
-
self._model_paths = self._download_models(models)
|
| 206 |
-
# Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
|
| 207 |
-
# from disk in C++, so build the list once and reuse it across both stages and every case.
|
| 208 |
-
self._configurations: "list[itk.ModelConfiguration] | None" = None
|
| 209 |
-
self._voxel_sizes = voxel_sizes
|
| 210 |
-
self._overlap = overlap
|
| 211 |
-
self._layers_masks = layers_masks
|
| 212 |
-
self._mixed_precision = mixed_precision
|
| 213 |
-
self._grid_spacing = grid_spacing
|
| 214 |
-
self._displacement_half_width = displacement_half_width
|
| 215 |
-
self._iterations = iterations
|
| 216 |
-
self._learning_rate = learning_rate
|
| 217 |
-
self._regularization_weight = regularization_weight
|
| 218 |
-
self._grid_shrink = grid_shrink
|
| 219 |
-
self._distance = distance
|
| 220 |
-
self._layers_weight = layers_weight
|
| 221 |
-
self._subset_features = subset_features
|
| 222 |
-
self._pca = pca
|
| 223 |
-
self._linear = linear
|
| 224 |
-
self._linear_iterations = linear_iterations
|
| 225 |
-
self._seed = seed
|
| 226 |
-
|
| 227 |
-
@staticmethod
|
| 228 |
-
def _download_models(models: list[str]) -> list[str]:
|
| 229 |
-
"""Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
|
| 230 |
-
paths = []
|
| 231 |
-
for ref in models:
|
| 232 |
-
repo, filename = ref.split(":", 1)
|
| 233 |
-
paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
|
| 234 |
-
return paths
|
| 235 |
-
|
| 236 |
-
def _model_configurations(self) -> list["itk.ModelConfiguration"]:
|
| 237 |
-
"""Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
|
| 238 |
-
|
| 239 |
-
Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
|
| 240 |
-
it is built lazily and cached. The coarse/fine filters copy each configuration by value in
|
| 241 |
-
``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
|
| 242 |
-
``shared_ptr`` — so a single build is reused everywhere without any reload.
|
| 243 |
-
"""
|
| 244 |
-
if self._configurations is None:
|
| 245 |
-
self._configurations = [
|
| 246 |
-
itk.ModelConfiguration(
|
| 247 |
-
path,
|
| 248 |
-
DIM,
|
| 249 |
-
NUM_CHANNELS,
|
| 250 |
-
[0, 0, 0],
|
| 251 |
-
[float(v) for v in voxel_size],
|
| 252 |
-
self._overlap,
|
| 253 |
-
list(layers_mask),
|
| 254 |
-
self._mixed_precision,
|
| 255 |
-
)
|
| 256 |
-
for path, voxel_size, layers_mask in zip(self._model_paths, self._voxel_sizes, self._layers_masks)
|
| 257 |
-
]
|
| 258 |
-
return self._configurations
|
| 259 |
-
|
| 260 |
-
def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
|
| 261 |
-
"""Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
|
| 262 |
-
rigid = itk.VersorRigid3DTransform[itk.D].New()
|
| 263 |
-
initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
|
| 264 |
-
Transform=rigid, FixedImage=fixed, MovingImage=moving
|
| 265 |
-
)
|
| 266 |
-
initializer.MomentsOn()
|
| 267 |
-
initializer.InitializeTransform()
|
| 268 |
-
|
| 269 |
-
affine = itk.AffineTransform[itk.D, DIM].New()
|
| 270 |
-
affine.SetCenter(rigid.GetCenter())
|
| 271 |
-
affine.SetMatrix(rigid.GetMatrix())
|
| 272 |
-
affine.SetOffset(rigid.GetOffset())
|
| 273 |
-
levels = 3
|
| 274 |
-
metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
|
| 275 |
-
metric = metric_type.New()
|
| 276 |
-
metric.SetNumberOfHistogramBins(32)
|
| 277 |
-
optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
|
| 278 |
-
optimizer.SetNumberOfIterations(self._linear_iterations)
|
| 279 |
-
optimizer.SetLearningRate(1.0)
|
| 280 |
-
optimizer.SetMinimumStepLength(1e-5)
|
| 281 |
-
optimizer.SetRelaxationFactor(0.6)
|
| 282 |
-
scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
|
| 283 |
-
scales.SetMetric(metric)
|
| 284 |
-
optimizer.SetScalesEstimator(scales)
|
| 285 |
-
registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
|
| 286 |
-
FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
|
| 287 |
-
)
|
| 288 |
-
registration.SetNumberOfLevels(levels)
|
| 289 |
-
registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
|
| 290 |
-
registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
|
| 291 |
-
registration.InPlaceOn()
|
| 292 |
-
registration.Update()
|
| 293 |
-
return affine
|
| 294 |
-
|
| 295 |
-
def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
|
| 296 |
-
"""ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
|
| 297 |
-
coarse = _coarse_registration_type().New()
|
| 298 |
-
coarse.SetFixedImage(fixed)
|
| 299 |
-
coarse.SetMovingImage(moving)
|
| 300 |
-
for configuration in self._model_configurations():
|
| 301 |
-
coarse.AddModelConfiguration(configuration)
|
| 302 |
-
coarse.SetGridSpacing(self._grid_spacing)
|
| 303 |
-
coarse.SetDisplacementHalfWidth(self._displacement_half_width)
|
| 304 |
-
coarse.SetDevice(device)
|
| 305 |
-
coarse.SetSeed(self._seed)
|
| 306 |
-
coarse.Update()
|
| 307 |
-
field = coarse.GetOutput()
|
| 308 |
-
field.DisconnectPipeline()
|
| 309 |
-
return field
|
| 310 |
-
|
| 311 |
-
def _fine(
|
| 312 |
-
self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
|
| 313 |
-
) -> "itk.Image":
|
| 314 |
-
"""Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
|
| 315 |
-
fine = _fine_registration_type().New()
|
| 316 |
-
fine.SetFixedImage(fixed)
|
| 317 |
-
fine.SetMovingImage(moving)
|
| 318 |
-
fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
|
| 319 |
-
for configuration in self._model_configurations():
|
| 320 |
-
fine.AddModelConfiguration(configuration)
|
| 321 |
-
fine.SetDistance(list(self._distance))
|
| 322 |
-
fine.SetLayersWeight([float(v) for v in self._layers_weight])
|
| 323 |
-
fine.SetSubsetFeatures([int(v) for v in self._subset_features])
|
| 324 |
-
fine.SetPCA([int(v) for v in self._pca])
|
| 325 |
-
fine.SetNumberOfIterations(self._iterations)
|
| 326 |
-
fine.SetLearningRate(self._learning_rate)
|
| 327 |
-
fine.SetRegularizationWeight(self._regularization_weight)
|
| 328 |
-
fine.SetGridShrinkFactor(self._grid_shrink)
|
| 329 |
-
fine.SetDevice(device)
|
| 330 |
-
fine.SetSeed(self._seed)
|
| 331 |
-
|
| 332 |
-
# Optional terminal progress over the Adam iterations, driven from the metric trace. ``disable=None``
|
| 333 |
-
# auto-hides it when stderr is not a TTY (e.g. under KonfAI/Slicer, where the outer "Prediction" bar
|
| 334 |
-
# already reports progress), so captured logs stay clean; ``leave=False`` avoids stacking one bar per
|
| 335 |
-
# patch. The observer is best-effort — if the filter emits no IterationEvent the bar just fills at the end.
|
| 336 |
-
progress = tqdm.tqdm(
|
| 337 |
-
total=self._iterations or None, desc="Registration", ncols=0, leave=False, disable=None
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
def _update(*_: object) -> None:
|
| 341 |
-
values = list(fine.GetMetricValuesPerIteration())
|
| 342 |
-
progress.n = min(len(values), self._iterations)
|
| 343 |
-
if values:
|
| 344 |
-
progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
|
| 345 |
-
progress.refresh()
|
| 346 |
-
|
| 347 |
-
try:
|
| 348 |
-
fine.AddObserver(itk.IterationEvent(), _update)
|
| 349 |
-
except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
|
| 350 |
-
pass
|
| 351 |
-
fine.Update()
|
| 352 |
-
progress.n = progress.total or self._iterations # show completion even if no IterationEvent fired
|
| 353 |
-
progress.refresh()
|
| 354 |
-
progress.close()
|
| 355 |
-
field = fine.GetDisplacementField()
|
| 356 |
-
field.DisconnectPipeline()
|
| 357 |
-
return field
|
| 358 |
-
|
| 359 |
-
@staticmethod
|
| 360 |
-
def _zero_field(reference: "itk.Image") -> "itk.Image":
|
| 361 |
-
"""An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
|
| 362 |
-
field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
|
| 363 |
-
field.CopyInformation(reference)
|
| 364 |
-
field.SetRegions(reference.GetLargestPossibleRegion())
|
| 365 |
-
field.Allocate()
|
| 366 |
-
zero = itk.Vector[itk.F, DIM]()
|
| 367 |
-
zero.Fill(0) # itk::Vector default ctor does not zero-initialise
|
| 368 |
-
field.FillBuffer(zero)
|
| 369 |
-
return field
|
| 370 |
-
|
| 371 |
-
def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
|
| 372 |
-
"""Run the configured coarse/fine chain; each fine warm-starts from the running field.
|
| 373 |
-
|
| 374 |
-
``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
|
| 375 |
-
coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
|
| 376 |
-
(the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
|
| 377 |
-
"""
|
| 378 |
-
field: "itk.Image | None" = None
|
| 379 |
-
for stage in self._stages:
|
| 380 |
-
if stage == "coarse":
|
| 381 |
-
field = self._coarse(fixed, moving, device)
|
| 382 |
-
elif stage == "fine":
|
| 383 |
-
field = self._fine(fixed, moving, field, device)
|
| 384 |
-
else:
|
| 385 |
-
raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
|
| 386 |
-
return field
|
| 387 |
-
|
| 388 |
-
def register(
|
| 389 |
-
self,
|
| 390 |
-
fixed: sitk.Image,
|
| 391 |
-
moving: sitk.Image,
|
| 392 |
-
device_index: int,
|
| 393 |
-
fixed_mask: sitk.Image | None = None,
|
| 394 |
-
moving_mask: sitk.Image | None = None,
|
| 395 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 396 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
|
| 397 |
-
device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
|
| 398 |
-
fixed_itk = _sitk_to_itk(fixed)
|
| 399 |
-
moving_itk = _sitk_to_itk(moving)
|
| 400 |
-
|
| 401 |
-
# Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
|
| 402 |
-
affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
|
| 403 |
-
resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
|
| 404 |
-
Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
|
| 405 |
-
)
|
| 406 |
-
resampler.UseReferenceImageOn()
|
| 407 |
-
resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
|
| 408 |
-
resampler.Update()
|
| 409 |
-
moving_linear = resampler.GetOutput()
|
| 410 |
-
|
| 411 |
-
field = self._run_stages(fixed_itk, moving_linear, device)
|
| 412 |
-
|
| 413 |
-
# One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
|
| 414 |
-
# ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
|
| 415 |
-
# moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
|
| 416 |
-
chain = [_itk_affine_to_sitk(affine)]
|
| 417 |
-
if field is not None:
|
| 418 |
-
chain.append(_itk_field_to_sitk_transform(field, fixed))
|
| 419 |
-
composite = sitk.CompositeTransform(chain)
|
| 420 |
-
moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 421 |
-
dvf = sitk.TransformToDisplacementField(
|
| 422 |
-
composite,
|
| 423 |
-
sitk.sitkVectorFloat64,
|
| 424 |
-
fixed.GetSize(),
|
| 425 |
-
fixed.GetOrigin(),
|
| 426 |
-
fixed.GetSpacing(),
|
| 427 |
-
fixed.GetDirection(),
|
| 428 |
-
)
|
| 429 |
-
moved_np, _ = image_to_data(moved)
|
| 430 |
-
dvf_np, _ = image_to_data(dvf)
|
| 431 |
-
return moved_np, dvf_np
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
class ConvexAdamRegistration(torch.nn.Module):
|
| 435 |
-
"""Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
|
| 436 |
-
|
| 437 |
-
``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
|
| 438 |
-
the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
|
| 439 |
-
"""
|
| 440 |
-
|
| 441 |
-
accepts_attributes = True
|
| 442 |
-
|
| 443 |
-
def __init__(self, engine: ConvexAdamEngine) -> None:
|
| 444 |
-
super().__init__()
|
| 445 |
-
self._engine = engine
|
| 446 |
-
|
| 447 |
-
def forward(
|
| 448 |
-
self,
|
| 449 |
-
fixed: torch.Tensor,
|
| 450 |
-
moving: torch.Tensor,
|
| 451 |
-
fixed_mask: torch.Tensor,
|
| 452 |
-
moving_mask: torch.Tensor,
|
| 453 |
-
attributes: list[list[Attribute]],
|
| 454 |
-
) -> torch.Tensor:
|
| 455 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
|
| 456 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
|
| 457 |
-
# channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
|
| 458 |
-
fixed_attrs, moving_attrs, _, _ = attributes
|
| 459 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 460 |
-
combined = []
|
| 461 |
-
# ConvexAdam runs a gradient-based instance optimisation (Adam over the field) inside itk-impact's
|
| 462 |
-
# .Update(); the predictor calls forward under torch.inference_mode(), which forbids autograd. The
|
| 463 |
-
# image tensors have already crossed to numpy/ITK here, so re-enable grad for the optimisation.
|
| 464 |
-
with torch.inference_mode(False), torch.enable_grad(), _no_texpr_fuser():
|
| 465 |
-
for b in range(fixed.shape[0]):
|
| 466 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 467 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 468 |
-
moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
|
| 469 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 470 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
class ChannelSelect(torch.nn.Module):
|
| 474 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 475 |
-
|
| 476 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 477 |
-
super().__init__()
|
| 478 |
-
self._start = start
|
| 479 |
-
self._stop = stop
|
| 480 |
-
|
| 481 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 482 |
-
return tensor[:, self._start : self._stop]
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
class RegistrationNet(network.Network):
|
| 486 |
-
"""Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
|
| 487 |
-
the mask branches 2/3 are accepted but unused by this engine).
|
| 488 |
-
|
| 489 |
-
Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
|
| 490 |
-
DIM-component displacement field, in mm). Geometry is attached by the predictor via
|
| 491 |
-
``same_as_group: Volume_0:Fixed``.
|
| 492 |
-
"""
|
| 493 |
-
|
| 494 |
-
def __init__(
|
| 495 |
-
self,
|
| 496 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 497 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 498 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 499 |
-
},
|
| 500 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 501 |
-
models: dict[str, ModelSpec] = {},
|
| 502 |
-
overlap: Annotated[int, Range(1, 128)] = 2,
|
| 503 |
-
mixed_precision: bool = False,
|
| 504 |
-
grid_spacing: Annotated[int, Range(1, 512)] = 4,
|
| 505 |
-
displacement_half_width: Annotated[int, Range(1, 512)] = 6,
|
| 506 |
-
iterations: Annotated[int, Range(0, 100000)] = 150,
|
| 507 |
-
learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
|
| 508 |
-
regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
|
| 509 |
-
grid_shrink: Annotated[int, Range(1, 128)] = 4,
|
| 510 |
-
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 511 |
-
stages: list[str] = ["coarse", "fine"],
|
| 512 |
-
linear: bool = True,
|
| 513 |
-
linear_iterations: Annotated[int, Range(0, 100000)] = 200,
|
| 514 |
-
seed: int = 42,
|
| 515 |
-
) -> None:
|
| 516 |
-
super().__init__(
|
| 517 |
-
in_channels=1,
|
| 518 |
-
optimizer=optimizer,
|
| 519 |
-
schedulers=schedulers,
|
| 520 |
-
outputs_criterions=outputs_criterions,
|
| 521 |
-
dim=3,
|
| 522 |
-
)
|
| 523 |
-
specs = _sorted_specs(models)
|
| 524 |
-
engine = ConvexAdamEngine(
|
| 525 |
-
[spec.ref for spec in specs],
|
| 526 |
-
[list(spec.voxel_size) for spec in specs],
|
| 527 |
-
overlap,
|
| 528 |
-
[[c == "1" for c in spec.layers_mask] for spec in specs],
|
| 529 |
-
mixed_precision,
|
| 530 |
-
grid_spacing,
|
| 531 |
-
displacement_half_width,
|
| 532 |
-
iterations,
|
| 533 |
-
learning_rate,
|
| 534 |
-
regularization_weight,
|
| 535 |
-
grid_shrink,
|
| 536 |
-
[spec.distance for spec in specs],
|
| 537 |
-
[float(spec.layers_weight) for spec in specs],
|
| 538 |
-
subset_features,
|
| 539 |
-
[int(spec.pca) for spec in specs],
|
| 540 |
-
stages,
|
| 541 |
-
linear,
|
| 542 |
-
linear_iterations,
|
| 543 |
-
seed,
|
| 544 |
-
)
|
| 545 |
-
self.add_module(
|
| 546 |
-
"Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
|
| 547 |
-
)
|
| 548 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 549 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ConvexAdam_Fine/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
models:
|
| 6 |
'0':
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.convexadam:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
models:
|
| 6 |
'0':
|
ConvexAdam_Fine/requirements.txt
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
itk-impact
|
|
|
|
|
|
| 1 |
itk-impact
|
| 2 |
+
impact_reg_konfai>=1.6.1
|
FireANTs_Affine/Model.py
DELETED
|
@@ -1,669 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
#
|
| 17 |
-
# This wrapper does NOT copy any FireANTs source: it only calls the public FireANTs API of the
|
| 18 |
-
# separately-installed ``fireants`` wheel (PyPI). FireANTs is distributed under the FireANTs License
|
| 19 |
-
# v1.0 and must be cited — see the NOTICE file in this directory for the license, copyright and
|
| 20 |
-
# bibliography that ship with this app.
|
| 21 |
-
|
| 22 |
-
"""FireANTs registration as a self-contained KonfAI model (shared by the FireANTs presets).
|
| 23 |
-
|
| 24 |
-
Same idiomatic ``add_module`` graph and the same output contract as the ConvexAdam preset
|
| 25 |
-
(``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``), so the
|
| 26 |
-
orchestrator / app.json / ensemble / uncertainty are unchanged. The engine chains FireANTs' own
|
| 27 |
-
composable stages (GPU, Riemannian Adam), each seeding the next like ANTs' ``-t`` stages:
|
| 28 |
-
|
| 29 |
-
Rigid (MI, centre-of-mass init) -> Affine (MI, seeded by the rigid) -> deformable
|
| 30 |
-
|
| 31 |
-
The deformable stage is selected by ``deformable_method`` — the ONE knob that specialises this shared
|
| 32 |
-
Model.py into the different presets (exactly as ConvexAdam's shared Model.py is specialised by
|
| 33 |
-
``stages``):
|
| 34 |
-
|
| 35 |
-
"syn" symmetric diffeomorphic SyN (CC) — invertible, higher quality, averages cleanly for ensembling
|
| 36 |
-
"greedy" greedy diffeomorphic (CC) — one-directional, faster / lower VRAM
|
| 37 |
-
"none" linear only — Rigid+Affine, no deformable (the FireANTs_Affine preset)
|
| 38 |
-
|
| 39 |
-
Masks: the optional Fixed/Moving masks restrict the metric to a region. FireANTs implements this by
|
| 40 |
-
carrying the mask as the last image channel and prefixing the metric with ``masked_``; a mask is only
|
| 41 |
-
honoured when it actually restricts (some voxels in, some out), so the common mask-free path is
|
| 42 |
-
unchanged (an absent optional mask arrives as a whole-image default and is treated as no mask).
|
| 43 |
-
|
| 44 |
-
The deformable stages produce the single TOTAL displacement field on the fixed grid (the linear
|
| 45 |
-
pre-align is baked in via ``init_affine``, ANTs convention); ``none`` uses the affine matrix directly.
|
| 46 |
-
``MovedImage`` and the emitted ``DisplacementField`` are rebuilt from that transform with SimpleITK —
|
| 47 |
-
the same output path as the ConvexAdam engine — so all presets/engines are interchangeable in an
|
| 48 |
-
ensemble. FireANTs' output-transform writer only serialises to a file, so the deformable field is
|
| 49 |
-
round-tripped through a temporary NIfTI (no FireANTs internals are reimplemented here).
|
| 50 |
-
|
| 51 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
|
| 52 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 53 |
-
"""
|
| 54 |
-
|
| 55 |
-
import contextlib
|
| 56 |
-
import json
|
| 57 |
-
import os
|
| 58 |
-
import tempfile
|
| 59 |
-
from dataclasses import dataclass
|
| 60 |
-
from pathlib import Path
|
| 61 |
-
from typing import Annotated, Literal
|
| 62 |
-
|
| 63 |
-
import numpy as np
|
| 64 |
-
import SimpleITK as sitk
|
| 65 |
-
import torch
|
| 66 |
-
from konfai.metric.measure import IMPACTReg
|
| 67 |
-
from konfai.network import network
|
| 68 |
-
from konfai.utils.config import Choices, Range
|
| 69 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 70 |
-
|
| 71 |
-
DIM = 3
|
| 72 |
-
|
| 73 |
-
# Feature-model registry (models.json): the available IMPACT feature models, fetched from HF (NOT bundled).
|
| 74 |
-
# Only consulted by the "impact" deformable metric; ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins
|
| 75 |
-
# for dev/offline. Mirrors the ConvexAdam preset so the same 30-model catalogue and picker are shared.
|
| 76 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 77 |
-
|
| 78 |
-
_DISTANCES: dict[str, type[torch.nn.Module]] = {"L1": torch.nn.L1Loss, "L2": torch.nn.MSELoss}
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _fireants_git_ref() -> str:
|
| 82 |
-
"""Best-effort FireANTs git ref whose ``fused_ops`` matches the installed ``fireants``.
|
| 83 |
-
|
| 84 |
-
Overridable with ``FIREANTS_FUSED_OPS_REF``; falls back to ``main`` if the version is unknown.
|
| 85 |
-
"""
|
| 86 |
-
try:
|
| 87 |
-
import importlib.metadata
|
| 88 |
-
|
| 89 |
-
version = importlib.metadata.version("fireants").strip()
|
| 90 |
-
if version:
|
| 91 |
-
return version if version.startswith("v") else f"v{version}"
|
| 92 |
-
except Exception:
|
| 93 |
-
pass
|
| 94 |
-
return "main"
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def ensure_fireants_runtime(build_kernels: bool = True) -> None:
|
| 98 |
-
"""Make the ``fireants`` runtime importable before a preset uses it — best-effort, never fatal.
|
| 99 |
-
|
| 100 |
-
A plain ``pip install fireants`` fails inside a host like 3D Slicer for two reasons, both handled
|
| 101 |
-
here with a clear one-line status at each step:
|
| 102 |
-
|
| 103 |
-
1. **fireants won't install.** It pins ``simpleitk==2.2.1`` (no wheel on modern Python), while the
|
| 104 |
-
host already ships a newer SimpleITK. We install it with ``--no-deps`` so that pin is ignored
|
| 105 |
-
and the host's SimpleITK/torch are reused; its light deps ship in ``requirements.txt``.
|
| 106 |
-
2. **The fused CUDA kernels** (``fireants_fused_ops``) that make registration fast and
|
| 107 |
-
memory-light are OPTIONAL. Without them fireants runs in pure PyTorch (correct, only slower).
|
| 108 |
-
We enable them only when a CUDA compiler (``nvcc``) is present, compiling from the upstream
|
| 109 |
-
FireANTs source at install time — nothing is vendored into this app. No compiler, or a failed
|
| 110 |
-
build, simply falls back to pure PyTorch.
|
| 111 |
-
"""
|
| 112 |
-
import importlib
|
| 113 |
-
import shutil
|
| 114 |
-
import subprocess
|
| 115 |
-
import sys
|
| 116 |
-
|
| 117 |
-
def _log(message: str) -> None:
|
| 118 |
-
print(f"[FireANTs] {message}", flush=True)
|
| 119 |
-
|
| 120 |
-
# 1) fireants itself — install --no-deps to sidestep its unsatisfiable ``simpleitk==2.2.1`` pin.
|
| 121 |
-
try:
|
| 122 |
-
importlib.import_module("fireants")
|
| 123 |
-
except Exception:
|
| 124 |
-
_log("installing fireants (--no-deps, reusing the host's SimpleITK/torch)...")
|
| 125 |
-
try:
|
| 126 |
-
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "--no-deps", "fireants"])
|
| 127 |
-
importlib.invalidate_caches()
|
| 128 |
-
importlib.import_module("fireants")
|
| 129 |
-
_log("fireants installed.")
|
| 130 |
-
except Exception as exc:
|
| 131 |
-
raise RuntimeError(
|
| 132 |
-
"Could not install 'fireants'. Install it manually with:\n"
|
| 133 |
-
f" {sys.executable} -m pip install --no-deps fireants\n"
|
| 134 |
-
"(its hydra-core/nibabel/pandas dependencies ship in this app's requirements.txt).\n"
|
| 135 |
-
f"Original error: {exc}"
|
| 136 |
-
) from exc
|
| 137 |
-
|
| 138 |
-
if not build_kernels or os.environ.get("FIREANTS_SKIP_FUSED_OPS", "").strip().lower() in {"1", "true", "yes"}:
|
| 139 |
-
_log("skipping the fused CUDA kernels -> pure PyTorch (correct, slower and more memory).")
|
| 140 |
-
return
|
| 141 |
-
|
| 142 |
-
# 2) fused CUDA kernels — optional accelerator.
|
| 143 |
-
try:
|
| 144 |
-
importlib.import_module("fireants_fused_ops")
|
| 145 |
-
_log("fused CUDA kernels already available.")
|
| 146 |
-
return
|
| 147 |
-
except Exception:
|
| 148 |
-
pass
|
| 149 |
-
|
| 150 |
-
# 2a) a prebuilt wheel matching this torch's CUDA build, if one is ever published.
|
| 151 |
-
cuda_tag = ""
|
| 152 |
-
try:
|
| 153 |
-
cuda_tag = (torch.version.cuda or "").replace(".", "")
|
| 154 |
-
except Exception:
|
| 155 |
-
pass
|
| 156 |
-
if cuda_tag:
|
| 157 |
-
wheel = f"fireants-fused-ops-cu{cuda_tag}"
|
| 158 |
-
try:
|
| 159 |
-
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", wheel])
|
| 160 |
-
importlib.invalidate_caches()
|
| 161 |
-
importlib.import_module("fireants_fused_ops")
|
| 162 |
-
_log(f"installed prebuilt CUDA kernels ({wheel}).")
|
| 163 |
-
return
|
| 164 |
-
except Exception:
|
| 165 |
-
pass # no matching wheel -> try a local build
|
| 166 |
-
|
| 167 |
-
# 2b) local build is OPT-IN: compiling CUDA kernels is heavy and can exhaust RAM on the user's
|
| 168 |
-
# machine, so it NEVER runs by default. Set FIREANTS_BUILD_KERNELS=1 to enable it (devs with a
|
| 169 |
-
# CUDA toolkit); it then builds ONE file at a time (MAX_JOBS=1) to keep memory bounded. The clean
|
| 170 |
-
# path for end users is a prebuilt wheel (2a) — a local compile also needs Python dev headers
|
| 171 |
-
# (absent from some embedded Pythons, e.g. Slicer) and a CUDA-compatible host compiler.
|
| 172 |
-
if os.environ.get("FIREANTS_BUILD_KERNELS", "").strip().lower() not in ("1", "true", "yes"):
|
| 173 |
-
_log(
|
| 174 |
-
"no prebuilt kernels for this platform -> running FireANTs in pure PyTorch (correct, slower "
|
| 175 |
-
"and heavier). Set FIREANTS_BUILD_KERNELS=1 to compile them locally (needs a CUDA toolkit)."
|
| 176 |
-
)
|
| 177 |
-
return
|
| 178 |
-
cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
|
| 179 |
-
nvcc = shutil.which("nvcc") or (os.path.join(cuda_home, "bin", "nvcc") if cuda_home else None)
|
| 180 |
-
if not nvcc or not os.path.exists(nvcc) or shutil.which("git") is None:
|
| 181 |
-
_log("FIREANTS_BUILD_KERNELS set but no CUDA toolkit (nvcc) / git found -> pure PyTorch.")
|
| 182 |
-
return
|
| 183 |
-
|
| 184 |
-
ref = os.environ.get("FIREANTS_FUSED_OPS_REF") or _fireants_git_ref()
|
| 185 |
-
_log(f"nvcc found ({nvcc}); compiling the FireANTs CUDA kernels one file at a time (ref '{ref}')...")
|
| 186 |
-
import tempfile
|
| 187 |
-
|
| 188 |
-
env = os.environ.copy()
|
| 189 |
-
env["MAX_JOBS"] = "1" # one compile at a time -> bounded RAM (prevents OOM on large hosts)
|
| 190 |
-
env.setdefault("NVCC_APPEND_FLAGS", "-allow-unsupported-compiler") # tolerate a newer host compiler
|
| 191 |
-
tmp = tempfile.mkdtemp(prefix="fireants_fused_ops_")
|
| 192 |
-
try:
|
| 193 |
-
# Shallow clone WITHOUT --recursive: FireANTs' submodules (an SSH-only 'cookbook') are unrelated
|
| 194 |
-
# to fused_ops and would otherwise abort the build with a public-key/permission error.
|
| 195 |
-
subprocess.check_call(
|
| 196 |
-
["git", "clone", "--depth", "1", "--branch", ref, "https://github.com/rohitrango/FireANTs.git", tmp]
|
| 197 |
-
)
|
| 198 |
-
subprocess.check_call(
|
| 199 |
-
[sys.executable, "-m", "pip", "install", "-q", "--no-build-isolation", os.path.join(tmp, "fused_ops")],
|
| 200 |
-
env=env,
|
| 201 |
-
)
|
| 202 |
-
importlib.invalidate_caches()
|
| 203 |
-
importlib.import_module("fireants_fused_ops")
|
| 204 |
-
_log("compiled and installed the fast CUDA kernels.")
|
| 205 |
-
except Exception as exc:
|
| 206 |
-
_log(
|
| 207 |
-
"kernel build failed -> running in pure PyTorch (correct, only speed/memory affected). Set "
|
| 208 |
-
"FIREANTS_FUSED_OPS_REF to a compatible FireANTs tag, or install a prebuilt fireants-fused-ops "
|
| 209 |
-
f"wheel, to enable them. Details: {exc}"
|
| 210 |
-
)
|
| 211 |
-
finally:
|
| 212 |
-
shutil.rmtree(tmp, ignore_errors=True)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
def registry_choices() -> list[str]:
|
| 216 |
-
"""The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry."""
|
| 217 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 218 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 222 |
-
"""Load ``models.json`` (available feature models). ``KONFAI_IMPACT_MODELS_REGISTRY`` (local path) wins
|
| 223 |
-
for dev/offline; otherwise ``ref`` is a ``repo:file`` Hugging Face reference (fetched, not bundled)."""
|
| 224 |
-
from huggingface_hub import hf_hub_download
|
| 225 |
-
|
| 226 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 227 |
-
if local:
|
| 228 |
-
path = Path(local)
|
| 229 |
-
elif ":" in ref:
|
| 230 |
-
repo, filename = ref.split(":", 1)
|
| 231 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 232 |
-
else:
|
| 233 |
-
raise ValueError(
|
| 234 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference — or set "
|
| 235 |
-
"KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 236 |
-
)
|
| 237 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 241 |
-
"""A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
|
| 242 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
@dataclass
|
| 246 |
-
class ModelSpec:
|
| 247 |
-
"""One IMPACT feature model in the deformable metric (several are fused). ``ref`` picks the model; the
|
| 248 |
-
rest are its per-model knobs — the same as the ConvexAdam / elastix ``ModelSpec`` except ``voxel_size``
|
| 249 |
-
(an itk-impact resampling knob) has no meaning for FireANTs' geometry-free torch ``custom_loss`` and is
|
| 250 |
-
intentionally absent."""
|
| 251 |
-
|
| 252 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 253 |
-
layers_mask: str = "01" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
|
| 254 |
-
layers_weight: float = 1.0 # this model's weight in the multi-model fusion
|
| 255 |
-
pca: Annotated[int, Range(0, 100)] = 0 # keep the top-K principal components of the features (0 = no PCA)
|
| 256 |
-
distance: Literal["L1", "L2"] = "L1"
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
@contextlib.contextmanager
|
| 260 |
-
def _no_texpr_fuser():
|
| 261 |
-
"""Disable the TensorExpr JIT fuser while IMPACT's TorchScript feature model runs under autograd.
|
| 262 |
-
|
| 263 |
-
The IMPACT feature models are TorchScript; run under FireANTs' gradient optimisation the TensorExpr
|
| 264 |
-
fuser trips on shape ops (``aten::size`` INTERNAL ASSERT). Scoped and restored so no other torch/JIT
|
| 265 |
-
user is affected; the modern profiling executor stays on (this is NOT the legacy executor).
|
| 266 |
-
"""
|
| 267 |
-
torch._C._jit_set_texpr_fuser_enabled(False)
|
| 268 |
-
try:
|
| 269 |
-
yield
|
| 270 |
-
finally:
|
| 271 |
-
torch._C._jit_set_texpr_fuser_enabled(True)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
class _ImpactCore(IMPACTReg):
|
| 275 |
-
"""One IMPACT feature model, exposed as a FireANTs ``forward(moved, fixed)``.
|
| 276 |
-
|
| 277 |
-
Reuses ``IMPACTReg._compute`` / ``preprocessing`` verbatim — the stats-normalised feature extraction
|
| 278 |
-
(the model wants per-image ``[min, mean, max, std]``) and the per-layer weighted distance — so the
|
| 279 |
-
metric is exactly KonfAI's, not a re-derivation. Only KonfAI's config-binding ``__init__`` and its
|
| 280 |
-
``Attribute``-based geometry are replaced: FireANTs passes raw tensors at the current pyramid scale, so
|
| 281 |
-
the intensity statistics are computed from those tensors directly. ``pca`` (absent from KonfAI's torch
|
| 282 |
-
``IMPACTReg``) is added here as a per-layer feature-space reduction matching itk-impact.
|
| 283 |
-
"""
|
| 284 |
-
|
| 285 |
-
def __init__(self, ref: str, in_channels: int, weights: list[float], distance: str, pca: int) -> None:
|
| 286 |
-
from huggingface_hub import hf_hub_download
|
| 287 |
-
|
| 288 |
-
torch.nn.Module.__init__(self) # bypass IMPACTReg.__init__ (KONFAI_CONFIG_PATH / apply_config binding)
|
| 289 |
-
self.name = "Reg"
|
| 290 |
-
self.in_channels = int(in_channels)
|
| 291 |
-
self.weights = [float(w) for w in weights]
|
| 292 |
-
self.nb_layer = len(self.weights)
|
| 293 |
-
self.loss = _DISTANCES[distance]()
|
| 294 |
-
self.pca = int(pca) # PCA lives in KonfAI's IMPACTReg._compute (same behaviour as itk-impact)
|
| 295 |
-
self.dim = DIM
|
| 296 |
-
self.shape = None # score the whole (downsampled) tensor — no ModelPatch tiling
|
| 297 |
-
if ":" in ref: # a "repo:path" HF reference; otherwise a local model file
|
| 298 |
-
repo, filename = ref.split(":", 1)
|
| 299 |
-
self.model_path = hf_hub_download(repo, filename, repo_type="model") # nosec B615
|
| 300 |
-
else:
|
| 301 |
-
self.model_path = ref
|
| 302 |
-
self.model = None # lazy-loaded on the first forward, like IMPACTReg
|
| 303 |
-
|
| 304 |
-
@staticmethod
|
| 305 |
-
def _stats(tensor: torch.Tensor) -> dict:
|
| 306 |
-
detached = tensor.detach()
|
| 307 |
-
return {
|
| 308 |
-
"ImageMin": float(detached.min()),
|
| 309 |
-
"ImageMean": float(detached.mean()),
|
| 310 |
-
"ImageMax": float(detached.max()),
|
| 311 |
-
"ImageStd": float(detached.std()),
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor: # type: ignore[override]
|
| 315 |
-
if self.model is None:
|
| 316 |
-
self.model = torch.jit.load(self.model_path) # nosec B614
|
| 317 |
-
self.model.to(moved.device).eval()
|
| 318 |
-
with _no_texpr_fuser():
|
| 319 |
-
loss, true_nb = self._compute(moved, [self._stats(moved)], fixed, [self._stats(fixed)], None)
|
| 320 |
-
return loss / max(true_nb, 1)
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
class ImpactFeatureLoss(torch.nn.Module):
|
| 324 |
-
"""FireANTs ``custom_loss`` = the KonfAI IMPACT metric fused over several feature models.
|
| 325 |
-
|
| 326 |
-
``forward(moved, fixed)`` sums each model's ``layers_weight * IMPACT(model)``. A model's per-layer
|
| 327 |
-
weights come from its ``layers_mask`` bitmask; its input channel count is read from the registry
|
| 328 |
-
(``models.json`` ``numberofchannels``) so it never has to be configured by hand.
|
| 329 |
-
"""
|
| 330 |
-
|
| 331 |
-
def __init__(self, specs: list["ModelSpec"]) -> None:
|
| 332 |
-
super().__init__()
|
| 333 |
-
registry = load_models_registry()
|
| 334 |
-
self._cores = torch.nn.ModuleList()
|
| 335 |
-
self._model_weights: list[float] = []
|
| 336 |
-
for spec in specs:
|
| 337 |
-
in_channels = int(registry.get(spec.ref.split(":", 1)[-1], {}).get("numberofchannels", 1))
|
| 338 |
-
weights = [1.0 if char == "1" else 0.0 for char in spec.layers_mask]
|
| 339 |
-
self._cores.append(_ImpactCore(spec.ref, in_channels, weights, spec.distance, spec.pca))
|
| 340 |
-
self._model_weights.append(float(spec.layers_weight))
|
| 341 |
-
|
| 342 |
-
def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor:
|
| 343 |
-
total: torch.Tensor | None = None
|
| 344 |
-
for weight, core in zip(self._model_weights, self._cores, strict=True):
|
| 345 |
-
term = weight * core(moved, fixed)
|
| 346 |
-
total = term if total is None else total + term
|
| 347 |
-
return total
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
class FireANTsEngine:
|
| 351 |
-
"""Register a fixed/moving pair with FireANTs (Rigid -> Affine -> [SyN | Greedy | none]); return
|
| 352 |
-
(moved, dvf) on the fixed grid.
|
| 353 |
-
|
| 354 |
-
``fireants`` is imported lazily inside :meth:`register` so this module can be imported for config
|
| 355 |
-
/signature introspection (SlicerImpactReg reads the tuning knobs off the ``RegistrationNet``
|
| 356 |
-
annotations) on a machine without a GPU or without FireANTs installed.
|
| 357 |
-
"""
|
| 358 |
-
|
| 359 |
-
def __init__(
|
| 360 |
-
self,
|
| 361 |
-
scales: list[int],
|
| 362 |
-
affine_iterations: list[int],
|
| 363 |
-
deformable_iterations: list[int],
|
| 364 |
-
cc_kernel: int,
|
| 365 |
-
affine_metric: str,
|
| 366 |
-
affine_lr: float,
|
| 367 |
-
deformable_method: str,
|
| 368 |
-
deformable_metric: str,
|
| 369 |
-
deformable_lr: float,
|
| 370 |
-
integrator_n: int,
|
| 371 |
-
smooth_warp_sigma: float,
|
| 372 |
-
smooth_grad_sigma: float,
|
| 373 |
-
seed: int,
|
| 374 |
-
impact_specs: list["ModelSpec"],
|
| 375 |
-
) -> None:
|
| 376 |
-
self._scales = [int(s) for s in scales]
|
| 377 |
-
self._affine_iterations = [int(i) for i in affine_iterations]
|
| 378 |
-
self._deformable_iterations = [int(i) for i in deformable_iterations]
|
| 379 |
-
self._cc_kernel = int(cc_kernel)
|
| 380 |
-
self._affine_metric = affine_metric
|
| 381 |
-
self._affine_lr = float(affine_lr)
|
| 382 |
-
self._deformable_method = deformable_method
|
| 383 |
-
self._deformable_metric = deformable_metric
|
| 384 |
-
self._deformable_lr = float(deformable_lr)
|
| 385 |
-
self._integrator_n = int(integrator_n)
|
| 386 |
-
self._smooth_warp_sigma = float(smooth_warp_sigma)
|
| 387 |
-
self._smooth_grad_sigma = float(smooth_grad_sigma)
|
| 388 |
-
self._seed = int(seed)
|
| 389 |
-
# IMPACT deformable metric (only used when deformable_metric == "impact"): KonfAI IMPACT feature
|
| 390 |
-
# models drive the SyN/greedy stage instead of the analytic CC/MI/MSE.
|
| 391 |
-
self._impact_specs = impact_specs
|
| 392 |
-
|
| 393 |
-
@staticmethod
|
| 394 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 395 |
-
"""True only for a mask that actually restricts the region — some voxels in, some out. An absent
|
| 396 |
-
optional mask arrives as a whole-image (all-ones) default and an all-zero mask is degenerate; both
|
| 397 |
-
are treated as no mask so the plain (non-masked) metric path is used."""
|
| 398 |
-
if mask is None:
|
| 399 |
-
return False
|
| 400 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 401 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 402 |
-
|
| 403 |
-
@staticmethod
|
| 404 |
-
def _affine_to_sitk(affine_matrix: "torch.Tensor") -> sitk.AffineTransform:
|
| 405 |
-
"""FireANTs' physical (LPS) linear matrix -> SimpleITK AffineTransform (fixed -> moving points),
|
| 406 |
-
the same convention FireANTs writes into an ANTs ``0GenericAffine.mat``."""
|
| 407 |
-
matrix = affine_matrix.float().cpu().numpy()[0]
|
| 408 |
-
affine = sitk.AffineTransform(DIM)
|
| 409 |
-
affine.SetMatrix(matrix[:DIM, :DIM].flatten().astype(np.float64))
|
| 410 |
-
affine.SetTranslation(matrix[:DIM, DIM].astype(np.float64))
|
| 411 |
-
return affine
|
| 412 |
-
|
| 413 |
-
def _total_field_transform(self, reg) -> sitk.Transform:
|
| 414 |
-
"""Optimise a deformable stage and return its TOTAL displacement (affine baked in) as a
|
| 415 |
-
SimpleITK ``DisplacementFieldTransform`` on the fixed grid.
|
| 416 |
-
|
| 417 |
-
FireANTs serialises the total field (ANTs convention, fixed grid) only to a file, so it is
|
| 418 |
-
round-tripped through a temporary NIfTI — its public API, no internals reimplemented."""
|
| 419 |
-
reg.optimize()
|
| 420 |
-
with tempfile.TemporaryDirectory() as tmp:
|
| 421 |
-
warp_path = os.path.join(tmp, "total_warp.nii.gz")
|
| 422 |
-
reg.save_as_ants_transforms(warp_path)
|
| 423 |
-
total_field = sitk.ReadImage(warp_path, sitk.sitkVectorFloat64)
|
| 424 |
-
return sitk.DisplacementFieldTransform(total_field) # consumes total_field
|
| 425 |
-
|
| 426 |
-
def register(
|
| 427 |
-
self,
|
| 428 |
-
fixed: sitk.Image,
|
| 429 |
-
moving: sitk.Image,
|
| 430 |
-
device_index: int,
|
| 431 |
-
fixed_mask: sitk.Image | None = None,
|
| 432 |
-
moving_mask: sitk.Image | None = None,
|
| 433 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 434 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
|
| 435 |
-
ensure_fireants_runtime()
|
| 436 |
-
from fireants.io import BatchedImages, Image
|
| 437 |
-
from fireants.io.imagemask import apply_mask_to_image, generate_image_mask_allones
|
| 438 |
-
from fireants.registration.affine import AffineRegistration
|
| 439 |
-
from fireants.registration.rigid import RigidRegistration
|
| 440 |
-
|
| 441 |
-
torch.manual_seed(self._seed)
|
| 442 |
-
device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
|
| 443 |
-
# FireANTs' Image ctor accepts a SimpleITK image directly, so the fixed/moving cross into
|
| 444 |
-
# FireANTs in-memory (no file load) with their geometry preserved.
|
| 445 |
-
fixed_img = Image(fixed, device=device)
|
| 446 |
-
moving_img = Image(moving, device=device)
|
| 447 |
-
|
| 448 |
-
# Masked metric only when a mask genuinely restricts the region. FireANTs' masked mode wants the
|
| 449 |
-
# mask as the last channel of BOTH images (all-ones where one side has none) and a ``masked_``
|
| 450 |
-
# metric prefix; the plain path is untouched when no real mask is present.
|
| 451 |
-
use_fixed_mask = self._is_partial_mask(fixed_mask)
|
| 452 |
-
use_moving_mask = self._is_partial_mask(moving_mask)
|
| 453 |
-
masked = use_fixed_mask or use_moving_mask
|
| 454 |
-
if masked:
|
| 455 |
-
fmask = Image(fixed_mask, device=device) if use_fixed_mask else generate_image_mask_allones(fixed_img)
|
| 456 |
-
mmask = Image(moving_mask, device=device) if use_moving_mask else generate_image_mask_allones(moving_img)
|
| 457 |
-
fixed_img = apply_mask_to_image(fixed_img, fmask)
|
| 458 |
-
moving_img = apply_mask_to_image(moving_img, mmask)
|
| 459 |
-
|
| 460 |
-
bf = BatchedImages([fixed_img])
|
| 461 |
-
bm = BatchedImages([moving_img])
|
| 462 |
-
affine_loss = f"masked_{self._affine_metric}" if masked else self._affine_metric
|
| 463 |
-
deformable_loss = f"masked_{self._deformable_metric}" if masked else self._deformable_metric
|
| 464 |
-
|
| 465 |
-
# Linear: Rigid(MI, COM init) -> Affine(MI, seeded by the rigid), mirroring ANTs. The affine
|
| 466 |
-
# seeds the deformable stage (or is the whole transform when deformable_method == "none").
|
| 467 |
-
rigid = RigidRegistration(
|
| 468 |
-
scales=self._scales,
|
| 469 |
-
iterations=self._affine_iterations,
|
| 470 |
-
fixed_images=bf,
|
| 471 |
-
moving_images=bm,
|
| 472 |
-
loss_type=affine_loss,
|
| 473 |
-
optimizer="Adam",
|
| 474 |
-
optimizer_lr=self._affine_lr,
|
| 475 |
-
cc_kernel_size=self._cc_kernel,
|
| 476 |
-
init_translation="cof",
|
| 477 |
-
)
|
| 478 |
-
rigid.optimize()
|
| 479 |
-
rigid_matrix = rigid.get_rigid_matrix().detach()
|
| 480 |
-
|
| 481 |
-
affine = AffineRegistration(
|
| 482 |
-
scales=self._scales,
|
| 483 |
-
iterations=self._affine_iterations,
|
| 484 |
-
fixed_images=bf,
|
| 485 |
-
moving_images=bm,
|
| 486 |
-
loss_type=affine_loss,
|
| 487 |
-
optimizer="Adam",
|
| 488 |
-
optimizer_lr=self._affine_lr,
|
| 489 |
-
cc_kernel_size=self._cc_kernel,
|
| 490 |
-
init_rigid=rigid_matrix,
|
| 491 |
-
)
|
| 492 |
-
affine.optimize()
|
| 493 |
-
affine_matrix = affine.get_affine_matrix().detach()
|
| 494 |
-
|
| 495 |
-
# Deformable stage (or none). SyN and Greedy share the same constructor surface; both warm-start
|
| 496 |
-
# from the affine so their TOTAL transform already bakes in the linear pre-align.
|
| 497 |
-
if self._deformable_method == "none":
|
| 498 |
-
transform: sitk.Transform = self._affine_to_sitk(affine_matrix)
|
| 499 |
-
else:
|
| 500 |
-
if self._deformable_method == "syn":
|
| 501 |
-
from fireants.registration.syn import SyNRegistration as Deformable
|
| 502 |
-
elif self._deformable_method == "greedy":
|
| 503 |
-
from fireants.registration.greedy import GreedyRegistration as Deformable
|
| 504 |
-
else:
|
| 505 |
-
raise ValueError(
|
| 506 |
-
f"Unknown deformable_method '{self._deformable_method}' (expected 'syn', 'greedy' or 'none')."
|
| 507 |
-
)
|
| 508 |
-
# "impact" swaps the analytic metric for a KonfAI IMPACT feature loss on the deformable stage
|
| 509 |
-
# (the linear pre-align keeps its own affine_metric); masks do not restrict the IMPACT metric.
|
| 510 |
-
if self._deformable_metric == "impact":
|
| 511 |
-
loss_type: str = "custom"
|
| 512 |
-
custom_loss: torch.nn.Module | None = ImpactFeatureLoss(self._impact_specs)
|
| 513 |
-
else:
|
| 514 |
-
loss_type, custom_loss = deformable_loss, None
|
| 515 |
-
reg = Deformable(
|
| 516 |
-
scales=self._scales,
|
| 517 |
-
iterations=self._deformable_iterations,
|
| 518 |
-
fixed_images=bf,
|
| 519 |
-
moving_images=bm,
|
| 520 |
-
loss_type=loss_type,
|
| 521 |
-
custom_loss=custom_loss,
|
| 522 |
-
cc_kernel_size=self._cc_kernel,
|
| 523 |
-
deformation_type="compositive",
|
| 524 |
-
integrator_n=self._integrator_n,
|
| 525 |
-
smooth_warp_sigma=self._smooth_warp_sigma,
|
| 526 |
-
smooth_grad_sigma=self._smooth_grad_sigma,
|
| 527 |
-
optimizer="Adam",
|
| 528 |
-
optimizer_lr=self._deformable_lr,
|
| 529 |
-
init_affine=affine_matrix,
|
| 530 |
-
)
|
| 531 |
-
transform = self._total_field_transform(reg)
|
| 532 |
-
|
| 533 |
-
if torch.cuda.is_available():
|
| 534 |
-
torch.cuda.synchronize()
|
| 535 |
-
|
| 536 |
-
# Rebuild moved + DVF from the single transform on the fixed grid — the ConvexAdam output path,
|
| 537 |
-
# so every FireANTs preset emits identical-shaped results.
|
| 538 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 539 |
-
dvf = sitk.TransformToDisplacementField(
|
| 540 |
-
transform,
|
| 541 |
-
sitk.sitkVectorFloat64,
|
| 542 |
-
fixed.GetSize(),
|
| 543 |
-
fixed.GetOrigin(),
|
| 544 |
-
fixed.GetSpacing(),
|
| 545 |
-
fixed.GetDirection(),
|
| 546 |
-
)
|
| 547 |
-
moved_np, _ = image_to_data(moved)
|
| 548 |
-
dvf_np, _ = image_to_data(dvf)
|
| 549 |
-
return moved_np, dvf_np
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
class FireANTsRegistration(torch.nn.Module):
|
| 553 |
-
"""Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
|
| 554 |
-
|
| 555 |
-
``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list
|
| 556 |
-
alongside the tensors (same convention as the ConvexAdam / elastix engines); registration needs the
|
| 557 |
-
physical geometry, and the mask branches restrict the metric.
|
| 558 |
-
"""
|
| 559 |
-
|
| 560 |
-
accepts_attributes = True
|
| 561 |
-
|
| 562 |
-
def __init__(self, engine: FireANTsEngine) -> None:
|
| 563 |
-
super().__init__()
|
| 564 |
-
self._engine = engine
|
| 565 |
-
|
| 566 |
-
def forward(
|
| 567 |
-
self,
|
| 568 |
-
fixed: torch.Tensor,
|
| 569 |
-
moving: torch.Tensor,
|
| 570 |
-
fixed_mask: torch.Tensor,
|
| 571 |
-
moving_mask: torch.Tensor,
|
| 572 |
-
attributes: list[list[Attribute]],
|
| 573 |
-
) -> torch.Tensor:
|
| 574 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over
|
| 575 |
-
# the batch. Returns, per sample, the moved image (1 channel) channel-stacked with the
|
| 576 |
-
# displacement field (DIM channels); downstream ChannelSelect modules split them. A whole-image
|
| 577 |
-
# mask (the default when none is supplied) restricts nothing.
|
| 578 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 579 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 580 |
-
combined = []
|
| 581 |
-
# FireANTs runs a gradient-based instance optimisation (Riemannian Adam over the warp); the
|
| 582 |
-
# predictor calls forward under torch.inference_mode(), which forbids autograd. The image tensors
|
| 583 |
-
# have already crossed to numpy/SimpleITK here, so re-enable grad for the optimisation.
|
| 584 |
-
with torch.inference_mode(False), torch.enable_grad():
|
| 585 |
-
for b in range(fixed.shape[0]):
|
| 586 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 587 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 588 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 589 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 590 |
-
moved_np, dvf_np = self._engine.register(
|
| 591 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 592 |
-
)
|
| 593 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 594 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
class ChannelSelect(torch.nn.Module):
|
| 598 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 599 |
-
|
| 600 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 601 |
-
super().__init__()
|
| 602 |
-
self._start = start
|
| 603 |
-
self._stop = stop
|
| 604 |
-
|
| 605 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 606 |
-
return tensor[:, self._start : self._stop]
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
class RegistrationNet(network.Network):
|
| 610 |
-
"""Pairwise FireANTs registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 611 |
-
fixed mask = 2, moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 612 |
-
|
| 613 |
-
Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 614 |
-
(the DIM-component displacement field, in mm). Geometry is attached by the predictor via
|
| 615 |
-
``same_as_group: Volume_0:Fixed``. The knobs below are read straight from these annotations by the
|
| 616 |
-
UI: ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` a dropdown. ``deformable_method``
|
| 617 |
-
is the knob that specialises this shared model into each FireANTs preset.
|
| 618 |
-
"""
|
| 619 |
-
|
| 620 |
-
def __init__(
|
| 621 |
-
self,
|
| 622 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 623 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 624 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 625 |
-
},
|
| 626 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 627 |
-
scales: list[int] = [4, 2, 1],
|
| 628 |
-
affine_iterations: list[int] = [200, 100, 50],
|
| 629 |
-
deformable_iterations: list[int] = [200, 100, 50],
|
| 630 |
-
cc_kernel: Annotated[int, Range(1, 21)] = 5,
|
| 631 |
-
affine_metric: Literal["mi", "cc", "mse"] = "mi",
|
| 632 |
-
affine_lr: Annotated[float, Range(0.0, 10.0)] = 0.003,
|
| 633 |
-
deformable_method: Literal["none", "syn", "greedy"] = "syn",
|
| 634 |
-
deformable_metric: Literal["cc", "mi", "mse", "impact"] = "cc",
|
| 635 |
-
deformable_lr: Annotated[float, Range(0.0, 10.0)] = 0.25,
|
| 636 |
-
integrator_n: Annotated[int, Range(1, 100)] = 10,
|
| 637 |
-
smooth_warp_sigma: Annotated[float, Range(0.0, 100.0)] = 0.5,
|
| 638 |
-
smooth_grad_sigma: Annotated[float, Range(0.0, 100.0)] = 1.0,
|
| 639 |
-
seed: int = 42,
|
| 640 |
-
models: dict[str, ModelSpec] = {},
|
| 641 |
-
) -> None:
|
| 642 |
-
super().__init__(
|
| 643 |
-
in_channels=1,
|
| 644 |
-
optimizer=optimizer,
|
| 645 |
-
schedulers=schedulers,
|
| 646 |
-
outputs_criterions=outputs_criterions,
|
| 647 |
-
dim=3,
|
| 648 |
-
)
|
| 649 |
-
engine = FireANTsEngine(
|
| 650 |
-
scales,
|
| 651 |
-
affine_iterations,
|
| 652 |
-
deformable_iterations,
|
| 653 |
-
cc_kernel,
|
| 654 |
-
affine_metric,
|
| 655 |
-
affine_lr,
|
| 656 |
-
deformable_method,
|
| 657 |
-
deformable_metric,
|
| 658 |
-
deformable_lr,
|
| 659 |
-
integrator_n,
|
| 660 |
-
smooth_warp_sigma,
|
| 661 |
-
smooth_grad_sigma,
|
| 662 |
-
seed,
|
| 663 |
-
_sorted_specs(models),
|
| 664 |
-
)
|
| 665 |
-
self.add_module(
|
| 666 |
-
"Registration", FireANTsRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
|
| 667 |
-
)
|
| 668 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 669 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
FireANTs_Affine/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
scales:
|
| 6 |
- 4
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.fireants:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
scales:
|
| 6 |
- 4
|
FireANTs_Affine/requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
hydra-core
|
| 2 |
nibabel
|
| 3 |
pandas
|
|
|
|
|
|
| 1 |
hydra-core
|
| 2 |
nibabel
|
| 3 |
pandas
|
| 4 |
+
impact_reg_konfai>=1.6.1
|
FireANTs_IMPACT/Model.py
DELETED
|
@@ -1,669 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
#
|
| 17 |
-
# This wrapper does NOT copy any FireANTs source: it only calls the public FireANTs API of the
|
| 18 |
-
# separately-installed ``fireants`` wheel (PyPI). FireANTs is distributed under the FireANTs License
|
| 19 |
-
# v1.0 and must be cited — see the NOTICE file in this directory for the license, copyright and
|
| 20 |
-
# bibliography that ship with this app.
|
| 21 |
-
|
| 22 |
-
"""FireANTs registration as a self-contained KonfAI model (shared by the FireANTs presets).
|
| 23 |
-
|
| 24 |
-
Same idiomatic ``add_module`` graph and the same output contract as the ConvexAdam preset
|
| 25 |
-
(``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``), so the
|
| 26 |
-
orchestrator / app.json / ensemble / uncertainty are unchanged. The engine chains FireANTs' own
|
| 27 |
-
composable stages (GPU, Riemannian Adam), each seeding the next like ANTs' ``-t`` stages:
|
| 28 |
-
|
| 29 |
-
Rigid (MI, centre-of-mass init) -> Affine (MI, seeded by the rigid) -> deformable
|
| 30 |
-
|
| 31 |
-
The deformable stage is selected by ``deformable_method`` — the ONE knob that specialises this shared
|
| 32 |
-
Model.py into the different presets (exactly as ConvexAdam's shared Model.py is specialised by
|
| 33 |
-
``stages``):
|
| 34 |
-
|
| 35 |
-
"syn" symmetric diffeomorphic SyN (CC) — invertible, higher quality, averages cleanly for ensembling
|
| 36 |
-
"greedy" greedy diffeomorphic (CC) — one-directional, faster / lower VRAM
|
| 37 |
-
"none" linear only — Rigid+Affine, no deformable (the FireANTs_Affine preset)
|
| 38 |
-
|
| 39 |
-
Masks: the optional Fixed/Moving masks restrict the metric to a region. FireANTs implements this by
|
| 40 |
-
carrying the mask as the last image channel and prefixing the metric with ``masked_``; a mask is only
|
| 41 |
-
honoured when it actually restricts (some voxels in, some out), so the common mask-free path is
|
| 42 |
-
unchanged (an absent optional mask arrives as a whole-image default and is treated as no mask).
|
| 43 |
-
|
| 44 |
-
The deformable stages produce the single TOTAL displacement field on the fixed grid (the linear
|
| 45 |
-
pre-align is baked in via ``init_affine``, ANTs convention); ``none`` uses the affine matrix directly.
|
| 46 |
-
``MovedImage`` and the emitted ``DisplacementField`` are rebuilt from that transform with SimpleITK —
|
| 47 |
-
the same output path as the ConvexAdam engine — so all presets/engines are interchangeable in an
|
| 48 |
-
ensemble. FireANTs' output-transform writer only serialises to a file, so the deformable field is
|
| 49 |
-
round-tripped through a temporary NIfTI (no FireANTs internals are reimplemented here).
|
| 50 |
-
|
| 51 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
|
| 52 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 53 |
-
"""
|
| 54 |
-
|
| 55 |
-
import contextlib
|
| 56 |
-
import json
|
| 57 |
-
import os
|
| 58 |
-
import tempfile
|
| 59 |
-
from dataclasses import dataclass
|
| 60 |
-
from pathlib import Path
|
| 61 |
-
from typing import Annotated, Literal
|
| 62 |
-
|
| 63 |
-
import numpy as np
|
| 64 |
-
import SimpleITK as sitk
|
| 65 |
-
import torch
|
| 66 |
-
from konfai.metric.measure import IMPACTReg
|
| 67 |
-
from konfai.network import network
|
| 68 |
-
from konfai.utils.config import Choices, Range
|
| 69 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 70 |
-
|
| 71 |
-
DIM = 3
|
| 72 |
-
|
| 73 |
-
# Feature-model registry (models.json): the available IMPACT feature models, fetched from HF (NOT bundled).
|
| 74 |
-
# Only consulted by the "impact" deformable metric; ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins
|
| 75 |
-
# for dev/offline. Mirrors the ConvexAdam preset so the same 30-model catalogue and picker are shared.
|
| 76 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 77 |
-
|
| 78 |
-
_DISTANCES: dict[str, type[torch.nn.Module]] = {"L1": torch.nn.L1Loss, "L2": torch.nn.MSELoss}
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _fireants_git_ref() -> str:
|
| 82 |
-
"""Best-effort FireANTs git ref whose ``fused_ops`` matches the installed ``fireants``.
|
| 83 |
-
|
| 84 |
-
Overridable with ``FIREANTS_FUSED_OPS_REF``; falls back to ``main`` if the version is unknown.
|
| 85 |
-
"""
|
| 86 |
-
try:
|
| 87 |
-
import importlib.metadata
|
| 88 |
-
|
| 89 |
-
version = importlib.metadata.version("fireants").strip()
|
| 90 |
-
if version:
|
| 91 |
-
return version if version.startswith("v") else f"v{version}"
|
| 92 |
-
except Exception:
|
| 93 |
-
pass
|
| 94 |
-
return "main"
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def ensure_fireants_runtime(build_kernels: bool = True) -> None:
|
| 98 |
-
"""Make the ``fireants`` runtime importable before a preset uses it — best-effort, never fatal.
|
| 99 |
-
|
| 100 |
-
A plain ``pip install fireants`` fails inside a host like 3D Slicer for two reasons, both handled
|
| 101 |
-
here with a clear one-line status at each step:
|
| 102 |
-
|
| 103 |
-
1. **fireants won't install.** It pins ``simpleitk==2.2.1`` (no wheel on modern Python), while the
|
| 104 |
-
host already ships a newer SimpleITK. We install it with ``--no-deps`` so that pin is ignored
|
| 105 |
-
and the host's SimpleITK/torch are reused; its light deps ship in ``requirements.txt``.
|
| 106 |
-
2. **The fused CUDA kernels** (``fireants_fused_ops``) that make registration fast and
|
| 107 |
-
memory-light are OPTIONAL. Without them fireants runs in pure PyTorch (correct, only slower).
|
| 108 |
-
We enable them only when a CUDA compiler (``nvcc``) is present, compiling from the upstream
|
| 109 |
-
FireANTs source at install time — nothing is vendored into this app. No compiler, or a failed
|
| 110 |
-
build, simply falls back to pure PyTorch.
|
| 111 |
-
"""
|
| 112 |
-
import importlib
|
| 113 |
-
import shutil
|
| 114 |
-
import subprocess
|
| 115 |
-
import sys
|
| 116 |
-
|
| 117 |
-
def _log(message: str) -> None:
|
| 118 |
-
print(f"[FireANTs] {message}", flush=True)
|
| 119 |
-
|
| 120 |
-
# 1) fireants itself — install --no-deps to sidestep its unsatisfiable ``simpleitk==2.2.1`` pin.
|
| 121 |
-
try:
|
| 122 |
-
importlib.import_module("fireants")
|
| 123 |
-
except Exception:
|
| 124 |
-
_log("installing fireants (--no-deps, reusing the host's SimpleITK/torch)...")
|
| 125 |
-
try:
|
| 126 |
-
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "--no-deps", "fireants"])
|
| 127 |
-
importlib.invalidate_caches()
|
| 128 |
-
importlib.import_module("fireants")
|
| 129 |
-
_log("fireants installed.")
|
| 130 |
-
except Exception as exc:
|
| 131 |
-
raise RuntimeError(
|
| 132 |
-
"Could not install 'fireants'. Install it manually with:\n"
|
| 133 |
-
f" {sys.executable} -m pip install --no-deps fireants\n"
|
| 134 |
-
"(its hydra-core/nibabel/pandas dependencies ship in this app's requirements.txt).\n"
|
| 135 |
-
f"Original error: {exc}"
|
| 136 |
-
) from exc
|
| 137 |
-
|
| 138 |
-
if not build_kernels or os.environ.get("FIREANTS_SKIP_FUSED_OPS", "").strip().lower() in {"1", "true", "yes"}:
|
| 139 |
-
_log("skipping the fused CUDA kernels -> pure PyTorch (correct, slower and more memory).")
|
| 140 |
-
return
|
| 141 |
-
|
| 142 |
-
# 2) fused CUDA kernels — optional accelerator.
|
| 143 |
-
try:
|
| 144 |
-
importlib.import_module("fireants_fused_ops")
|
| 145 |
-
_log("fused CUDA kernels already available.")
|
| 146 |
-
return
|
| 147 |
-
except Exception:
|
| 148 |
-
pass
|
| 149 |
-
|
| 150 |
-
# 2a) a prebuilt wheel matching this torch's CUDA build, if one is ever published.
|
| 151 |
-
cuda_tag = ""
|
| 152 |
-
try:
|
| 153 |
-
cuda_tag = (torch.version.cuda or "").replace(".", "")
|
| 154 |
-
except Exception:
|
| 155 |
-
pass
|
| 156 |
-
if cuda_tag:
|
| 157 |
-
wheel = f"fireants-fused-ops-cu{cuda_tag}"
|
| 158 |
-
try:
|
| 159 |
-
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", wheel])
|
| 160 |
-
importlib.invalidate_caches()
|
| 161 |
-
importlib.import_module("fireants_fused_ops")
|
| 162 |
-
_log(f"installed prebuilt CUDA kernels ({wheel}).")
|
| 163 |
-
return
|
| 164 |
-
except Exception:
|
| 165 |
-
pass # no matching wheel -> try a local build
|
| 166 |
-
|
| 167 |
-
# 2b) local build is OPT-IN: compiling CUDA kernels is heavy and can exhaust RAM on the user's
|
| 168 |
-
# machine, so it NEVER runs by default. Set FIREANTS_BUILD_KERNELS=1 to enable it (devs with a
|
| 169 |
-
# CUDA toolkit); it then builds ONE file at a time (MAX_JOBS=1) to keep memory bounded. The clean
|
| 170 |
-
# path for end users is a prebuilt wheel (2a) — a local compile also needs Python dev headers
|
| 171 |
-
# (absent from some embedded Pythons, e.g. Slicer) and a CUDA-compatible host compiler.
|
| 172 |
-
if os.environ.get("FIREANTS_BUILD_KERNELS", "").strip().lower() not in ("1", "true", "yes"):
|
| 173 |
-
_log(
|
| 174 |
-
"no prebuilt kernels for this platform -> running FireANTs in pure PyTorch (correct, slower "
|
| 175 |
-
"and heavier). Set FIREANTS_BUILD_KERNELS=1 to compile them locally (needs a CUDA toolkit)."
|
| 176 |
-
)
|
| 177 |
-
return
|
| 178 |
-
cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
|
| 179 |
-
nvcc = shutil.which("nvcc") or (os.path.join(cuda_home, "bin", "nvcc") if cuda_home else None)
|
| 180 |
-
if not nvcc or not os.path.exists(nvcc) or shutil.which("git") is None:
|
| 181 |
-
_log("FIREANTS_BUILD_KERNELS set but no CUDA toolkit (nvcc) / git found -> pure PyTorch.")
|
| 182 |
-
return
|
| 183 |
-
|
| 184 |
-
ref = os.environ.get("FIREANTS_FUSED_OPS_REF") or _fireants_git_ref()
|
| 185 |
-
_log(f"nvcc found ({nvcc}); compiling the FireANTs CUDA kernels one file at a time (ref '{ref}')...")
|
| 186 |
-
import tempfile
|
| 187 |
-
|
| 188 |
-
env = os.environ.copy()
|
| 189 |
-
env["MAX_JOBS"] = "1" # one compile at a time -> bounded RAM (prevents OOM on large hosts)
|
| 190 |
-
env.setdefault("NVCC_APPEND_FLAGS", "-allow-unsupported-compiler") # tolerate a newer host compiler
|
| 191 |
-
tmp = tempfile.mkdtemp(prefix="fireants_fused_ops_")
|
| 192 |
-
try:
|
| 193 |
-
# Shallow clone WITHOUT --recursive: FireANTs' submodules (an SSH-only 'cookbook') are unrelated
|
| 194 |
-
# to fused_ops and would otherwise abort the build with a public-key/permission error.
|
| 195 |
-
subprocess.check_call(
|
| 196 |
-
["git", "clone", "--depth", "1", "--branch", ref, "https://github.com/rohitrango/FireANTs.git", tmp]
|
| 197 |
-
)
|
| 198 |
-
subprocess.check_call(
|
| 199 |
-
[sys.executable, "-m", "pip", "install", "-q", "--no-build-isolation", os.path.join(tmp, "fused_ops")],
|
| 200 |
-
env=env,
|
| 201 |
-
)
|
| 202 |
-
importlib.invalidate_caches()
|
| 203 |
-
importlib.import_module("fireants_fused_ops")
|
| 204 |
-
_log("compiled and installed the fast CUDA kernels.")
|
| 205 |
-
except Exception as exc:
|
| 206 |
-
_log(
|
| 207 |
-
"kernel build failed -> running in pure PyTorch (correct, only speed/memory affected). Set "
|
| 208 |
-
"FIREANTS_FUSED_OPS_REF to a compatible FireANTs tag, or install a prebuilt fireants-fused-ops "
|
| 209 |
-
f"wheel, to enable them. Details: {exc}"
|
| 210 |
-
)
|
| 211 |
-
finally:
|
| 212 |
-
shutil.rmtree(tmp, ignore_errors=True)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
def registry_choices() -> list[str]:
|
| 216 |
-
"""The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry."""
|
| 217 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 218 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 222 |
-
"""Load ``models.json`` (available feature models). ``KONFAI_IMPACT_MODELS_REGISTRY`` (local path) wins
|
| 223 |
-
for dev/offline; otherwise ``ref`` is a ``repo:file`` Hugging Face reference (fetched, not bundled)."""
|
| 224 |
-
from huggingface_hub import hf_hub_download
|
| 225 |
-
|
| 226 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 227 |
-
if local:
|
| 228 |
-
path = Path(local)
|
| 229 |
-
elif ":" in ref:
|
| 230 |
-
repo, filename = ref.split(":", 1)
|
| 231 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 232 |
-
else:
|
| 233 |
-
raise ValueError(
|
| 234 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference — or set "
|
| 235 |
-
"KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 236 |
-
)
|
| 237 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 241 |
-
"""A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
|
| 242 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
@dataclass
|
| 246 |
-
class ModelSpec:
|
| 247 |
-
"""One IMPACT feature model in the deformable metric (several are fused). ``ref`` picks the model; the
|
| 248 |
-
rest are its per-model knobs — the same as the ConvexAdam / elastix ``ModelSpec`` except ``voxel_size``
|
| 249 |
-
(an itk-impact resampling knob) has no meaning for FireANTs' geometry-free torch ``custom_loss`` and is
|
| 250 |
-
intentionally absent."""
|
| 251 |
-
|
| 252 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 253 |
-
layers_mask: str = "01" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
|
| 254 |
-
layers_weight: float = 1.0 # this model's weight in the multi-model fusion
|
| 255 |
-
pca: Annotated[int, Range(0, 100)] = 0 # keep the top-K principal components of the features (0 = no PCA)
|
| 256 |
-
distance: Literal["L1", "L2"] = "L1"
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
@contextlib.contextmanager
|
| 260 |
-
def _no_texpr_fuser():
|
| 261 |
-
"""Disable the TensorExpr JIT fuser while IMPACT's TorchScript feature model runs under autograd.
|
| 262 |
-
|
| 263 |
-
The IMPACT feature models are TorchScript; run under FireANTs' gradient optimisation the TensorExpr
|
| 264 |
-
fuser trips on shape ops (``aten::size`` INTERNAL ASSERT). Scoped and restored so no other torch/JIT
|
| 265 |
-
user is affected; the modern profiling executor stays on (this is NOT the legacy executor).
|
| 266 |
-
"""
|
| 267 |
-
torch._C._jit_set_texpr_fuser_enabled(False)
|
| 268 |
-
try:
|
| 269 |
-
yield
|
| 270 |
-
finally:
|
| 271 |
-
torch._C._jit_set_texpr_fuser_enabled(True)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
class _ImpactCore(IMPACTReg):
|
| 275 |
-
"""One IMPACT feature model, exposed as a FireANTs ``forward(moved, fixed)``.
|
| 276 |
-
|
| 277 |
-
Reuses ``IMPACTReg._compute`` / ``preprocessing`` verbatim — the stats-normalised feature extraction
|
| 278 |
-
(the model wants per-image ``[min, mean, max, std]``) and the per-layer weighted distance — so the
|
| 279 |
-
metric is exactly KonfAI's, not a re-derivation. Only KonfAI's config-binding ``__init__`` and its
|
| 280 |
-
``Attribute``-based geometry are replaced: FireANTs passes raw tensors at the current pyramid scale, so
|
| 281 |
-
the intensity statistics are computed from those tensors directly. ``pca`` (absent from KonfAI's torch
|
| 282 |
-
``IMPACTReg``) is added here as a per-layer feature-space reduction matching itk-impact.
|
| 283 |
-
"""
|
| 284 |
-
|
| 285 |
-
def __init__(self, ref: str, in_channels: int, weights: list[float], distance: str, pca: int) -> None:
|
| 286 |
-
from huggingface_hub import hf_hub_download
|
| 287 |
-
|
| 288 |
-
torch.nn.Module.__init__(self) # bypass IMPACTReg.__init__ (KONFAI_CONFIG_PATH / apply_config binding)
|
| 289 |
-
self.name = "Reg"
|
| 290 |
-
self.in_channels = int(in_channels)
|
| 291 |
-
self.weights = [float(w) for w in weights]
|
| 292 |
-
self.nb_layer = len(self.weights)
|
| 293 |
-
self.loss = _DISTANCES[distance]()
|
| 294 |
-
self.pca = int(pca) # PCA lives in KonfAI's IMPACTReg._compute (same behaviour as itk-impact)
|
| 295 |
-
self.dim = DIM
|
| 296 |
-
self.shape = None # score the whole (downsampled) tensor — no ModelPatch tiling
|
| 297 |
-
if ":" in ref: # a "repo:path" HF reference; otherwise a local model file
|
| 298 |
-
repo, filename = ref.split(":", 1)
|
| 299 |
-
self.model_path = hf_hub_download(repo, filename, repo_type="model") # nosec B615
|
| 300 |
-
else:
|
| 301 |
-
self.model_path = ref
|
| 302 |
-
self.model = None # lazy-loaded on the first forward, like IMPACTReg
|
| 303 |
-
|
| 304 |
-
@staticmethod
|
| 305 |
-
def _stats(tensor: torch.Tensor) -> dict:
|
| 306 |
-
detached = tensor.detach()
|
| 307 |
-
return {
|
| 308 |
-
"ImageMin": float(detached.min()),
|
| 309 |
-
"ImageMean": float(detached.mean()),
|
| 310 |
-
"ImageMax": float(detached.max()),
|
| 311 |
-
"ImageStd": float(detached.std()),
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor: # type: ignore[override]
|
| 315 |
-
if self.model is None:
|
| 316 |
-
self.model = torch.jit.load(self.model_path) # nosec B614
|
| 317 |
-
self.model.to(moved.device).eval()
|
| 318 |
-
with _no_texpr_fuser():
|
| 319 |
-
loss, true_nb = self._compute(moved, [self._stats(moved)], fixed, [self._stats(fixed)], None)
|
| 320 |
-
return loss / max(true_nb, 1)
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
class ImpactFeatureLoss(torch.nn.Module):
|
| 324 |
-
"""FireANTs ``custom_loss`` = the KonfAI IMPACT metric fused over several feature models.
|
| 325 |
-
|
| 326 |
-
``forward(moved, fixed)`` sums each model's ``layers_weight * IMPACT(model)``. A model's per-layer
|
| 327 |
-
weights come from its ``layers_mask`` bitmask; its input channel count is read from the registry
|
| 328 |
-
(``models.json`` ``numberofchannels``) so it never has to be configured by hand.
|
| 329 |
-
"""
|
| 330 |
-
|
| 331 |
-
def __init__(self, specs: list["ModelSpec"]) -> None:
|
| 332 |
-
super().__init__()
|
| 333 |
-
registry = load_models_registry()
|
| 334 |
-
self._cores = torch.nn.ModuleList()
|
| 335 |
-
self._model_weights: list[float] = []
|
| 336 |
-
for spec in specs:
|
| 337 |
-
in_channels = int(registry.get(spec.ref.split(":", 1)[-1], {}).get("numberofchannels", 1))
|
| 338 |
-
weights = [1.0 if char == "1" else 0.0 for char in spec.layers_mask]
|
| 339 |
-
self._cores.append(_ImpactCore(spec.ref, in_channels, weights, spec.distance, spec.pca))
|
| 340 |
-
self._model_weights.append(float(spec.layers_weight))
|
| 341 |
-
|
| 342 |
-
def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor:
|
| 343 |
-
total: torch.Tensor | None = None
|
| 344 |
-
for weight, core in zip(self._model_weights, self._cores, strict=True):
|
| 345 |
-
term = weight * core(moved, fixed)
|
| 346 |
-
total = term if total is None else total + term
|
| 347 |
-
return total
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
class FireANTsEngine:
|
| 351 |
-
"""Register a fixed/moving pair with FireANTs (Rigid -> Affine -> [SyN | Greedy | none]); return
|
| 352 |
-
(moved, dvf) on the fixed grid.
|
| 353 |
-
|
| 354 |
-
``fireants`` is imported lazily inside :meth:`register` so this module can be imported for config
|
| 355 |
-
/signature introspection (SlicerImpactReg reads the tuning knobs off the ``RegistrationNet``
|
| 356 |
-
annotations) on a machine without a GPU or without FireANTs installed.
|
| 357 |
-
"""
|
| 358 |
-
|
| 359 |
-
def __init__(
|
| 360 |
-
self,
|
| 361 |
-
scales: list[int],
|
| 362 |
-
affine_iterations: list[int],
|
| 363 |
-
deformable_iterations: list[int],
|
| 364 |
-
cc_kernel: int,
|
| 365 |
-
affine_metric: str,
|
| 366 |
-
affine_lr: float,
|
| 367 |
-
deformable_method: str,
|
| 368 |
-
deformable_metric: str,
|
| 369 |
-
deformable_lr: float,
|
| 370 |
-
integrator_n: int,
|
| 371 |
-
smooth_warp_sigma: float,
|
| 372 |
-
smooth_grad_sigma: float,
|
| 373 |
-
seed: int,
|
| 374 |
-
impact_specs: list["ModelSpec"],
|
| 375 |
-
) -> None:
|
| 376 |
-
self._scales = [int(s) for s in scales]
|
| 377 |
-
self._affine_iterations = [int(i) for i in affine_iterations]
|
| 378 |
-
self._deformable_iterations = [int(i) for i in deformable_iterations]
|
| 379 |
-
self._cc_kernel = int(cc_kernel)
|
| 380 |
-
self._affine_metric = affine_metric
|
| 381 |
-
self._affine_lr = float(affine_lr)
|
| 382 |
-
self._deformable_method = deformable_method
|
| 383 |
-
self._deformable_metric = deformable_metric
|
| 384 |
-
self._deformable_lr = float(deformable_lr)
|
| 385 |
-
self._integrator_n = int(integrator_n)
|
| 386 |
-
self._smooth_warp_sigma = float(smooth_warp_sigma)
|
| 387 |
-
self._smooth_grad_sigma = float(smooth_grad_sigma)
|
| 388 |
-
self._seed = int(seed)
|
| 389 |
-
# IMPACT deformable metric (only used when deformable_metric == "impact"): KonfAI IMPACT feature
|
| 390 |
-
# models drive the SyN/greedy stage instead of the analytic CC/MI/MSE.
|
| 391 |
-
self._impact_specs = impact_specs
|
| 392 |
-
|
| 393 |
-
@staticmethod
|
| 394 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 395 |
-
"""True only for a mask that actually restricts the region — some voxels in, some out. An absent
|
| 396 |
-
optional mask arrives as a whole-image (all-ones) default and an all-zero mask is degenerate; both
|
| 397 |
-
are treated as no mask so the plain (non-masked) metric path is used."""
|
| 398 |
-
if mask is None:
|
| 399 |
-
return False
|
| 400 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 401 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 402 |
-
|
| 403 |
-
@staticmethod
|
| 404 |
-
def _affine_to_sitk(affine_matrix: "torch.Tensor") -> sitk.AffineTransform:
|
| 405 |
-
"""FireANTs' physical (LPS) linear matrix -> SimpleITK AffineTransform (fixed -> moving points),
|
| 406 |
-
the same convention FireANTs writes into an ANTs ``0GenericAffine.mat``."""
|
| 407 |
-
matrix = affine_matrix.float().cpu().numpy()[0]
|
| 408 |
-
affine = sitk.AffineTransform(DIM)
|
| 409 |
-
affine.SetMatrix(matrix[:DIM, :DIM].flatten().astype(np.float64))
|
| 410 |
-
affine.SetTranslation(matrix[:DIM, DIM].astype(np.float64))
|
| 411 |
-
return affine
|
| 412 |
-
|
| 413 |
-
def _total_field_transform(self, reg) -> sitk.Transform:
|
| 414 |
-
"""Optimise a deformable stage and return its TOTAL displacement (affine baked in) as a
|
| 415 |
-
SimpleITK ``DisplacementFieldTransform`` on the fixed grid.
|
| 416 |
-
|
| 417 |
-
FireANTs serialises the total field (ANTs convention, fixed grid) only to a file, so it is
|
| 418 |
-
round-tripped through a temporary NIfTI — its public API, no internals reimplemented."""
|
| 419 |
-
reg.optimize()
|
| 420 |
-
with tempfile.TemporaryDirectory() as tmp:
|
| 421 |
-
warp_path = os.path.join(tmp, "total_warp.nii.gz")
|
| 422 |
-
reg.save_as_ants_transforms(warp_path)
|
| 423 |
-
total_field = sitk.ReadImage(warp_path, sitk.sitkVectorFloat64)
|
| 424 |
-
return sitk.DisplacementFieldTransform(total_field) # consumes total_field
|
| 425 |
-
|
| 426 |
-
def register(
|
| 427 |
-
self,
|
| 428 |
-
fixed: sitk.Image,
|
| 429 |
-
moving: sitk.Image,
|
| 430 |
-
device_index: int,
|
| 431 |
-
fixed_mask: sitk.Image | None = None,
|
| 432 |
-
moving_mask: sitk.Image | None = None,
|
| 433 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 434 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
|
| 435 |
-
ensure_fireants_runtime()
|
| 436 |
-
from fireants.io import BatchedImages, Image
|
| 437 |
-
from fireants.io.imagemask import apply_mask_to_image, generate_image_mask_allones
|
| 438 |
-
from fireants.registration.affine import AffineRegistration
|
| 439 |
-
from fireants.registration.rigid import RigidRegistration
|
| 440 |
-
|
| 441 |
-
torch.manual_seed(self._seed)
|
| 442 |
-
device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
|
| 443 |
-
# FireANTs' Image ctor accepts a SimpleITK image directly, so the fixed/moving cross into
|
| 444 |
-
# FireANTs in-memory (no file load) with their geometry preserved.
|
| 445 |
-
fixed_img = Image(fixed, device=device)
|
| 446 |
-
moving_img = Image(moving, device=device)
|
| 447 |
-
|
| 448 |
-
# Masked metric only when a mask genuinely restricts the region. FireANTs' masked mode wants the
|
| 449 |
-
# mask as the last channel of BOTH images (all-ones where one side has none) and a ``masked_``
|
| 450 |
-
# metric prefix; the plain path is untouched when no real mask is present.
|
| 451 |
-
use_fixed_mask = self._is_partial_mask(fixed_mask)
|
| 452 |
-
use_moving_mask = self._is_partial_mask(moving_mask)
|
| 453 |
-
masked = use_fixed_mask or use_moving_mask
|
| 454 |
-
if masked:
|
| 455 |
-
fmask = Image(fixed_mask, device=device) if use_fixed_mask else generate_image_mask_allones(fixed_img)
|
| 456 |
-
mmask = Image(moving_mask, device=device) if use_moving_mask else generate_image_mask_allones(moving_img)
|
| 457 |
-
fixed_img = apply_mask_to_image(fixed_img, fmask)
|
| 458 |
-
moving_img = apply_mask_to_image(moving_img, mmask)
|
| 459 |
-
|
| 460 |
-
bf = BatchedImages([fixed_img])
|
| 461 |
-
bm = BatchedImages([moving_img])
|
| 462 |
-
affine_loss = f"masked_{self._affine_metric}" if masked else self._affine_metric
|
| 463 |
-
deformable_loss = f"masked_{self._deformable_metric}" if masked else self._deformable_metric
|
| 464 |
-
|
| 465 |
-
# Linear: Rigid(MI, COM init) -> Affine(MI, seeded by the rigid), mirroring ANTs. The affine
|
| 466 |
-
# seeds the deformable stage (or is the whole transform when deformable_method == "none").
|
| 467 |
-
rigid = RigidRegistration(
|
| 468 |
-
scales=self._scales,
|
| 469 |
-
iterations=self._affine_iterations,
|
| 470 |
-
fixed_images=bf,
|
| 471 |
-
moving_images=bm,
|
| 472 |
-
loss_type=affine_loss,
|
| 473 |
-
optimizer="Adam",
|
| 474 |
-
optimizer_lr=self._affine_lr,
|
| 475 |
-
cc_kernel_size=self._cc_kernel,
|
| 476 |
-
init_translation="cof",
|
| 477 |
-
)
|
| 478 |
-
rigid.optimize()
|
| 479 |
-
rigid_matrix = rigid.get_rigid_matrix().detach()
|
| 480 |
-
|
| 481 |
-
affine = AffineRegistration(
|
| 482 |
-
scales=self._scales,
|
| 483 |
-
iterations=self._affine_iterations,
|
| 484 |
-
fixed_images=bf,
|
| 485 |
-
moving_images=bm,
|
| 486 |
-
loss_type=affine_loss,
|
| 487 |
-
optimizer="Adam",
|
| 488 |
-
optimizer_lr=self._affine_lr,
|
| 489 |
-
cc_kernel_size=self._cc_kernel,
|
| 490 |
-
init_rigid=rigid_matrix,
|
| 491 |
-
)
|
| 492 |
-
affine.optimize()
|
| 493 |
-
affine_matrix = affine.get_affine_matrix().detach()
|
| 494 |
-
|
| 495 |
-
# Deformable stage (or none). SyN and Greedy share the same constructor surface; both warm-start
|
| 496 |
-
# from the affine so their TOTAL transform already bakes in the linear pre-align.
|
| 497 |
-
if self._deformable_method == "none":
|
| 498 |
-
transform: sitk.Transform = self._affine_to_sitk(affine_matrix)
|
| 499 |
-
else:
|
| 500 |
-
if self._deformable_method == "syn":
|
| 501 |
-
from fireants.registration.syn import SyNRegistration as Deformable
|
| 502 |
-
elif self._deformable_method == "greedy":
|
| 503 |
-
from fireants.registration.greedy import GreedyRegistration as Deformable
|
| 504 |
-
else:
|
| 505 |
-
raise ValueError(
|
| 506 |
-
f"Unknown deformable_method '{self._deformable_method}' (expected 'syn', 'greedy' or 'none')."
|
| 507 |
-
)
|
| 508 |
-
# "impact" swaps the analytic metric for a KonfAI IMPACT feature loss on the deformable stage
|
| 509 |
-
# (the linear pre-align keeps its own affine_metric); masks do not restrict the IMPACT metric.
|
| 510 |
-
if self._deformable_metric == "impact":
|
| 511 |
-
loss_type: str = "custom"
|
| 512 |
-
custom_loss: torch.nn.Module | None = ImpactFeatureLoss(self._impact_specs)
|
| 513 |
-
else:
|
| 514 |
-
loss_type, custom_loss = deformable_loss, None
|
| 515 |
-
reg = Deformable(
|
| 516 |
-
scales=self._scales,
|
| 517 |
-
iterations=self._deformable_iterations,
|
| 518 |
-
fixed_images=bf,
|
| 519 |
-
moving_images=bm,
|
| 520 |
-
loss_type=loss_type,
|
| 521 |
-
custom_loss=custom_loss,
|
| 522 |
-
cc_kernel_size=self._cc_kernel,
|
| 523 |
-
deformation_type="compositive",
|
| 524 |
-
integrator_n=self._integrator_n,
|
| 525 |
-
smooth_warp_sigma=self._smooth_warp_sigma,
|
| 526 |
-
smooth_grad_sigma=self._smooth_grad_sigma,
|
| 527 |
-
optimizer="Adam",
|
| 528 |
-
optimizer_lr=self._deformable_lr,
|
| 529 |
-
init_affine=affine_matrix,
|
| 530 |
-
)
|
| 531 |
-
transform = self._total_field_transform(reg)
|
| 532 |
-
|
| 533 |
-
if torch.cuda.is_available():
|
| 534 |
-
torch.cuda.synchronize()
|
| 535 |
-
|
| 536 |
-
# Rebuild moved + DVF from the single transform on the fixed grid — the ConvexAdam output path,
|
| 537 |
-
# so every FireANTs preset emits identical-shaped results.
|
| 538 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 539 |
-
dvf = sitk.TransformToDisplacementField(
|
| 540 |
-
transform,
|
| 541 |
-
sitk.sitkVectorFloat64,
|
| 542 |
-
fixed.GetSize(),
|
| 543 |
-
fixed.GetOrigin(),
|
| 544 |
-
fixed.GetSpacing(),
|
| 545 |
-
fixed.GetDirection(),
|
| 546 |
-
)
|
| 547 |
-
moved_np, _ = image_to_data(moved)
|
| 548 |
-
dvf_np, _ = image_to_data(dvf)
|
| 549 |
-
return moved_np, dvf_np
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
class FireANTsRegistration(torch.nn.Module):
|
| 553 |
-
"""Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
|
| 554 |
-
|
| 555 |
-
``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list
|
| 556 |
-
alongside the tensors (same convention as the ConvexAdam / elastix engines); registration needs the
|
| 557 |
-
physical geometry, and the mask branches restrict the metric.
|
| 558 |
-
"""
|
| 559 |
-
|
| 560 |
-
accepts_attributes = True
|
| 561 |
-
|
| 562 |
-
def __init__(self, engine: FireANTsEngine) -> None:
|
| 563 |
-
super().__init__()
|
| 564 |
-
self._engine = engine
|
| 565 |
-
|
| 566 |
-
def forward(
|
| 567 |
-
self,
|
| 568 |
-
fixed: torch.Tensor,
|
| 569 |
-
moving: torch.Tensor,
|
| 570 |
-
fixed_mask: torch.Tensor,
|
| 571 |
-
moving_mask: torch.Tensor,
|
| 572 |
-
attributes: list[list[Attribute]],
|
| 573 |
-
) -> torch.Tensor:
|
| 574 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over
|
| 575 |
-
# the batch. Returns, per sample, the moved image (1 channel) channel-stacked with the
|
| 576 |
-
# displacement field (DIM channels); downstream ChannelSelect modules split them. A whole-image
|
| 577 |
-
# mask (the default when none is supplied) restricts nothing.
|
| 578 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 579 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 580 |
-
combined = []
|
| 581 |
-
# FireANTs runs a gradient-based instance optimisation (Riemannian Adam over the warp); the
|
| 582 |
-
# predictor calls forward under torch.inference_mode(), which forbids autograd. The image tensors
|
| 583 |
-
# have already crossed to numpy/SimpleITK here, so re-enable grad for the optimisation.
|
| 584 |
-
with torch.inference_mode(False), torch.enable_grad():
|
| 585 |
-
for b in range(fixed.shape[0]):
|
| 586 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 587 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 588 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 589 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 590 |
-
moved_np, dvf_np = self._engine.register(
|
| 591 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 592 |
-
)
|
| 593 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 594 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
class ChannelSelect(torch.nn.Module):
|
| 598 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 599 |
-
|
| 600 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 601 |
-
super().__init__()
|
| 602 |
-
self._start = start
|
| 603 |
-
self._stop = stop
|
| 604 |
-
|
| 605 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 606 |
-
return tensor[:, self._start : self._stop]
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
class RegistrationNet(network.Network):
|
| 610 |
-
"""Pairwise FireANTs registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 611 |
-
fixed mask = 2, moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 612 |
-
|
| 613 |
-
Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 614 |
-
(the DIM-component displacement field, in mm). Geometry is attached by the predictor via
|
| 615 |
-
``same_as_group: Volume_0:Fixed``. The knobs below are read straight from these annotations by the
|
| 616 |
-
UI: ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` a dropdown. ``deformable_method``
|
| 617 |
-
is the knob that specialises this shared model into each FireANTs preset.
|
| 618 |
-
"""
|
| 619 |
-
|
| 620 |
-
def __init__(
|
| 621 |
-
self,
|
| 622 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 623 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 624 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 625 |
-
},
|
| 626 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 627 |
-
scales: list[int] = [4, 2, 1],
|
| 628 |
-
affine_iterations: list[int] = [200, 100, 50],
|
| 629 |
-
deformable_iterations: list[int] = [200, 100, 50],
|
| 630 |
-
cc_kernel: Annotated[int, Range(1, 21)] = 5,
|
| 631 |
-
affine_metric: Literal["mi", "cc", "mse"] = "mi",
|
| 632 |
-
affine_lr: Annotated[float, Range(0.0, 10.0)] = 0.003,
|
| 633 |
-
deformable_method: Literal["none", "syn", "greedy"] = "syn",
|
| 634 |
-
deformable_metric: Literal["cc", "mi", "mse", "impact"] = "cc",
|
| 635 |
-
deformable_lr: Annotated[float, Range(0.0, 10.0)] = 0.25,
|
| 636 |
-
integrator_n: Annotated[int, Range(1, 100)] = 10,
|
| 637 |
-
smooth_warp_sigma: Annotated[float, Range(0.0, 100.0)] = 0.5,
|
| 638 |
-
smooth_grad_sigma: Annotated[float, Range(0.0, 100.0)] = 1.0,
|
| 639 |
-
seed: int = 42,
|
| 640 |
-
models: dict[str, ModelSpec] = {},
|
| 641 |
-
) -> None:
|
| 642 |
-
super().__init__(
|
| 643 |
-
in_channels=1,
|
| 644 |
-
optimizer=optimizer,
|
| 645 |
-
schedulers=schedulers,
|
| 646 |
-
outputs_criterions=outputs_criterions,
|
| 647 |
-
dim=3,
|
| 648 |
-
)
|
| 649 |
-
engine = FireANTsEngine(
|
| 650 |
-
scales,
|
| 651 |
-
affine_iterations,
|
| 652 |
-
deformable_iterations,
|
| 653 |
-
cc_kernel,
|
| 654 |
-
affine_metric,
|
| 655 |
-
affine_lr,
|
| 656 |
-
deformable_method,
|
| 657 |
-
deformable_metric,
|
| 658 |
-
deformable_lr,
|
| 659 |
-
integrator_n,
|
| 660 |
-
smooth_warp_sigma,
|
| 661 |
-
smooth_grad_sigma,
|
| 662 |
-
seed,
|
| 663 |
-
_sorted_specs(models),
|
| 664 |
-
)
|
| 665 |
-
self.add_module(
|
| 666 |
-
"Registration", FireANTsRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
|
| 667 |
-
)
|
| 668 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 669 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
FireANTs_IMPACT/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
scales:
|
| 6 |
- 4
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.fireants:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
scales:
|
| 6 |
- 4
|
FireANTs_IMPACT/requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
hydra-core
|
| 2 |
nibabel
|
| 3 |
pandas
|
|
|
|
|
|
| 1 |
hydra-core
|
| 2 |
nibabel
|
| 3 |
pandas
|
| 4 |
+
impact_reg_konfai>=1.6.1
|
FireANTs_SyN/Model.py
DELETED
|
@@ -1,669 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
#
|
| 17 |
-
# This wrapper does NOT copy any FireANTs source: it only calls the public FireANTs API of the
|
| 18 |
-
# separately-installed ``fireants`` wheel (PyPI). FireANTs is distributed under the FireANTs License
|
| 19 |
-
# v1.0 and must be cited — see the NOTICE file in this directory for the license, copyright and
|
| 20 |
-
# bibliography that ship with this app.
|
| 21 |
-
|
| 22 |
-
"""FireANTs registration as a self-contained KonfAI model (shared by the FireANTs presets).
|
| 23 |
-
|
| 24 |
-
Same idiomatic ``add_module`` graph and the same output contract as the ConvexAdam preset
|
| 25 |
-
(``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``), so the
|
| 26 |
-
orchestrator / app.json / ensemble / uncertainty are unchanged. The engine chains FireANTs' own
|
| 27 |
-
composable stages (GPU, Riemannian Adam), each seeding the next like ANTs' ``-t`` stages:
|
| 28 |
-
|
| 29 |
-
Rigid (MI, centre-of-mass init) -> Affine (MI, seeded by the rigid) -> deformable
|
| 30 |
-
|
| 31 |
-
The deformable stage is selected by ``deformable_method`` — the ONE knob that specialises this shared
|
| 32 |
-
Model.py into the different presets (exactly as ConvexAdam's shared Model.py is specialised by
|
| 33 |
-
``stages``):
|
| 34 |
-
|
| 35 |
-
"syn" symmetric diffeomorphic SyN (CC) — invertible, higher quality, averages cleanly for ensembling
|
| 36 |
-
"greedy" greedy diffeomorphic (CC) — one-directional, faster / lower VRAM
|
| 37 |
-
"none" linear only — Rigid+Affine, no deformable (the FireANTs_Affine preset)
|
| 38 |
-
|
| 39 |
-
Masks: the optional Fixed/Moving masks restrict the metric to a region. FireANTs implements this by
|
| 40 |
-
carrying the mask as the last image channel and prefixing the metric with ``masked_``; a mask is only
|
| 41 |
-
honoured when it actually restricts (some voxels in, some out), so the common mask-free path is
|
| 42 |
-
unchanged (an absent optional mask arrives as a whole-image default and is treated as no mask).
|
| 43 |
-
|
| 44 |
-
The deformable stages produce the single TOTAL displacement field on the fixed grid (the linear
|
| 45 |
-
pre-align is baked in via ``init_affine``, ANTs convention); ``none`` uses the affine matrix directly.
|
| 46 |
-
``MovedImage`` and the emitted ``DisplacementField`` are rebuilt from that transform with SimpleITK —
|
| 47 |
-
the same output path as the ConvexAdam engine — so all presets/engines are interchangeable in an
|
| 48 |
-
ensemble. FireANTs' output-transform writer only serialises to a file, so the deformable field is
|
| 49 |
-
round-tripped through a temporary NIfTI (no FireANTs internals are reimplemented here).
|
| 50 |
-
|
| 51 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
|
| 52 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 53 |
-
"""
|
| 54 |
-
|
| 55 |
-
import contextlib
|
| 56 |
-
import json
|
| 57 |
-
import os
|
| 58 |
-
import tempfile
|
| 59 |
-
from dataclasses import dataclass
|
| 60 |
-
from pathlib import Path
|
| 61 |
-
from typing import Annotated, Literal
|
| 62 |
-
|
| 63 |
-
import numpy as np
|
| 64 |
-
import SimpleITK as sitk
|
| 65 |
-
import torch
|
| 66 |
-
from konfai.metric.measure import IMPACTReg
|
| 67 |
-
from konfai.network import network
|
| 68 |
-
from konfai.utils.config import Choices, Range
|
| 69 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 70 |
-
|
| 71 |
-
DIM = 3
|
| 72 |
-
|
| 73 |
-
# Feature-model registry (models.json): the available IMPACT feature models, fetched from HF (NOT bundled).
|
| 74 |
-
# Only consulted by the "impact" deformable metric; ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins
|
| 75 |
-
# for dev/offline. Mirrors the ConvexAdam preset so the same 30-model catalogue and picker are shared.
|
| 76 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 77 |
-
|
| 78 |
-
_DISTANCES: dict[str, type[torch.nn.Module]] = {"L1": torch.nn.L1Loss, "L2": torch.nn.MSELoss}
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _fireants_git_ref() -> str:
|
| 82 |
-
"""Best-effort FireANTs git ref whose ``fused_ops`` matches the installed ``fireants``.
|
| 83 |
-
|
| 84 |
-
Overridable with ``FIREANTS_FUSED_OPS_REF``; falls back to ``main`` if the version is unknown.
|
| 85 |
-
"""
|
| 86 |
-
try:
|
| 87 |
-
import importlib.metadata
|
| 88 |
-
|
| 89 |
-
version = importlib.metadata.version("fireants").strip()
|
| 90 |
-
if version:
|
| 91 |
-
return version if version.startswith("v") else f"v{version}"
|
| 92 |
-
except Exception:
|
| 93 |
-
pass
|
| 94 |
-
return "main"
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def ensure_fireants_runtime(build_kernels: bool = True) -> None:
|
| 98 |
-
"""Make the ``fireants`` runtime importable before a preset uses it — best-effort, never fatal.
|
| 99 |
-
|
| 100 |
-
A plain ``pip install fireants`` fails inside a host like 3D Slicer for two reasons, both handled
|
| 101 |
-
here with a clear one-line status at each step:
|
| 102 |
-
|
| 103 |
-
1. **fireants won't install.** It pins ``simpleitk==2.2.1`` (no wheel on modern Python), while the
|
| 104 |
-
host already ships a newer SimpleITK. We install it with ``--no-deps`` so that pin is ignored
|
| 105 |
-
and the host's SimpleITK/torch are reused; its light deps ship in ``requirements.txt``.
|
| 106 |
-
2. **The fused CUDA kernels** (``fireants_fused_ops``) that make registration fast and
|
| 107 |
-
memory-light are OPTIONAL. Without them fireants runs in pure PyTorch (correct, only slower).
|
| 108 |
-
We enable them only when a CUDA compiler (``nvcc``) is present, compiling from the upstream
|
| 109 |
-
FireANTs source at install time — nothing is vendored into this app. No compiler, or a failed
|
| 110 |
-
build, simply falls back to pure PyTorch.
|
| 111 |
-
"""
|
| 112 |
-
import importlib
|
| 113 |
-
import shutil
|
| 114 |
-
import subprocess
|
| 115 |
-
import sys
|
| 116 |
-
|
| 117 |
-
def _log(message: str) -> None:
|
| 118 |
-
print(f"[FireANTs] {message}", flush=True)
|
| 119 |
-
|
| 120 |
-
# 1) fireants itself — install --no-deps to sidestep its unsatisfiable ``simpleitk==2.2.1`` pin.
|
| 121 |
-
try:
|
| 122 |
-
importlib.import_module("fireants")
|
| 123 |
-
except Exception:
|
| 124 |
-
_log("installing fireants (--no-deps, reusing the host's SimpleITK/torch)...")
|
| 125 |
-
try:
|
| 126 |
-
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "--no-deps", "fireants"])
|
| 127 |
-
importlib.invalidate_caches()
|
| 128 |
-
importlib.import_module("fireants")
|
| 129 |
-
_log("fireants installed.")
|
| 130 |
-
except Exception as exc:
|
| 131 |
-
raise RuntimeError(
|
| 132 |
-
"Could not install 'fireants'. Install it manually with:\n"
|
| 133 |
-
f" {sys.executable} -m pip install --no-deps fireants\n"
|
| 134 |
-
"(its hydra-core/nibabel/pandas dependencies ship in this app's requirements.txt).\n"
|
| 135 |
-
f"Original error: {exc}"
|
| 136 |
-
) from exc
|
| 137 |
-
|
| 138 |
-
if not build_kernels or os.environ.get("FIREANTS_SKIP_FUSED_OPS", "").strip().lower() in {"1", "true", "yes"}:
|
| 139 |
-
_log("skipping the fused CUDA kernels -> pure PyTorch (correct, slower and more memory).")
|
| 140 |
-
return
|
| 141 |
-
|
| 142 |
-
# 2) fused CUDA kernels — optional accelerator.
|
| 143 |
-
try:
|
| 144 |
-
importlib.import_module("fireants_fused_ops")
|
| 145 |
-
_log("fused CUDA kernels already available.")
|
| 146 |
-
return
|
| 147 |
-
except Exception:
|
| 148 |
-
pass
|
| 149 |
-
|
| 150 |
-
# 2a) a prebuilt wheel matching this torch's CUDA build, if one is ever published.
|
| 151 |
-
cuda_tag = ""
|
| 152 |
-
try:
|
| 153 |
-
cuda_tag = (torch.version.cuda or "").replace(".", "")
|
| 154 |
-
except Exception:
|
| 155 |
-
pass
|
| 156 |
-
if cuda_tag:
|
| 157 |
-
wheel = f"fireants-fused-ops-cu{cuda_tag}"
|
| 158 |
-
try:
|
| 159 |
-
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", wheel])
|
| 160 |
-
importlib.invalidate_caches()
|
| 161 |
-
importlib.import_module("fireants_fused_ops")
|
| 162 |
-
_log(f"installed prebuilt CUDA kernels ({wheel}).")
|
| 163 |
-
return
|
| 164 |
-
except Exception:
|
| 165 |
-
pass # no matching wheel -> try a local build
|
| 166 |
-
|
| 167 |
-
# 2b) local build is OPT-IN: compiling CUDA kernels is heavy and can exhaust RAM on the user's
|
| 168 |
-
# machine, so it NEVER runs by default. Set FIREANTS_BUILD_KERNELS=1 to enable it (devs with a
|
| 169 |
-
# CUDA toolkit); it then builds ONE file at a time (MAX_JOBS=1) to keep memory bounded. The clean
|
| 170 |
-
# path for end users is a prebuilt wheel (2a) — a local compile also needs Python dev headers
|
| 171 |
-
# (absent from some embedded Pythons, e.g. Slicer) and a CUDA-compatible host compiler.
|
| 172 |
-
if os.environ.get("FIREANTS_BUILD_KERNELS", "").strip().lower() not in ("1", "true", "yes"):
|
| 173 |
-
_log(
|
| 174 |
-
"no prebuilt kernels for this platform -> running FireANTs in pure PyTorch (correct, slower "
|
| 175 |
-
"and heavier). Set FIREANTS_BUILD_KERNELS=1 to compile them locally (needs a CUDA toolkit)."
|
| 176 |
-
)
|
| 177 |
-
return
|
| 178 |
-
cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
|
| 179 |
-
nvcc = shutil.which("nvcc") or (os.path.join(cuda_home, "bin", "nvcc") if cuda_home else None)
|
| 180 |
-
if not nvcc or not os.path.exists(nvcc) or shutil.which("git") is None:
|
| 181 |
-
_log("FIREANTS_BUILD_KERNELS set but no CUDA toolkit (nvcc) / git found -> pure PyTorch.")
|
| 182 |
-
return
|
| 183 |
-
|
| 184 |
-
ref = os.environ.get("FIREANTS_FUSED_OPS_REF") or _fireants_git_ref()
|
| 185 |
-
_log(f"nvcc found ({nvcc}); compiling the FireANTs CUDA kernels one file at a time (ref '{ref}')...")
|
| 186 |
-
import tempfile
|
| 187 |
-
|
| 188 |
-
env = os.environ.copy()
|
| 189 |
-
env["MAX_JOBS"] = "1" # one compile at a time -> bounded RAM (prevents OOM on large hosts)
|
| 190 |
-
env.setdefault("NVCC_APPEND_FLAGS", "-allow-unsupported-compiler") # tolerate a newer host compiler
|
| 191 |
-
tmp = tempfile.mkdtemp(prefix="fireants_fused_ops_")
|
| 192 |
-
try:
|
| 193 |
-
# Shallow clone WITHOUT --recursive: FireANTs' submodules (an SSH-only 'cookbook') are unrelated
|
| 194 |
-
# to fused_ops and would otherwise abort the build with a public-key/permission error.
|
| 195 |
-
subprocess.check_call(
|
| 196 |
-
["git", "clone", "--depth", "1", "--branch", ref, "https://github.com/rohitrango/FireANTs.git", tmp]
|
| 197 |
-
)
|
| 198 |
-
subprocess.check_call(
|
| 199 |
-
[sys.executable, "-m", "pip", "install", "-q", "--no-build-isolation", os.path.join(tmp, "fused_ops")],
|
| 200 |
-
env=env,
|
| 201 |
-
)
|
| 202 |
-
importlib.invalidate_caches()
|
| 203 |
-
importlib.import_module("fireants_fused_ops")
|
| 204 |
-
_log("compiled and installed the fast CUDA kernels.")
|
| 205 |
-
except Exception as exc:
|
| 206 |
-
_log(
|
| 207 |
-
"kernel build failed -> running in pure PyTorch (correct, only speed/memory affected). Set "
|
| 208 |
-
"FIREANTS_FUSED_OPS_REF to a compatible FireANTs tag, or install a prebuilt fireants-fused-ops "
|
| 209 |
-
f"wheel, to enable them. Details: {exc}"
|
| 210 |
-
)
|
| 211 |
-
finally:
|
| 212 |
-
shutil.rmtree(tmp, ignore_errors=True)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
def registry_choices() -> list[str]:
|
| 216 |
-
"""The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry."""
|
| 217 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 218 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 222 |
-
"""Load ``models.json`` (available feature models). ``KONFAI_IMPACT_MODELS_REGISTRY`` (local path) wins
|
| 223 |
-
for dev/offline; otherwise ``ref`` is a ``repo:file`` Hugging Face reference (fetched, not bundled)."""
|
| 224 |
-
from huggingface_hub import hf_hub_download
|
| 225 |
-
|
| 226 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 227 |
-
if local:
|
| 228 |
-
path = Path(local)
|
| 229 |
-
elif ":" in ref:
|
| 230 |
-
repo, filename = ref.split(":", 1)
|
| 231 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 232 |
-
else:
|
| 233 |
-
raise ValueError(
|
| 234 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference — or set "
|
| 235 |
-
"KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 236 |
-
)
|
| 237 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 241 |
-
"""A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
|
| 242 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
@dataclass
|
| 246 |
-
class ModelSpec:
|
| 247 |
-
"""One IMPACT feature model in the deformable metric (several are fused). ``ref`` picks the model; the
|
| 248 |
-
rest are its per-model knobs — the same as the ConvexAdam / elastix ``ModelSpec`` except ``voxel_size``
|
| 249 |
-
(an itk-impact resampling knob) has no meaning for FireANTs' geometry-free torch ``custom_loss`` and is
|
| 250 |
-
intentionally absent."""
|
| 251 |
-
|
| 252 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 253 |
-
layers_mask: str = "01" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
|
| 254 |
-
layers_weight: float = 1.0 # this model's weight in the multi-model fusion
|
| 255 |
-
pca: Annotated[int, Range(0, 100)] = 0 # keep the top-K principal components of the features (0 = no PCA)
|
| 256 |
-
distance: Literal["L1", "L2"] = "L1"
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
@contextlib.contextmanager
|
| 260 |
-
def _no_texpr_fuser():
|
| 261 |
-
"""Disable the TensorExpr JIT fuser while IMPACT's TorchScript feature model runs under autograd.
|
| 262 |
-
|
| 263 |
-
The IMPACT feature models are TorchScript; run under FireANTs' gradient optimisation the TensorExpr
|
| 264 |
-
fuser trips on shape ops (``aten::size`` INTERNAL ASSERT). Scoped and restored so no other torch/JIT
|
| 265 |
-
user is affected; the modern profiling executor stays on (this is NOT the legacy executor).
|
| 266 |
-
"""
|
| 267 |
-
torch._C._jit_set_texpr_fuser_enabled(False)
|
| 268 |
-
try:
|
| 269 |
-
yield
|
| 270 |
-
finally:
|
| 271 |
-
torch._C._jit_set_texpr_fuser_enabled(True)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
class _ImpactCore(IMPACTReg):
|
| 275 |
-
"""One IMPACT feature model, exposed as a FireANTs ``forward(moved, fixed)``.
|
| 276 |
-
|
| 277 |
-
Reuses ``IMPACTReg._compute`` / ``preprocessing`` verbatim — the stats-normalised feature extraction
|
| 278 |
-
(the model wants per-image ``[min, mean, max, std]``) and the per-layer weighted distance — so the
|
| 279 |
-
metric is exactly KonfAI's, not a re-derivation. Only KonfAI's config-binding ``__init__`` and its
|
| 280 |
-
``Attribute``-based geometry are replaced: FireANTs passes raw tensors at the current pyramid scale, so
|
| 281 |
-
the intensity statistics are computed from those tensors directly. ``pca`` (absent from KonfAI's torch
|
| 282 |
-
``IMPACTReg``) is added here as a per-layer feature-space reduction matching itk-impact.
|
| 283 |
-
"""
|
| 284 |
-
|
| 285 |
-
def __init__(self, ref: str, in_channels: int, weights: list[float], distance: str, pca: int) -> None:
|
| 286 |
-
from huggingface_hub import hf_hub_download
|
| 287 |
-
|
| 288 |
-
torch.nn.Module.__init__(self) # bypass IMPACTReg.__init__ (KONFAI_CONFIG_PATH / apply_config binding)
|
| 289 |
-
self.name = "Reg"
|
| 290 |
-
self.in_channels = int(in_channels)
|
| 291 |
-
self.weights = [float(w) for w in weights]
|
| 292 |
-
self.nb_layer = len(self.weights)
|
| 293 |
-
self.loss = _DISTANCES[distance]()
|
| 294 |
-
self.pca = int(pca) # PCA lives in KonfAI's IMPACTReg._compute (same behaviour as itk-impact)
|
| 295 |
-
self.dim = DIM
|
| 296 |
-
self.shape = None # score the whole (downsampled) tensor — no ModelPatch tiling
|
| 297 |
-
if ":" in ref: # a "repo:path" HF reference; otherwise a local model file
|
| 298 |
-
repo, filename = ref.split(":", 1)
|
| 299 |
-
self.model_path = hf_hub_download(repo, filename, repo_type="model") # nosec B615
|
| 300 |
-
else:
|
| 301 |
-
self.model_path = ref
|
| 302 |
-
self.model = None # lazy-loaded on the first forward, like IMPACTReg
|
| 303 |
-
|
| 304 |
-
@staticmethod
|
| 305 |
-
def _stats(tensor: torch.Tensor) -> dict:
|
| 306 |
-
detached = tensor.detach()
|
| 307 |
-
return {
|
| 308 |
-
"ImageMin": float(detached.min()),
|
| 309 |
-
"ImageMean": float(detached.mean()),
|
| 310 |
-
"ImageMax": float(detached.max()),
|
| 311 |
-
"ImageStd": float(detached.std()),
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor: # type: ignore[override]
|
| 315 |
-
if self.model is None:
|
| 316 |
-
self.model = torch.jit.load(self.model_path) # nosec B614
|
| 317 |
-
self.model.to(moved.device).eval()
|
| 318 |
-
with _no_texpr_fuser():
|
| 319 |
-
loss, true_nb = self._compute(moved, [self._stats(moved)], fixed, [self._stats(fixed)], None)
|
| 320 |
-
return loss / max(true_nb, 1)
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
class ImpactFeatureLoss(torch.nn.Module):
|
| 324 |
-
"""FireANTs ``custom_loss`` = the KonfAI IMPACT metric fused over several feature models.
|
| 325 |
-
|
| 326 |
-
``forward(moved, fixed)`` sums each model's ``layers_weight * IMPACT(model)``. A model's per-layer
|
| 327 |
-
weights come from its ``layers_mask`` bitmask; its input channel count is read from the registry
|
| 328 |
-
(``models.json`` ``numberofchannels``) so it never has to be configured by hand.
|
| 329 |
-
"""
|
| 330 |
-
|
| 331 |
-
def __init__(self, specs: list["ModelSpec"]) -> None:
|
| 332 |
-
super().__init__()
|
| 333 |
-
registry = load_models_registry()
|
| 334 |
-
self._cores = torch.nn.ModuleList()
|
| 335 |
-
self._model_weights: list[float] = []
|
| 336 |
-
for spec in specs:
|
| 337 |
-
in_channels = int(registry.get(spec.ref.split(":", 1)[-1], {}).get("numberofchannels", 1))
|
| 338 |
-
weights = [1.0 if char == "1" else 0.0 for char in spec.layers_mask]
|
| 339 |
-
self._cores.append(_ImpactCore(spec.ref, in_channels, weights, spec.distance, spec.pca))
|
| 340 |
-
self._model_weights.append(float(spec.layers_weight))
|
| 341 |
-
|
| 342 |
-
def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor:
|
| 343 |
-
total: torch.Tensor | None = None
|
| 344 |
-
for weight, core in zip(self._model_weights, self._cores, strict=True):
|
| 345 |
-
term = weight * core(moved, fixed)
|
| 346 |
-
total = term if total is None else total + term
|
| 347 |
-
return total
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
class FireANTsEngine:
|
| 351 |
-
"""Register a fixed/moving pair with FireANTs (Rigid -> Affine -> [SyN | Greedy | none]); return
|
| 352 |
-
(moved, dvf) on the fixed grid.
|
| 353 |
-
|
| 354 |
-
``fireants`` is imported lazily inside :meth:`register` so this module can be imported for config
|
| 355 |
-
/signature introspection (SlicerImpactReg reads the tuning knobs off the ``RegistrationNet``
|
| 356 |
-
annotations) on a machine without a GPU or without FireANTs installed.
|
| 357 |
-
"""
|
| 358 |
-
|
| 359 |
-
def __init__(
|
| 360 |
-
self,
|
| 361 |
-
scales: list[int],
|
| 362 |
-
affine_iterations: list[int],
|
| 363 |
-
deformable_iterations: list[int],
|
| 364 |
-
cc_kernel: int,
|
| 365 |
-
affine_metric: str,
|
| 366 |
-
affine_lr: float,
|
| 367 |
-
deformable_method: str,
|
| 368 |
-
deformable_metric: str,
|
| 369 |
-
deformable_lr: float,
|
| 370 |
-
integrator_n: int,
|
| 371 |
-
smooth_warp_sigma: float,
|
| 372 |
-
smooth_grad_sigma: float,
|
| 373 |
-
seed: int,
|
| 374 |
-
impact_specs: list["ModelSpec"],
|
| 375 |
-
) -> None:
|
| 376 |
-
self._scales = [int(s) for s in scales]
|
| 377 |
-
self._affine_iterations = [int(i) for i in affine_iterations]
|
| 378 |
-
self._deformable_iterations = [int(i) for i in deformable_iterations]
|
| 379 |
-
self._cc_kernel = int(cc_kernel)
|
| 380 |
-
self._affine_metric = affine_metric
|
| 381 |
-
self._affine_lr = float(affine_lr)
|
| 382 |
-
self._deformable_method = deformable_method
|
| 383 |
-
self._deformable_metric = deformable_metric
|
| 384 |
-
self._deformable_lr = float(deformable_lr)
|
| 385 |
-
self._integrator_n = int(integrator_n)
|
| 386 |
-
self._smooth_warp_sigma = float(smooth_warp_sigma)
|
| 387 |
-
self._smooth_grad_sigma = float(smooth_grad_sigma)
|
| 388 |
-
self._seed = int(seed)
|
| 389 |
-
# IMPACT deformable metric (only used when deformable_metric == "impact"): KonfAI IMPACT feature
|
| 390 |
-
# models drive the SyN/greedy stage instead of the analytic CC/MI/MSE.
|
| 391 |
-
self._impact_specs = impact_specs
|
| 392 |
-
|
| 393 |
-
@staticmethod
|
| 394 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 395 |
-
"""True only for a mask that actually restricts the region — some voxels in, some out. An absent
|
| 396 |
-
optional mask arrives as a whole-image (all-ones) default and an all-zero mask is degenerate; both
|
| 397 |
-
are treated as no mask so the plain (non-masked) metric path is used."""
|
| 398 |
-
if mask is None:
|
| 399 |
-
return False
|
| 400 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 401 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 402 |
-
|
| 403 |
-
@staticmethod
|
| 404 |
-
def _affine_to_sitk(affine_matrix: "torch.Tensor") -> sitk.AffineTransform:
|
| 405 |
-
"""FireANTs' physical (LPS) linear matrix -> SimpleITK AffineTransform (fixed -> moving points),
|
| 406 |
-
the same convention FireANTs writes into an ANTs ``0GenericAffine.mat``."""
|
| 407 |
-
matrix = affine_matrix.float().cpu().numpy()[0]
|
| 408 |
-
affine = sitk.AffineTransform(DIM)
|
| 409 |
-
affine.SetMatrix(matrix[:DIM, :DIM].flatten().astype(np.float64))
|
| 410 |
-
affine.SetTranslation(matrix[:DIM, DIM].astype(np.float64))
|
| 411 |
-
return affine
|
| 412 |
-
|
| 413 |
-
def _total_field_transform(self, reg) -> sitk.Transform:
|
| 414 |
-
"""Optimise a deformable stage and return its TOTAL displacement (affine baked in) as a
|
| 415 |
-
SimpleITK ``DisplacementFieldTransform`` on the fixed grid.
|
| 416 |
-
|
| 417 |
-
FireANTs serialises the total field (ANTs convention, fixed grid) only to a file, so it is
|
| 418 |
-
round-tripped through a temporary NIfTI — its public API, no internals reimplemented."""
|
| 419 |
-
reg.optimize()
|
| 420 |
-
with tempfile.TemporaryDirectory() as tmp:
|
| 421 |
-
warp_path = os.path.join(tmp, "total_warp.nii.gz")
|
| 422 |
-
reg.save_as_ants_transforms(warp_path)
|
| 423 |
-
total_field = sitk.ReadImage(warp_path, sitk.sitkVectorFloat64)
|
| 424 |
-
return sitk.DisplacementFieldTransform(total_field) # consumes total_field
|
| 425 |
-
|
| 426 |
-
def register(
|
| 427 |
-
self,
|
| 428 |
-
fixed: sitk.Image,
|
| 429 |
-
moving: sitk.Image,
|
| 430 |
-
device_index: int,
|
| 431 |
-
fixed_mask: sitk.Image | None = None,
|
| 432 |
-
moving_mask: sitk.Image | None = None,
|
| 433 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 434 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
|
| 435 |
-
ensure_fireants_runtime()
|
| 436 |
-
from fireants.io import BatchedImages, Image
|
| 437 |
-
from fireants.io.imagemask import apply_mask_to_image, generate_image_mask_allones
|
| 438 |
-
from fireants.registration.affine import AffineRegistration
|
| 439 |
-
from fireants.registration.rigid import RigidRegistration
|
| 440 |
-
|
| 441 |
-
torch.manual_seed(self._seed)
|
| 442 |
-
device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
|
| 443 |
-
# FireANTs' Image ctor accepts a SimpleITK image directly, so the fixed/moving cross into
|
| 444 |
-
# FireANTs in-memory (no file load) with their geometry preserved.
|
| 445 |
-
fixed_img = Image(fixed, device=device)
|
| 446 |
-
moving_img = Image(moving, device=device)
|
| 447 |
-
|
| 448 |
-
# Masked metric only when a mask genuinely restricts the region. FireANTs' masked mode wants the
|
| 449 |
-
# mask as the last channel of BOTH images (all-ones where one side has none) and a ``masked_``
|
| 450 |
-
# metric prefix; the plain path is untouched when no real mask is present.
|
| 451 |
-
use_fixed_mask = self._is_partial_mask(fixed_mask)
|
| 452 |
-
use_moving_mask = self._is_partial_mask(moving_mask)
|
| 453 |
-
masked = use_fixed_mask or use_moving_mask
|
| 454 |
-
if masked:
|
| 455 |
-
fmask = Image(fixed_mask, device=device) if use_fixed_mask else generate_image_mask_allones(fixed_img)
|
| 456 |
-
mmask = Image(moving_mask, device=device) if use_moving_mask else generate_image_mask_allones(moving_img)
|
| 457 |
-
fixed_img = apply_mask_to_image(fixed_img, fmask)
|
| 458 |
-
moving_img = apply_mask_to_image(moving_img, mmask)
|
| 459 |
-
|
| 460 |
-
bf = BatchedImages([fixed_img])
|
| 461 |
-
bm = BatchedImages([moving_img])
|
| 462 |
-
affine_loss = f"masked_{self._affine_metric}" if masked else self._affine_metric
|
| 463 |
-
deformable_loss = f"masked_{self._deformable_metric}" if masked else self._deformable_metric
|
| 464 |
-
|
| 465 |
-
# Linear: Rigid(MI, COM init) -> Affine(MI, seeded by the rigid), mirroring ANTs. The affine
|
| 466 |
-
# seeds the deformable stage (or is the whole transform when deformable_method == "none").
|
| 467 |
-
rigid = RigidRegistration(
|
| 468 |
-
scales=self._scales,
|
| 469 |
-
iterations=self._affine_iterations,
|
| 470 |
-
fixed_images=bf,
|
| 471 |
-
moving_images=bm,
|
| 472 |
-
loss_type=affine_loss,
|
| 473 |
-
optimizer="Adam",
|
| 474 |
-
optimizer_lr=self._affine_lr,
|
| 475 |
-
cc_kernel_size=self._cc_kernel,
|
| 476 |
-
init_translation="cof",
|
| 477 |
-
)
|
| 478 |
-
rigid.optimize()
|
| 479 |
-
rigid_matrix = rigid.get_rigid_matrix().detach()
|
| 480 |
-
|
| 481 |
-
affine = AffineRegistration(
|
| 482 |
-
scales=self._scales,
|
| 483 |
-
iterations=self._affine_iterations,
|
| 484 |
-
fixed_images=bf,
|
| 485 |
-
moving_images=bm,
|
| 486 |
-
loss_type=affine_loss,
|
| 487 |
-
optimizer="Adam",
|
| 488 |
-
optimizer_lr=self._affine_lr,
|
| 489 |
-
cc_kernel_size=self._cc_kernel,
|
| 490 |
-
init_rigid=rigid_matrix,
|
| 491 |
-
)
|
| 492 |
-
affine.optimize()
|
| 493 |
-
affine_matrix = affine.get_affine_matrix().detach()
|
| 494 |
-
|
| 495 |
-
# Deformable stage (or none). SyN and Greedy share the same constructor surface; both warm-start
|
| 496 |
-
# from the affine so their TOTAL transform already bakes in the linear pre-align.
|
| 497 |
-
if self._deformable_method == "none":
|
| 498 |
-
transform: sitk.Transform = self._affine_to_sitk(affine_matrix)
|
| 499 |
-
else:
|
| 500 |
-
if self._deformable_method == "syn":
|
| 501 |
-
from fireants.registration.syn import SyNRegistration as Deformable
|
| 502 |
-
elif self._deformable_method == "greedy":
|
| 503 |
-
from fireants.registration.greedy import GreedyRegistration as Deformable
|
| 504 |
-
else:
|
| 505 |
-
raise ValueError(
|
| 506 |
-
f"Unknown deformable_method '{self._deformable_method}' (expected 'syn', 'greedy' or 'none')."
|
| 507 |
-
)
|
| 508 |
-
# "impact" swaps the analytic metric for a KonfAI IMPACT feature loss on the deformable stage
|
| 509 |
-
# (the linear pre-align keeps its own affine_metric); masks do not restrict the IMPACT metric.
|
| 510 |
-
if self._deformable_metric == "impact":
|
| 511 |
-
loss_type: str = "custom"
|
| 512 |
-
custom_loss: torch.nn.Module | None = ImpactFeatureLoss(self._impact_specs)
|
| 513 |
-
else:
|
| 514 |
-
loss_type, custom_loss = deformable_loss, None
|
| 515 |
-
reg = Deformable(
|
| 516 |
-
scales=self._scales,
|
| 517 |
-
iterations=self._deformable_iterations,
|
| 518 |
-
fixed_images=bf,
|
| 519 |
-
moving_images=bm,
|
| 520 |
-
loss_type=loss_type,
|
| 521 |
-
custom_loss=custom_loss,
|
| 522 |
-
cc_kernel_size=self._cc_kernel,
|
| 523 |
-
deformation_type="compositive",
|
| 524 |
-
integrator_n=self._integrator_n,
|
| 525 |
-
smooth_warp_sigma=self._smooth_warp_sigma,
|
| 526 |
-
smooth_grad_sigma=self._smooth_grad_sigma,
|
| 527 |
-
optimizer="Adam",
|
| 528 |
-
optimizer_lr=self._deformable_lr,
|
| 529 |
-
init_affine=affine_matrix,
|
| 530 |
-
)
|
| 531 |
-
transform = self._total_field_transform(reg)
|
| 532 |
-
|
| 533 |
-
if torch.cuda.is_available():
|
| 534 |
-
torch.cuda.synchronize()
|
| 535 |
-
|
| 536 |
-
# Rebuild moved + DVF from the single transform on the fixed grid — the ConvexAdam output path,
|
| 537 |
-
# so every FireANTs preset emits identical-shaped results.
|
| 538 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 539 |
-
dvf = sitk.TransformToDisplacementField(
|
| 540 |
-
transform,
|
| 541 |
-
sitk.sitkVectorFloat64,
|
| 542 |
-
fixed.GetSize(),
|
| 543 |
-
fixed.GetOrigin(),
|
| 544 |
-
fixed.GetSpacing(),
|
| 545 |
-
fixed.GetDirection(),
|
| 546 |
-
)
|
| 547 |
-
moved_np, _ = image_to_data(moved)
|
| 548 |
-
dvf_np, _ = image_to_data(dvf)
|
| 549 |
-
return moved_np, dvf_np
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
class FireANTsRegistration(torch.nn.Module):
|
| 553 |
-
"""Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
|
| 554 |
-
|
| 555 |
-
``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list
|
| 556 |
-
alongside the tensors (same convention as the ConvexAdam / elastix engines); registration needs the
|
| 557 |
-
physical geometry, and the mask branches restrict the metric.
|
| 558 |
-
"""
|
| 559 |
-
|
| 560 |
-
accepts_attributes = True
|
| 561 |
-
|
| 562 |
-
def __init__(self, engine: FireANTsEngine) -> None:
|
| 563 |
-
super().__init__()
|
| 564 |
-
self._engine = engine
|
| 565 |
-
|
| 566 |
-
def forward(
|
| 567 |
-
self,
|
| 568 |
-
fixed: torch.Tensor,
|
| 569 |
-
moving: torch.Tensor,
|
| 570 |
-
fixed_mask: torch.Tensor,
|
| 571 |
-
moving_mask: torch.Tensor,
|
| 572 |
-
attributes: list[list[Attribute]],
|
| 573 |
-
) -> torch.Tensor:
|
| 574 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over
|
| 575 |
-
# the batch. Returns, per sample, the moved image (1 channel) channel-stacked with the
|
| 576 |
-
# displacement field (DIM channels); downstream ChannelSelect modules split them. A whole-image
|
| 577 |
-
# mask (the default when none is supplied) restricts nothing.
|
| 578 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 579 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 580 |
-
combined = []
|
| 581 |
-
# FireANTs runs a gradient-based instance optimisation (Riemannian Adam over the warp); the
|
| 582 |
-
# predictor calls forward under torch.inference_mode(), which forbids autograd. The image tensors
|
| 583 |
-
# have already crossed to numpy/SimpleITK here, so re-enable grad for the optimisation.
|
| 584 |
-
with torch.inference_mode(False), torch.enable_grad():
|
| 585 |
-
for b in range(fixed.shape[0]):
|
| 586 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 587 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 588 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 589 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 590 |
-
moved_np, dvf_np = self._engine.register(
|
| 591 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 592 |
-
)
|
| 593 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 594 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
class ChannelSelect(torch.nn.Module):
|
| 598 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 599 |
-
|
| 600 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 601 |
-
super().__init__()
|
| 602 |
-
self._start = start
|
| 603 |
-
self._stop = stop
|
| 604 |
-
|
| 605 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 606 |
-
return tensor[:, self._start : self._stop]
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
class RegistrationNet(network.Network):
|
| 610 |
-
"""Pairwise FireANTs registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 611 |
-
fixed mask = 2, moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 612 |
-
|
| 613 |
-
Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 614 |
-
(the DIM-component displacement field, in mm). Geometry is attached by the predictor via
|
| 615 |
-
``same_as_group: Volume_0:Fixed``. The knobs below are read straight from these annotations by the
|
| 616 |
-
UI: ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` a dropdown. ``deformable_method``
|
| 617 |
-
is the knob that specialises this shared model into each FireANTs preset.
|
| 618 |
-
"""
|
| 619 |
-
|
| 620 |
-
def __init__(
|
| 621 |
-
self,
|
| 622 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 623 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 624 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 625 |
-
},
|
| 626 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 627 |
-
scales: list[int] = [4, 2, 1],
|
| 628 |
-
affine_iterations: list[int] = [200, 100, 50],
|
| 629 |
-
deformable_iterations: list[int] = [200, 100, 50],
|
| 630 |
-
cc_kernel: Annotated[int, Range(1, 21)] = 5,
|
| 631 |
-
affine_metric: Literal["mi", "cc", "mse"] = "mi",
|
| 632 |
-
affine_lr: Annotated[float, Range(0.0, 10.0)] = 0.003,
|
| 633 |
-
deformable_method: Literal["none", "syn", "greedy"] = "syn",
|
| 634 |
-
deformable_metric: Literal["cc", "mi", "mse", "impact"] = "cc",
|
| 635 |
-
deformable_lr: Annotated[float, Range(0.0, 10.0)] = 0.25,
|
| 636 |
-
integrator_n: Annotated[int, Range(1, 100)] = 10,
|
| 637 |
-
smooth_warp_sigma: Annotated[float, Range(0.0, 100.0)] = 0.5,
|
| 638 |
-
smooth_grad_sigma: Annotated[float, Range(0.0, 100.0)] = 1.0,
|
| 639 |
-
seed: int = 42,
|
| 640 |
-
models: dict[str, ModelSpec] = {},
|
| 641 |
-
) -> None:
|
| 642 |
-
super().__init__(
|
| 643 |
-
in_channels=1,
|
| 644 |
-
optimizer=optimizer,
|
| 645 |
-
schedulers=schedulers,
|
| 646 |
-
outputs_criterions=outputs_criterions,
|
| 647 |
-
dim=3,
|
| 648 |
-
)
|
| 649 |
-
engine = FireANTsEngine(
|
| 650 |
-
scales,
|
| 651 |
-
affine_iterations,
|
| 652 |
-
deformable_iterations,
|
| 653 |
-
cc_kernel,
|
| 654 |
-
affine_metric,
|
| 655 |
-
affine_lr,
|
| 656 |
-
deformable_method,
|
| 657 |
-
deformable_metric,
|
| 658 |
-
deformable_lr,
|
| 659 |
-
integrator_n,
|
| 660 |
-
smooth_warp_sigma,
|
| 661 |
-
smooth_grad_sigma,
|
| 662 |
-
seed,
|
| 663 |
-
_sorted_specs(models),
|
| 664 |
-
)
|
| 665 |
-
self.add_module(
|
| 666 |
-
"Registration", FireANTsRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
|
| 667 |
-
)
|
| 668 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 669 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
FireANTs_SyN/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
scales:
|
| 6 |
- 4
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.fireants:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
scales:
|
| 6 |
- 4
|
FireANTs_SyN/requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
hydra-core
|
| 2 |
nibabel
|
| 3 |
pandas
|
|
|
|
|
|
| 1 |
hydra-core
|
| 2 |
nibabel
|
| 3 |
pandas
|
| 4 |
+
impact_reg_konfai>=1.6.1
|
Generic_Rigid/Model.py
DELETED
|
@@ -1,301 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
|
| 18 |
-
|
| 19 |
-
``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
|
| 20 |
-
2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
|
| 21 |
-
the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
|
| 22 |
-
the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
|
| 23 |
-
subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
|
| 24 |
-
|
| 25 |
-
A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
|
| 26 |
-
``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
|
| 27 |
-
|
| 28 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
|
| 29 |
-
(``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import os
|
| 34 |
-
import re
|
| 35 |
-
from dataclasses import dataclass, field
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import Annotated, Literal
|
| 38 |
-
|
| 39 |
-
import torch
|
| 40 |
-
from huggingface_hub import hf_hub_download
|
| 41 |
-
from konfai.network import network
|
| 42 |
-
from konfai.utils.config import Choices, Range
|
| 43 |
-
|
| 44 |
-
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 45 |
-
# A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
|
| 46 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
|
| 47 |
-
# iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
|
| 48 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 49 |
-
|
| 50 |
-
# ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
|
| 51 |
-
# Static anyway; in Jacobian we clamp ``l`` to this plateau.
|
| 52 |
-
_FOV_RAMP_MAX_LAYER = 6
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry_choices() -> list[str]:
|
| 56 |
-
"""The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
|
| 57 |
-
(offline-first). A user may still point ``ref`` at a local model."""
|
| 58 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 59 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _num(x: object) -> str:
|
| 63 |
-
"""Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
|
| 64 |
-
return "%g" % float(x)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
@dataclass
|
| 68 |
-
class ModelSpec:
|
| 69 |
-
"""One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
|
| 70 |
-
rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
|
| 71 |
-
(``models.json``) keyed by ``ref`` — never tuned."""
|
| 72 |
-
|
| 73 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 74 |
-
voxel_size: list[float] = field(default_factory=list)
|
| 75 |
-
layers_weight: list[float] = field(default_factory=lambda: [1.0])
|
| 76 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0
|
| 77 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 78 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 79 |
-
layers_mask: str = ""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
@dataclass
|
| 83 |
-
class ResolutionSpec:
|
| 84 |
-
"""One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
|
| 85 |
-
|
| 86 |
-
max_iterations: Annotated[int, Range(1, 100000)]
|
| 87 |
-
models: dict[str, ModelSpec]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 91 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order."""
|
| 92 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 96 |
-
"""Load models.json (the fixed params per model) from the model repo on Hugging Face.
|
| 97 |
-
|
| 98 |
-
The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
|
| 99 |
-
dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
|
| 100 |
-
"""
|
| 101 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 102 |
-
if local:
|
| 103 |
-
path = Path(local)
|
| 104 |
-
elif ":" in ref:
|
| 105 |
-
repo, filename = ref.split(":", 1)
|
| 106 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(
|
| 109 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
|
| 110 |
-
f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 111 |
-
)
|
| 112 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _model_key(ref: str) -> str:
|
| 116 |
-
"""Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
|
| 117 |
-
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _deepest_active_layer(layers_mask: str) -> int:
|
| 121 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
|
| 122 |
-
|
| 123 |
-
A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
|
| 124 |
-
== ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
|
| 125 |
-
receptive field, so the FOV is governed by the rightmost ``'1'``.
|
| 126 |
-
"""
|
| 127 |
-
mask = layers_mask.strip().strip('"')
|
| 128 |
-
active = [i for i, char in enumerate(mask) if char == "1"]
|
| 129 |
-
if not active:
|
| 130 |
-
raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
|
| 131 |
-
return max(active)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 135 |
-
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 136 |
-
|
| 137 |
-
Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
|
| 138 |
-
``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
|
| 139 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
|
| 140 |
-
to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
|
| 141 |
-
a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
|
| 142 |
-
``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
|
| 143 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut.
|
| 144 |
-
"""
|
| 145 |
-
formula = str(fov.get("formula", "")).strip()
|
| 146 |
-
key = re.sub(r"\s+", "", formula).lower()
|
| 147 |
-
if key.isdigit():
|
| 148 |
-
return int(key)
|
| 149 |
-
if key == "2*r*d+1":
|
| 150 |
-
return 2 * int(fov["r"]) * int(fov["d"]) + 1
|
| 151 |
-
if key == "2^l+3":
|
| 152 |
-
return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
|
| 153 |
-
if "global" in key:
|
| 154 |
-
raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
|
| 155 |
-
if fov.get("value") is not None:
|
| 156 |
-
return int(fov["value"])
|
| 157 |
-
raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 161 |
-
"""PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
|
| 162 |
-
image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
|
| 163 |
-
e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
|
| 164 |
-
dim = int(entry.get("dimension", 3))
|
| 165 |
-
if mode.strip().strip('"').lower() != "jacobian":
|
| 166 |
-
return " ".join(["0"] * dim)
|
| 167 |
-
fov = _fov_value(entry.get("fov", {}), layers_mask)
|
| 168 |
-
return " ".join([str(fov)] * dim)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
|
| 172 |
-
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 173 |
-
|
| 174 |
-
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 175 |
-
ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
|
| 176 |
-
is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
|
| 177 |
-
from the registry formula and the cell's ``layers_mask``.
|
| 178 |
-
"""
|
| 179 |
-
res = _sorted_specs(resolutions)
|
| 180 |
-
n = len(res)
|
| 181 |
-
mode_clean = mode.strip().strip('"') or "Static"
|
| 182 |
-
|
| 183 |
-
impact: list[str] = []
|
| 184 |
-
for k, r in enumerate(res):
|
| 185 |
-
models = _sorted_specs(r.models)
|
| 186 |
-
entries = [registry[_model_key(m.ref)] for m in models]
|
| 187 |
-
|
| 188 |
-
def row(stem: str, values: list[str]) -> None:
|
| 189 |
-
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 190 |
-
|
| 191 |
-
# From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
|
| 192 |
-
# model FOV); everything else is a per-model knob taken straight from the cell.
|
| 193 |
-
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 194 |
-
row("Dimension", [e["dimension"] for e in entries])
|
| 195 |
-
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
| 196 |
-
row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
|
| 197 |
-
row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
|
| 198 |
-
row("LayersMask", [f'"{m.layers_mask}"' for m in models])
|
| 199 |
-
row("SubsetFeatures", [str(m.subset_features) for m in models])
|
| 200 |
-
row("PCA", [str(m.pca) for m in models])
|
| 201 |
-
row("Distance", [f'"{m.distance}"' for m in models])
|
| 202 |
-
row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
|
| 203 |
-
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 204 |
-
|
| 205 |
-
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 206 |
-
# (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
|
| 207 |
-
lines = template_text.splitlines()
|
| 208 |
-
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 209 |
-
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
| 210 |
-
block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
|
| 211 |
-
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for i, (m, line) in enumerate(indexed):
|
| 214 |
-
key = m.group(1) if m else None
|
| 215 |
-
if block_lo <= i <= block_hi:
|
| 216 |
-
if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
|
| 217 |
-
out.extend(impact[:-1])
|
| 218 |
-
elif key == "MaximumNumberOfIterations":
|
| 219 |
-
out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
|
| 220 |
-
elif key == "NumberOfResolutions":
|
| 221 |
-
out.append(f"(NumberOfResolutions {n})")
|
| 222 |
-
elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
|
| 223 |
-
out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
|
| 224 |
-
elif key == "ImpactMode":
|
| 225 |
-
out.append(f'(ImpactMode "{mode_clean}")')
|
| 226 |
-
else:
|
| 227 |
-
out.append(line)
|
| 228 |
-
return "\n".join(out)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class ChannelSelect(torch.nn.Module):
|
| 232 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 235 |
-
super().__init__()
|
| 236 |
-
self._start = start
|
| 237 |
-
self._stop = stop
|
| 238 |
-
|
| 239 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 240 |
-
return tensor[:, self._start : self._stop]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
class RegistrationNet(network.Network):
|
| 244 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
|
| 245 |
-
moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 246 |
-
|
| 247 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 248 |
-
(the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
|
| 249 |
-
``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
|
| 250 |
-
``same_as_group: Volume_0:Fixed``.
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
def __init__(
|
| 254 |
-
self,
|
| 255 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 256 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 257 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 258 |
-
},
|
| 259 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 260 |
-
engine: str = "elastix",
|
| 261 |
-
parameter_maps: list[str] = [],
|
| 262 |
-
max_iterations: Annotated[int, Range(0, 100000)] = 0,
|
| 263 |
-
final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
|
| 264 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0,
|
| 265 |
-
spatial_samples: Annotated[int, Range(0, 100000)] = 0,
|
| 266 |
-
parameter_overrides: list[str] = [],
|
| 267 |
-
resolutions: dict[str, ResolutionSpec] = {},
|
| 268 |
-
mode: Literal["Static", "Jacobian"] = "Static",
|
| 269 |
-
) -> None:
|
| 270 |
-
# The registration is fully described by ``resolutions`` (config = source of truth): each resolution
|
| 271 |
-
# lists its self-configured models; the download list is derived from the cells. Global knobs override
|
| 272 |
-
# the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
|
| 273 |
-
# NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
|
| 274 |
-
# preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
|
| 275 |
-
from elastix_engine import ElastixRegistration
|
| 276 |
-
|
| 277 |
-
super().__init__(
|
| 278 |
-
in_channels=1,
|
| 279 |
-
optimizer=optimizer,
|
| 280 |
-
schedulers=schedulers,
|
| 281 |
-
outputs_criterions=outputs_criterions,
|
| 282 |
-
dim=3,
|
| 283 |
-
)
|
| 284 |
-
self.add_module(
|
| 285 |
-
"Registration",
|
| 286 |
-
ElastixRegistration(
|
| 287 |
-
engine,
|
| 288 |
-
parameter_maps,
|
| 289 |
-
max_iterations,
|
| 290 |
-
final_grid_spacing,
|
| 291 |
-
subset_features,
|
| 292 |
-
spatial_samples,
|
| 293 |
-
parameter_overrides,
|
| 294 |
-
resolutions,
|
| 295 |
-
mode,
|
| 296 |
-
),
|
| 297 |
-
in_branch=[0, 1, 2, 3],
|
| 298 |
-
out_branch=["registration"],
|
| 299 |
-
)
|
| 300 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 301 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Generic_Rigid/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.elastix:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
Generic_Rigid/elastix_engine.py
DELETED
|
@@ -1,386 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Elastix-IMPACT runtime for the registration bundle.
|
| 18 |
-
|
| 19 |
-
``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
|
| 20 |
-
parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
|
| 21 |
-
``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
|
| 22 |
-
images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
import os
|
| 26 |
-
import re
|
| 27 |
-
import shutil
|
| 28 |
-
import subprocess # nosec B404
|
| 29 |
-
import tempfile
|
| 30 |
-
from pathlib import Path
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
import SimpleITK as sitk
|
| 34 |
-
import torch
|
| 35 |
-
import tqdm
|
| 36 |
-
from huggingface_hub import hf_hub_download
|
| 37 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 38 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 39 |
-
|
| 40 |
-
from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
|
| 41 |
-
|
| 42 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 43 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 44 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 48 |
-
"""True only for a mask that actually restricts the metric region — some voxels in, some out. An
|
| 49 |
-
absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
|
| 50 |
-
is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
|
| 51 |
-
the whole image) instead of paying for a mask that restricts nothing."""
|
| 52 |
-
if mask is None:
|
| 53 |
-
return False
|
| 54 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 55 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class ElastixEngine:
|
| 59 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 60 |
-
|
| 61 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 62 |
-
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 63 |
-
"""
|
| 64 |
-
|
| 65 |
-
def __init__(
|
| 66 |
-
self,
|
| 67 |
-
parameter_maps: list[str],
|
| 68 |
-
max_iterations: int = 0,
|
| 69 |
-
final_grid_spacing: float = 0.0,
|
| 70 |
-
subset_features: int = 0,
|
| 71 |
-
spatial_samples: int = 0,
|
| 72 |
-
parameter_overrides: list[str] = [],
|
| 73 |
-
resolutions: dict = {},
|
| 74 |
-
mode: str = "Static",
|
| 75 |
-
) -> None:
|
| 76 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 77 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 78 |
-
self._max_iterations = max_iterations
|
| 79 |
-
self._final_grid_spacing = final_grid_spacing
|
| 80 |
-
self._subset_features = subset_features
|
| 81 |
-
self._spatial_samples = spatial_samples
|
| 82 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 83 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 84 |
-
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 85 |
-
self._mode = mode
|
| 86 |
-
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 87 |
-
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 88 |
-
self._resolutions = resolutions
|
| 89 |
-
self._registry = load_models_registry() if resolutions else {}
|
| 90 |
-
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 91 |
-
models: list[str] = []
|
| 92 |
-
for res in _sorted_specs(resolutions):
|
| 93 |
-
for model in _sorted_specs(res.models):
|
| 94 |
-
if model.ref not in models:
|
| 95 |
-
models.append(model.ref)
|
| 96 |
-
self._models = models
|
| 97 |
-
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 98 |
-
self._iterations = self._total_iterations()
|
| 99 |
-
self._elastix_bin = self._ensure_binary()
|
| 100 |
-
self._local_models = self._download_models()
|
| 101 |
-
|
| 102 |
-
def _total_iterations(self) -> int:
|
| 103 |
-
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 104 |
-
if self._resolutions:
|
| 105 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 106 |
-
total = 0
|
| 107 |
-
for src in self._parameter_maps:
|
| 108 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 109 |
-
if match:
|
| 110 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 111 |
-
return total
|
| 112 |
-
|
| 113 |
-
def _ensure_binary(self) -> Path:
|
| 114 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 115 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 116 |
-
if override:
|
| 117 |
-
try_elastix(Path(override))
|
| 118 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 119 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 120 |
-
try:
|
| 121 |
-
try_elastix(ELASTIX_CACHE)
|
| 122 |
-
except Exception:
|
| 123 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 124 |
-
try_elastix(ELASTIX_CACHE)
|
| 125 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 126 |
-
|
| 127 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 128 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 129 |
-
models = []
|
| 130 |
-
for ref in self._models:
|
| 131 |
-
repo, filename = ref.split(":", 1)
|
| 132 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 133 |
-
models.append((filename, local))
|
| 134 |
-
return models
|
| 135 |
-
|
| 136 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 137 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 138 |
-
|
| 139 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 140 |
-
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 141 |
-
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 142 |
-
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 143 |
-
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 144 |
-
"""
|
| 145 |
-
per_token: dict[str, str] = {}
|
| 146 |
-
if not global_only and self._max_iterations > 0:
|
| 147 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 148 |
-
if self._final_grid_spacing > 0:
|
| 149 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 150 |
-
if not global_only and self._subset_features > 0:
|
| 151 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 152 |
-
if self._spatial_samples > 0:
|
| 153 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 154 |
-
exact: list[tuple[str, str]] = []
|
| 155 |
-
for entry in self._parameter_overrides:
|
| 156 |
-
key, sep, value = entry.partition("=")
|
| 157 |
-
if not sep or not key.strip():
|
| 158 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 159 |
-
exact.append((key.strip(), value.strip()))
|
| 160 |
-
return per_token, exact
|
| 161 |
-
|
| 162 |
-
@staticmethod
|
| 163 |
-
def _apply_map_overrides(
|
| 164 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 165 |
-
) -> str:
|
| 166 |
-
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 167 |
-
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 168 |
-
"""
|
| 169 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 170 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 171 |
-
seen: set[str] = set()
|
| 172 |
-
lines = []
|
| 173 |
-
for line in text.splitlines():
|
| 174 |
-
match = entry_pattern.match(line)
|
| 175 |
-
if match:
|
| 176 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 177 |
-
if key == "ImpactGPU":
|
| 178 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 179 |
-
else:
|
| 180 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 181 |
-
if exact_value is not None:
|
| 182 |
-
seen.add(key)
|
| 183 |
-
line = f"{indent}({key} {exact_value})"
|
| 184 |
-
else:
|
| 185 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 186 |
-
if token_key in per_token:
|
| 187 |
-
seen.add(token_key)
|
| 188 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 189 |
-
line = f"{indent}({key} {replaced})"
|
| 190 |
-
lines.append(line)
|
| 191 |
-
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 192 |
-
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 193 |
-
for key in sorted(requested - seen):
|
| 194 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 195 |
-
return "\n".join(lines)
|
| 196 |
-
|
| 197 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 198 |
-
"""Stage the parameter maps into ``work``.
|
| 199 |
-
|
| 200 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 201 |
-
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 202 |
-
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 203 |
-
"""
|
| 204 |
-
staged = []
|
| 205 |
-
for src in self._parameter_maps:
|
| 206 |
-
if self._resolutions:
|
| 207 |
-
text = generate_impact_parameter_map(
|
| 208 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 209 |
-
)
|
| 210 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 211 |
-
else:
|
| 212 |
-
text = src.read_text(encoding="utf-8")
|
| 213 |
-
per_token, exact = self._parameter_map_overrides()
|
| 214 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 215 |
-
dst = work / src.name
|
| 216 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 217 |
-
staged.append(dst)
|
| 218 |
-
return staged
|
| 219 |
-
|
| 220 |
-
def register(
|
| 221 |
-
self,
|
| 222 |
-
fixed: sitk.Image,
|
| 223 |
-
moving: sitk.Image,
|
| 224 |
-
device_index: int,
|
| 225 |
-
fixed_mask: sitk.Image | None = None,
|
| 226 |
-
moving_mask: sitk.Image | None = None,
|
| 227 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 228 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 229 |
-
|
| 230 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 231 |
-
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 232 |
-
"""
|
| 233 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 234 |
-
try:
|
| 235 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 236 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 237 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 238 |
-
|
| 239 |
-
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 240 |
-
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 241 |
-
for rel_name, model_path in self._local_models:
|
| 242 |
-
dst = work / rel_name
|
| 243 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 244 |
-
if not dst.exists():
|
| 245 |
-
dst.symlink_to(model_path)
|
| 246 |
-
|
| 247 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 248 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 249 |
-
if _is_partial_mask(mask):
|
| 250 |
-
mask_path = work / name
|
| 251 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 252 |
-
args += [flag, str(mask_path)]
|
| 253 |
-
args += ["-out", str(work)]
|
| 254 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 255 |
-
args += ["-p", str(pmap)]
|
| 256 |
-
|
| 257 |
-
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 258 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 259 |
-
env = os.environ.copy()
|
| 260 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 261 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 262 |
-
proc = subprocess.Popen( # nosec B603
|
| 263 |
-
args,
|
| 264 |
-
cwd=str(work),
|
| 265 |
-
stdout=subprocess.PIPE,
|
| 266 |
-
stderr=subprocess.STDOUT,
|
| 267 |
-
text=True,
|
| 268 |
-
bufsize=1,
|
| 269 |
-
env=env,
|
| 270 |
-
)
|
| 271 |
-
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 272 |
-
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 273 |
-
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 274 |
-
captured: list[str] = []
|
| 275 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 276 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 277 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 278 |
-
assert proc.stdout is not None
|
| 279 |
-
resolution = 0
|
| 280 |
-
for line in proc.stdout:
|
| 281 |
-
captured.append(line)
|
| 282 |
-
stripped = line.strip()
|
| 283 |
-
if stripped.startswith("Resolution:"):
|
| 284 |
-
try:
|
| 285 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 286 |
-
except ValueError:
|
| 287 |
-
pass
|
| 288 |
-
elif iteration_line.match(line):
|
| 289 |
-
progress.update(1)
|
| 290 |
-
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 291 |
-
if len(columns) > 1:
|
| 292 |
-
try:
|
| 293 |
-
progress.set_description(
|
| 294 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 295 |
-
)
|
| 296 |
-
except ValueError:
|
| 297 |
-
pass
|
| 298 |
-
progress.close()
|
| 299 |
-
returncode = proc.wait()
|
| 300 |
-
if returncode != 0:
|
| 301 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 302 |
-
|
| 303 |
-
transforms = sorted(
|
| 304 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 305 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 306 |
-
)
|
| 307 |
-
if not transforms:
|
| 308 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 309 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 310 |
-
|
| 311 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 312 |
-
dvf = sitk.TransformToDisplacementField(
|
| 313 |
-
transform,
|
| 314 |
-
sitk.sitkVectorFloat64,
|
| 315 |
-
fixed.GetSize(),
|
| 316 |
-
fixed.GetOrigin(),
|
| 317 |
-
fixed.GetSpacing(),
|
| 318 |
-
fixed.GetDirection(),
|
| 319 |
-
)
|
| 320 |
-
moved_np, _ = image_to_data(moved)
|
| 321 |
-
dvf_np, _ = image_to_data(dvf)
|
| 322 |
-
return moved_np, dvf_np
|
| 323 |
-
finally:
|
| 324 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
class ElastixRegistration(torch.nn.Module):
|
| 328 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 329 |
-
|
| 330 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 331 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 332 |
-
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 333 |
-
"""
|
| 334 |
-
|
| 335 |
-
accepts_attributes = True
|
| 336 |
-
|
| 337 |
-
def __init__(
|
| 338 |
-
self,
|
| 339 |
-
engine: str,
|
| 340 |
-
parameter_maps: list[str],
|
| 341 |
-
max_iterations: int = 0,
|
| 342 |
-
final_grid_spacing: float = 0.0,
|
| 343 |
-
subset_features: int = 0,
|
| 344 |
-
spatial_samples: int = 0,
|
| 345 |
-
parameter_overrides: list[str] = [],
|
| 346 |
-
resolutions: dict = {},
|
| 347 |
-
mode: str = "Static",
|
| 348 |
-
) -> None:
|
| 349 |
-
super().__init__()
|
| 350 |
-
if engine != "elastix":
|
| 351 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 352 |
-
self._engine = ElastixEngine(
|
| 353 |
-
parameter_maps,
|
| 354 |
-
max_iterations,
|
| 355 |
-
final_grid_spacing,
|
| 356 |
-
subset_features,
|
| 357 |
-
spatial_samples,
|
| 358 |
-
parameter_overrides,
|
| 359 |
-
resolutions,
|
| 360 |
-
mode,
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
def forward(
|
| 364 |
-
self,
|
| 365 |
-
fixed: torch.Tensor,
|
| 366 |
-
moving: torch.Tensor,
|
| 367 |
-
fixed_mask: torch.Tensor,
|
| 368 |
-
moving_mask: torch.Tensor,
|
| 369 |
-
attributes: list[list[Attribute]],
|
| 370 |
-
) -> torch.Tensor:
|
| 371 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 372 |
-
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 373 |
-
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 374 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 375 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 376 |
-
combined = []
|
| 377 |
-
for b in range(fixed.shape[0]):
|
| 378 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 379 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 380 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 381 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 382 |
-
moved_np, dvf_np = self._engine.register(
|
| 383 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 384 |
-
)
|
| 385 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 386 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Generic_Rigid/install.py
DELETED
|
@@ -1,325 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
import argparse
|
| 18 |
-
import platform
|
| 19 |
-
import re
|
| 20 |
-
import shutil
|
| 21 |
-
import stat
|
| 22 |
-
import subprocess # nosec B404
|
| 23 |
-
import zipfile
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
|
| 26 |
-
import requests
|
| 27 |
-
from tqdm import tqdm
|
| 28 |
-
|
| 29 |
-
# -----------------------------------------------------------------------------
|
| 30 |
-
# Elastix + IMPACT binary assets hosted on GitHub Releases.
|
| 31 |
-
#
|
| 32 |
-
# Key format: (OS, ARCH, FLAVOR)
|
| 33 |
-
# - OS : platform.system() -> "Linux", "Windows", "Darwin"
|
| 34 |
-
# - ARCH : normalized architecture -> "x86_64"
|
| 35 |
-
# - FLAVOR : "cpu" or "cu128"
|
| 36 |
-
#
|
| 37 |
-
# CPU assets are standalone (LibTorch CPU bundled).
|
| 38 |
-
# CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
|
| 39 |
-
# -----------------------------------------------------------------------------
|
| 40 |
-
ELX_ASSET_TEMPLATE = {
|
| 41 |
-
("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
|
| 42 |
-
("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
|
| 43 |
-
("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
|
| 44 |
-
("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
|
| 45 |
-
("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
# -----------------------------------------------------------------------------
|
| 49 |
-
# Official LibTorch downloads (PyTorch).
|
| 50 |
-
#
|
| 51 |
-
# IMPORTANT:
|
| 52 |
-
# - Version MUST match the one used at build time (ABI compatibility).
|
| 53 |
-
# - Using "shared-with-deps" ensures CUDA runtime libraries are included
|
| 54 |
-
# (except the NVIDIA driver, which must be installed system-wide).
|
| 55 |
-
# -----------------------------------------------------------------------------
|
| 56 |
-
|
| 57 |
-
LIBTORCH_URL = {
|
| 58 |
-
(
|
| 59 |
-
"Linux",
|
| 60 |
-
"x86_64",
|
| 61 |
-
"cpu",
|
| 62 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 63 |
-
(
|
| 64 |
-
"Windows",
|
| 65 |
-
"x86_64",
|
| 66 |
-
"cpu",
|
| 67 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 68 |
-
(
|
| 69 |
-
"Darwin",
|
| 70 |
-
"arm64",
|
| 71 |
-
"cpu",
|
| 72 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 73 |
-
(
|
| 74 |
-
"Linux",
|
| 75 |
-
"x86_64",
|
| 76 |
-
"cu128",
|
| 77 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 78 |
-
(
|
| 79 |
-
"Windows",
|
| 80 |
-
"x86_64",
|
| 81 |
-
"cu128",
|
| 82 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
# -----------------------------------------------------------------------------
|
| 86 |
-
# Minimum NVIDIA driver versions required for CUDA 12.8.
|
| 87 |
-
#
|
| 88 |
-
# The CUDA Toolkit itself is NOT required.
|
| 89 |
-
# Only a sufficiently recent NVIDIA driver must be installed.
|
| 90 |
-
# -----------------------------------------------------------------------------
|
| 91 |
-
CUDA128_MIN_DRIVER_LINUX = (570, 26)
|
| 92 |
-
CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
|
| 93 |
-
|
| 94 |
-
GITHUB_OWNER = "vboussot"
|
| 95 |
-
GITHUB_REPO = "ImpactElastix"
|
| 96 |
-
GITHUB_TAG = "1.0.0"
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def run_cmd(cmd: list[str]) -> str:
|
| 103 |
-
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
|
| 107 |
-
"""
|
| 108 |
-
Detect presence of an NVIDIA GPU and extract the driver version.
|
| 109 |
-
Returns:
|
| 110 |
-
(True, (major, minor)) if detected
|
| 111 |
-
(False, None) if nvidia-smi is not available
|
| 112 |
-
"""
|
| 113 |
-
try:
|
| 114 |
-
nvidia_smi = shutil.which("nvidia-smi")
|
| 115 |
-
if nvidia_smi is None:
|
| 116 |
-
raise RuntimeError("nvidia-smi not found in PATH")
|
| 117 |
-
|
| 118 |
-
out = (
|
| 119 |
-
subprocess.check_output(
|
| 120 |
-
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
|
| 121 |
-
) # nosec B603
|
| 122 |
-
.decode("utf-8")
|
| 123 |
-
.strip()
|
| 124 |
-
)
|
| 125 |
-
except Exception:
|
| 126 |
-
return (False, None)
|
| 127 |
-
|
| 128 |
-
# Exemple: "575.64"
|
| 129 |
-
m = re.match(r"([0-9]+)\.([0-9]+)", out)
|
| 130 |
-
if not m:
|
| 131 |
-
return (True, None)
|
| 132 |
-
|
| 133 |
-
return (True, (int(m.group(1)), int(m.group(2))))
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
|
| 137 |
-
"""
|
| 138 |
-
Check whether the detected NVIDIA driver satisfies the minimum
|
| 139 |
-
requirement for CUDA 12.8 on the given operating system.
|
| 140 |
-
"""
|
| 141 |
-
if drv is None:
|
| 142 |
-
return False
|
| 143 |
-
if os_name == "Linux":
|
| 144 |
-
return drv >= CUDA128_MIN_DRIVER_LINUX
|
| 145 |
-
if os_name == "Windows":
|
| 146 |
-
return drv >= CUDA128_MIN_DRIVER_WINDOWS
|
| 147 |
-
return False
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def normalize_arch(machine: str) -> str:
|
| 151 |
-
"""
|
| 152 |
-
Normalize platform.machine() output across operating systems.
|
| 153 |
-
|
| 154 |
-
Examples:
|
| 155 |
-
AMD64 -> x86_64
|
| 156 |
-
aarch64 -> arm64
|
| 157 |
-
"""
|
| 158 |
-
m = machine.lower()
|
| 159 |
-
if m in ("x86_64", "amd64"):
|
| 160 |
-
return "x86_64"
|
| 161 |
-
if m in ("aarch64", "arm64"):
|
| 162 |
-
return "arm64"
|
| 163 |
-
return machine
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def download_file(url: str, dst: Path) -> None:
|
| 167 |
-
"""
|
| 168 |
-
Download a file from the given URL with a progress indicator.
|
| 169 |
-
"""
|
| 170 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
-
print(f"Downloading: {url}", flush=True)
|
| 172 |
-
print(f" to: {dst}", flush=True)
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
with requests.get(url, stream=True, timeout=60) as r:
|
| 176 |
-
r.raise_for_status()
|
| 177 |
-
total = int(r.headers.get("content-length", 0))
|
| 178 |
-
with open(dst, "wb") as f:
|
| 179 |
-
with tqdm(
|
| 180 |
-
total=total,
|
| 181 |
-
unit="B",
|
| 182 |
-
unit_scale=True,
|
| 183 |
-
desc=f"Downloading {dst.name}",
|
| 184 |
-
) as pbar:
|
| 185 |
-
for chunk in r.iter_content(chunk_size=8192):
|
| 186 |
-
f.write(chunk)
|
| 187 |
-
pbar.update(len(chunk))
|
| 188 |
-
except Exception as e:
|
| 189 |
-
raise e
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def extract_archive(archive: Path, dst_dir: Path) -> None:
|
| 193 |
-
"""
|
| 194 |
-
Extract a ZIP archive to the destination directory.
|
| 195 |
-
"""
|
| 196 |
-
dst_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
-
print(f"Extracting: {archive} -> {dst_dir}", flush=True)
|
| 198 |
-
with zipfile.ZipFile(archive, "r") as z:
|
| 199 |
-
z.extractall(dst_dir)
|
| 200 |
-
archive.unlink()
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
|
| 204 |
-
os_name = platform.system()
|
| 205 |
-
arch = normalize_arch(platform.machine())
|
| 206 |
-
has_nvidia, drv = detect_nvidia_driver()
|
| 207 |
-
|
| 208 |
-
if os_name not in ("Linux", "Windows", "Darwin"):
|
| 209 |
-
raise NameError(f"Unsupported OS: {os_name}")
|
| 210 |
-
|
| 211 |
-
if arch not in ("x86_64", "arm64"):
|
| 212 |
-
raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
|
| 213 |
-
|
| 214 |
-
flavor = "cpu"
|
| 215 |
-
if force_cuda:
|
| 216 |
-
if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
|
| 217 |
-
raise NameError(
|
| 218 |
-
"CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
flavor = "cu128"
|
| 222 |
-
elif not force_cpu:
|
| 223 |
-
if has_nvidia and driver_ok_for_cuda(os_name, drv):
|
| 224 |
-
flavor = "cu128"
|
| 225 |
-
else:
|
| 226 |
-
flavor = "cpu"
|
| 227 |
-
|
| 228 |
-
print(f"System: {os_name} {arch}", flush=True)
|
| 229 |
-
print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
|
| 230 |
-
print(f"Selected flavor: {flavor}", flush=True)
|
| 231 |
-
|
| 232 |
-
key = (os_name, arch, flavor)
|
| 233 |
-
if key not in ELX_ASSET_TEMPLATE:
|
| 234 |
-
raise NameError(f"No elastix asset configured for {key}")
|
| 235 |
-
if flavor != "cpu" and key not in LIBTORCH_URL:
|
| 236 |
-
raise NameError(f"No libtorch url configured for {key}")
|
| 237 |
-
|
| 238 |
-
install_path = install_path.resolve()
|
| 239 |
-
install_path.mkdir(parents=True, exist_ok=True)
|
| 240 |
-
|
| 241 |
-
elx_asset = ELX_ASSET_TEMPLATE[key]
|
| 242 |
-
elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
|
| 243 |
-
elx_archive = install_path / elx_asset
|
| 244 |
-
download_file(elx_url, elx_archive)
|
| 245 |
-
extract_archive(elx_archive, install_path)
|
| 246 |
-
|
| 247 |
-
# -------------------------------------------------------------------------
|
| 248 |
-
# ZIP archives may drop executable permissions.
|
| 249 |
-
# Ensure elastix and transformix are executable on Unix platforms.
|
| 250 |
-
# -------------------------------------------------------------------------
|
| 251 |
-
if os_name in ("Linux", "Darwin"):
|
| 252 |
-
for exe in ("elastix", "transformix"):
|
| 253 |
-
p = install_path / "bin" / exe
|
| 254 |
-
if p.exists():
|
| 255 |
-
p.chmod(p.stat().st_mode | stat.S_IEXEC)
|
| 256 |
-
|
| 257 |
-
# ---------------------------------------------------------------------
|
| 258 |
-
# - Download matching LibTorch 12.8 with or without CUDA 12.8
|
| 259 |
-
# - Extract it
|
| 260 |
-
# - Move runtime libraries to a location visible to the dynamic loader
|
| 261 |
-
#
|
| 262 |
-
# Linux / macOS : prefix/lib
|
| 263 |
-
# Windows : prefix (next to elastix.exe)
|
| 264 |
-
# ---------------------------------------------------------------------
|
| 265 |
-
lt_url = LIBTORCH_URL[key]
|
| 266 |
-
lt_archive = install_path / Path(lt_url).name
|
| 267 |
-
download_file(lt_url, lt_archive)
|
| 268 |
-
extract_archive(lt_archive, install_path)
|
| 269 |
-
for p in (install_path / "libtorch" / "lib").iterdir():
|
| 270 |
-
if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
|
| 271 |
-
shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
|
| 272 |
-
|
| 273 |
-
shutil.rmtree(install_path / "libtorch")
|
| 274 |
-
if os_name == "Linux" and flavor == "cu128":
|
| 275 |
-
shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def get_elastix_bin(install_path: Path) -> Path:
|
| 279 |
-
return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def try_elastix(install_path: Path) -> None:
|
| 283 |
-
try:
|
| 284 |
-
subprocess.run(
|
| 285 |
-
[str(get_elastix_bin(install_path)), "-h"],
|
| 286 |
-
capture_output=True,
|
| 287 |
-
text=True,
|
| 288 |
-
check=True,
|
| 289 |
-
) # nosec B603
|
| 290 |
-
except subprocess.CalledProcessError as e:
|
| 291 |
-
msg = "Elastix execution failed.\n\n"
|
| 292 |
-
|
| 293 |
-
msg += f"Command:\n{' '.join(e.cmd)}\n"
|
| 294 |
-
msg += f"Return code: {e.returncode}\n\n"
|
| 295 |
-
|
| 296 |
-
if e.stderr:
|
| 297 |
-
msg += "Error output:\n"
|
| 298 |
-
msg += e.stderr.strip()
|
| 299 |
-
raise NameError(msg)
|
| 300 |
-
|
| 301 |
-
except OSError as e:
|
| 302 |
-
msg = (
|
| 303 |
-
"Elastix could not be started.\n\n"
|
| 304 |
-
"This is usually caused by missing shared libraries "
|
| 305 |
-
"(e.g. LibTorch or CUDA runtime).\n\n"
|
| 306 |
-
f"System error:\n{str(e)}"
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
raise NameError(msg)
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def main() -> None:
|
| 313 |
-
ap = argparse.ArgumentParser()
|
| 314 |
-
ap.add_argument(
|
| 315 |
-
"--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
|
| 316 |
-
)
|
| 317 |
-
group = ap.add_mutually_exclusive_group()
|
| 318 |
-
group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
|
| 319 |
-
group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
|
| 320 |
-
args = vars(ap.parse_args())
|
| 321 |
-
install_elastix_impact(**args)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if __name__ == "__main__":
|
| 325 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Generic_Rigid/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
impact_reg_konfai>=1.6.1
|
Generic_Rigid_BSpline/Model.py
DELETED
|
@@ -1,301 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
|
| 18 |
-
|
| 19 |
-
``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
|
| 20 |
-
2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
|
| 21 |
-
the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
|
| 22 |
-
the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
|
| 23 |
-
subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
|
| 24 |
-
|
| 25 |
-
A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
|
| 26 |
-
``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
|
| 27 |
-
|
| 28 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
|
| 29 |
-
(``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import os
|
| 34 |
-
import re
|
| 35 |
-
from dataclasses import dataclass, field
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import Annotated, Literal
|
| 38 |
-
|
| 39 |
-
import torch
|
| 40 |
-
from huggingface_hub import hf_hub_download
|
| 41 |
-
from konfai.network import network
|
| 42 |
-
from konfai.utils.config import Choices, Range
|
| 43 |
-
|
| 44 |
-
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 45 |
-
# A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
|
| 46 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
|
| 47 |
-
# iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
|
| 48 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 49 |
-
|
| 50 |
-
# ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
|
| 51 |
-
# Static anyway; in Jacobian we clamp ``l`` to this plateau.
|
| 52 |
-
_FOV_RAMP_MAX_LAYER = 6
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry_choices() -> list[str]:
|
| 56 |
-
"""The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
|
| 57 |
-
(offline-first). A user may still point ``ref`` at a local model."""
|
| 58 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 59 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _num(x: object) -> str:
|
| 63 |
-
"""Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
|
| 64 |
-
return "%g" % float(x)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
@dataclass
|
| 68 |
-
class ModelSpec:
|
| 69 |
-
"""One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
|
| 70 |
-
rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
|
| 71 |
-
(``models.json``) keyed by ``ref`` — never tuned."""
|
| 72 |
-
|
| 73 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 74 |
-
voxel_size: list[float] = field(default_factory=list)
|
| 75 |
-
layers_weight: list[float] = field(default_factory=lambda: [1.0])
|
| 76 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0
|
| 77 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 78 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 79 |
-
layers_mask: str = ""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
@dataclass
|
| 83 |
-
class ResolutionSpec:
|
| 84 |
-
"""One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
|
| 85 |
-
|
| 86 |
-
max_iterations: Annotated[int, Range(1, 100000)]
|
| 87 |
-
models: dict[str, ModelSpec]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 91 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order."""
|
| 92 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 96 |
-
"""Load models.json (the fixed params per model) from the model repo on Hugging Face.
|
| 97 |
-
|
| 98 |
-
The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
|
| 99 |
-
dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
|
| 100 |
-
"""
|
| 101 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 102 |
-
if local:
|
| 103 |
-
path = Path(local)
|
| 104 |
-
elif ":" in ref:
|
| 105 |
-
repo, filename = ref.split(":", 1)
|
| 106 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(
|
| 109 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
|
| 110 |
-
f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 111 |
-
)
|
| 112 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _model_key(ref: str) -> str:
|
| 116 |
-
"""Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
|
| 117 |
-
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _deepest_active_layer(layers_mask: str) -> int:
|
| 121 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
|
| 122 |
-
|
| 123 |
-
A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
|
| 124 |
-
== ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
|
| 125 |
-
receptive field, so the FOV is governed by the rightmost ``'1'``.
|
| 126 |
-
"""
|
| 127 |
-
mask = layers_mask.strip().strip('"')
|
| 128 |
-
active = [i for i, char in enumerate(mask) if char == "1"]
|
| 129 |
-
if not active:
|
| 130 |
-
raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
|
| 131 |
-
return max(active)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 135 |
-
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 136 |
-
|
| 137 |
-
Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
|
| 138 |
-
``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
|
| 139 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
|
| 140 |
-
to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
|
| 141 |
-
a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
|
| 142 |
-
``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
|
| 143 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut.
|
| 144 |
-
"""
|
| 145 |
-
formula = str(fov.get("formula", "")).strip()
|
| 146 |
-
key = re.sub(r"\s+", "", formula).lower()
|
| 147 |
-
if key.isdigit():
|
| 148 |
-
return int(key)
|
| 149 |
-
if key == "2*r*d+1":
|
| 150 |
-
return 2 * int(fov["r"]) * int(fov["d"]) + 1
|
| 151 |
-
if key == "2^l+3":
|
| 152 |
-
return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
|
| 153 |
-
if "global" in key:
|
| 154 |
-
raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
|
| 155 |
-
if fov.get("value") is not None:
|
| 156 |
-
return int(fov["value"])
|
| 157 |
-
raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 161 |
-
"""PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
|
| 162 |
-
image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
|
| 163 |
-
e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
|
| 164 |
-
dim = int(entry.get("dimension", 3))
|
| 165 |
-
if mode.strip().strip('"').lower() != "jacobian":
|
| 166 |
-
return " ".join(["0"] * dim)
|
| 167 |
-
fov = _fov_value(entry.get("fov", {}), layers_mask)
|
| 168 |
-
return " ".join([str(fov)] * dim)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
|
| 172 |
-
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 173 |
-
|
| 174 |
-
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 175 |
-
ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
|
| 176 |
-
is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
|
| 177 |
-
from the registry formula and the cell's ``layers_mask``.
|
| 178 |
-
"""
|
| 179 |
-
res = _sorted_specs(resolutions)
|
| 180 |
-
n = len(res)
|
| 181 |
-
mode_clean = mode.strip().strip('"') or "Static"
|
| 182 |
-
|
| 183 |
-
impact: list[str] = []
|
| 184 |
-
for k, r in enumerate(res):
|
| 185 |
-
models = _sorted_specs(r.models)
|
| 186 |
-
entries = [registry[_model_key(m.ref)] for m in models]
|
| 187 |
-
|
| 188 |
-
def row(stem: str, values: list[str]) -> None:
|
| 189 |
-
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 190 |
-
|
| 191 |
-
# From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
|
| 192 |
-
# model FOV); everything else is a per-model knob taken straight from the cell.
|
| 193 |
-
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 194 |
-
row("Dimension", [e["dimension"] for e in entries])
|
| 195 |
-
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
| 196 |
-
row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
|
| 197 |
-
row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
|
| 198 |
-
row("LayersMask", [f'"{m.layers_mask}"' for m in models])
|
| 199 |
-
row("SubsetFeatures", [str(m.subset_features) for m in models])
|
| 200 |
-
row("PCA", [str(m.pca) for m in models])
|
| 201 |
-
row("Distance", [f'"{m.distance}"' for m in models])
|
| 202 |
-
row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
|
| 203 |
-
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 204 |
-
|
| 205 |
-
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 206 |
-
# (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
|
| 207 |
-
lines = template_text.splitlines()
|
| 208 |
-
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 209 |
-
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
| 210 |
-
block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
|
| 211 |
-
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for i, (m, line) in enumerate(indexed):
|
| 214 |
-
key = m.group(1) if m else None
|
| 215 |
-
if block_lo <= i <= block_hi:
|
| 216 |
-
if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
|
| 217 |
-
out.extend(impact[:-1])
|
| 218 |
-
elif key == "MaximumNumberOfIterations":
|
| 219 |
-
out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
|
| 220 |
-
elif key == "NumberOfResolutions":
|
| 221 |
-
out.append(f"(NumberOfResolutions {n})")
|
| 222 |
-
elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
|
| 223 |
-
out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
|
| 224 |
-
elif key == "ImpactMode":
|
| 225 |
-
out.append(f'(ImpactMode "{mode_clean}")')
|
| 226 |
-
else:
|
| 227 |
-
out.append(line)
|
| 228 |
-
return "\n".join(out)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class ChannelSelect(torch.nn.Module):
|
| 232 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 235 |
-
super().__init__()
|
| 236 |
-
self._start = start
|
| 237 |
-
self._stop = stop
|
| 238 |
-
|
| 239 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 240 |
-
return tensor[:, self._start : self._stop]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
class RegistrationNet(network.Network):
|
| 244 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
|
| 245 |
-
moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 246 |
-
|
| 247 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 248 |
-
(the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
|
| 249 |
-
``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
|
| 250 |
-
``same_as_group: Volume_0:Fixed``.
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
def __init__(
|
| 254 |
-
self,
|
| 255 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 256 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 257 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 258 |
-
},
|
| 259 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 260 |
-
engine: str = "elastix",
|
| 261 |
-
parameter_maps: list[str] = [],
|
| 262 |
-
max_iterations: Annotated[int, Range(0, 100000)] = 0,
|
| 263 |
-
final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
|
| 264 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0,
|
| 265 |
-
spatial_samples: Annotated[int, Range(0, 100000)] = 0,
|
| 266 |
-
parameter_overrides: list[str] = [],
|
| 267 |
-
resolutions: dict[str, ResolutionSpec] = {},
|
| 268 |
-
mode: Literal["Static", "Jacobian"] = "Static",
|
| 269 |
-
) -> None:
|
| 270 |
-
# The registration is fully described by ``resolutions`` (config = source of truth): each resolution
|
| 271 |
-
# lists its self-configured models; the download list is derived from the cells. Global knobs override
|
| 272 |
-
# the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
|
| 273 |
-
# NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
|
| 274 |
-
# preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
|
| 275 |
-
from elastix_engine import ElastixRegistration
|
| 276 |
-
|
| 277 |
-
super().__init__(
|
| 278 |
-
in_channels=1,
|
| 279 |
-
optimizer=optimizer,
|
| 280 |
-
schedulers=schedulers,
|
| 281 |
-
outputs_criterions=outputs_criterions,
|
| 282 |
-
dim=3,
|
| 283 |
-
)
|
| 284 |
-
self.add_module(
|
| 285 |
-
"Registration",
|
| 286 |
-
ElastixRegistration(
|
| 287 |
-
engine,
|
| 288 |
-
parameter_maps,
|
| 289 |
-
max_iterations,
|
| 290 |
-
final_grid_spacing,
|
| 291 |
-
subset_features,
|
| 292 |
-
spatial_samples,
|
| 293 |
-
parameter_overrides,
|
| 294 |
-
resolutions,
|
| 295 |
-
mode,
|
| 296 |
-
),
|
| 297 |
-
in_branch=[0, 1, 2, 3],
|
| 298 |
-
out_branch=["registration"],
|
| 299 |
-
)
|
| 300 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 301 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Generic_Rigid_BSpline/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.elastix:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
Generic_Rigid_BSpline/elastix_engine.py
DELETED
|
@@ -1,386 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Elastix-IMPACT runtime for the registration bundle.
|
| 18 |
-
|
| 19 |
-
``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
|
| 20 |
-
parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
|
| 21 |
-
``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
|
| 22 |
-
images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
import os
|
| 26 |
-
import re
|
| 27 |
-
import shutil
|
| 28 |
-
import subprocess # nosec B404
|
| 29 |
-
import tempfile
|
| 30 |
-
from pathlib import Path
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
import SimpleITK as sitk
|
| 34 |
-
import torch
|
| 35 |
-
import tqdm
|
| 36 |
-
from huggingface_hub import hf_hub_download
|
| 37 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 38 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 39 |
-
|
| 40 |
-
from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
|
| 41 |
-
|
| 42 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 43 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 44 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 48 |
-
"""True only for a mask that actually restricts the metric region — some voxels in, some out. An
|
| 49 |
-
absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
|
| 50 |
-
is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
|
| 51 |
-
the whole image) instead of paying for a mask that restricts nothing."""
|
| 52 |
-
if mask is None:
|
| 53 |
-
return False
|
| 54 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 55 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class ElastixEngine:
|
| 59 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 60 |
-
|
| 61 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 62 |
-
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 63 |
-
"""
|
| 64 |
-
|
| 65 |
-
def __init__(
|
| 66 |
-
self,
|
| 67 |
-
parameter_maps: list[str],
|
| 68 |
-
max_iterations: int = 0,
|
| 69 |
-
final_grid_spacing: float = 0.0,
|
| 70 |
-
subset_features: int = 0,
|
| 71 |
-
spatial_samples: int = 0,
|
| 72 |
-
parameter_overrides: list[str] = [],
|
| 73 |
-
resolutions: dict = {},
|
| 74 |
-
mode: str = "Static",
|
| 75 |
-
) -> None:
|
| 76 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 77 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 78 |
-
self._max_iterations = max_iterations
|
| 79 |
-
self._final_grid_spacing = final_grid_spacing
|
| 80 |
-
self._subset_features = subset_features
|
| 81 |
-
self._spatial_samples = spatial_samples
|
| 82 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 83 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 84 |
-
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 85 |
-
self._mode = mode
|
| 86 |
-
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 87 |
-
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 88 |
-
self._resolutions = resolutions
|
| 89 |
-
self._registry = load_models_registry() if resolutions else {}
|
| 90 |
-
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 91 |
-
models: list[str] = []
|
| 92 |
-
for res in _sorted_specs(resolutions):
|
| 93 |
-
for model in _sorted_specs(res.models):
|
| 94 |
-
if model.ref not in models:
|
| 95 |
-
models.append(model.ref)
|
| 96 |
-
self._models = models
|
| 97 |
-
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 98 |
-
self._iterations = self._total_iterations()
|
| 99 |
-
self._elastix_bin = self._ensure_binary()
|
| 100 |
-
self._local_models = self._download_models()
|
| 101 |
-
|
| 102 |
-
def _total_iterations(self) -> int:
|
| 103 |
-
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 104 |
-
if self._resolutions:
|
| 105 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 106 |
-
total = 0
|
| 107 |
-
for src in self._parameter_maps:
|
| 108 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 109 |
-
if match:
|
| 110 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 111 |
-
return total
|
| 112 |
-
|
| 113 |
-
def _ensure_binary(self) -> Path:
|
| 114 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 115 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 116 |
-
if override:
|
| 117 |
-
try_elastix(Path(override))
|
| 118 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 119 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 120 |
-
try:
|
| 121 |
-
try_elastix(ELASTIX_CACHE)
|
| 122 |
-
except Exception:
|
| 123 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 124 |
-
try_elastix(ELASTIX_CACHE)
|
| 125 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 126 |
-
|
| 127 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 128 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 129 |
-
models = []
|
| 130 |
-
for ref in self._models:
|
| 131 |
-
repo, filename = ref.split(":", 1)
|
| 132 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 133 |
-
models.append((filename, local))
|
| 134 |
-
return models
|
| 135 |
-
|
| 136 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 137 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 138 |
-
|
| 139 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 140 |
-
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 141 |
-
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 142 |
-
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 143 |
-
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 144 |
-
"""
|
| 145 |
-
per_token: dict[str, str] = {}
|
| 146 |
-
if not global_only and self._max_iterations > 0:
|
| 147 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 148 |
-
if self._final_grid_spacing > 0:
|
| 149 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 150 |
-
if not global_only and self._subset_features > 0:
|
| 151 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 152 |
-
if self._spatial_samples > 0:
|
| 153 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 154 |
-
exact: list[tuple[str, str]] = []
|
| 155 |
-
for entry in self._parameter_overrides:
|
| 156 |
-
key, sep, value = entry.partition("=")
|
| 157 |
-
if not sep or not key.strip():
|
| 158 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 159 |
-
exact.append((key.strip(), value.strip()))
|
| 160 |
-
return per_token, exact
|
| 161 |
-
|
| 162 |
-
@staticmethod
|
| 163 |
-
def _apply_map_overrides(
|
| 164 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 165 |
-
) -> str:
|
| 166 |
-
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 167 |
-
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 168 |
-
"""
|
| 169 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 170 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 171 |
-
seen: set[str] = set()
|
| 172 |
-
lines = []
|
| 173 |
-
for line in text.splitlines():
|
| 174 |
-
match = entry_pattern.match(line)
|
| 175 |
-
if match:
|
| 176 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 177 |
-
if key == "ImpactGPU":
|
| 178 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 179 |
-
else:
|
| 180 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 181 |
-
if exact_value is not None:
|
| 182 |
-
seen.add(key)
|
| 183 |
-
line = f"{indent}({key} {exact_value})"
|
| 184 |
-
else:
|
| 185 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 186 |
-
if token_key in per_token:
|
| 187 |
-
seen.add(token_key)
|
| 188 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 189 |
-
line = f"{indent}({key} {replaced})"
|
| 190 |
-
lines.append(line)
|
| 191 |
-
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 192 |
-
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 193 |
-
for key in sorted(requested - seen):
|
| 194 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 195 |
-
return "\n".join(lines)
|
| 196 |
-
|
| 197 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 198 |
-
"""Stage the parameter maps into ``work``.
|
| 199 |
-
|
| 200 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 201 |
-
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 202 |
-
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 203 |
-
"""
|
| 204 |
-
staged = []
|
| 205 |
-
for src in self._parameter_maps:
|
| 206 |
-
if self._resolutions:
|
| 207 |
-
text = generate_impact_parameter_map(
|
| 208 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 209 |
-
)
|
| 210 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 211 |
-
else:
|
| 212 |
-
text = src.read_text(encoding="utf-8")
|
| 213 |
-
per_token, exact = self._parameter_map_overrides()
|
| 214 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 215 |
-
dst = work / src.name
|
| 216 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 217 |
-
staged.append(dst)
|
| 218 |
-
return staged
|
| 219 |
-
|
| 220 |
-
def register(
|
| 221 |
-
self,
|
| 222 |
-
fixed: sitk.Image,
|
| 223 |
-
moving: sitk.Image,
|
| 224 |
-
device_index: int,
|
| 225 |
-
fixed_mask: sitk.Image | None = None,
|
| 226 |
-
moving_mask: sitk.Image | None = None,
|
| 227 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 228 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 229 |
-
|
| 230 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 231 |
-
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 232 |
-
"""
|
| 233 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 234 |
-
try:
|
| 235 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 236 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 237 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 238 |
-
|
| 239 |
-
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 240 |
-
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 241 |
-
for rel_name, model_path in self._local_models:
|
| 242 |
-
dst = work / rel_name
|
| 243 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 244 |
-
if not dst.exists():
|
| 245 |
-
dst.symlink_to(model_path)
|
| 246 |
-
|
| 247 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 248 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 249 |
-
if _is_partial_mask(mask):
|
| 250 |
-
mask_path = work / name
|
| 251 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 252 |
-
args += [flag, str(mask_path)]
|
| 253 |
-
args += ["-out", str(work)]
|
| 254 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 255 |
-
args += ["-p", str(pmap)]
|
| 256 |
-
|
| 257 |
-
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 258 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 259 |
-
env = os.environ.copy()
|
| 260 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 261 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 262 |
-
proc = subprocess.Popen( # nosec B603
|
| 263 |
-
args,
|
| 264 |
-
cwd=str(work),
|
| 265 |
-
stdout=subprocess.PIPE,
|
| 266 |
-
stderr=subprocess.STDOUT,
|
| 267 |
-
text=True,
|
| 268 |
-
bufsize=1,
|
| 269 |
-
env=env,
|
| 270 |
-
)
|
| 271 |
-
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 272 |
-
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 273 |
-
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 274 |
-
captured: list[str] = []
|
| 275 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 276 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 277 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 278 |
-
assert proc.stdout is not None
|
| 279 |
-
resolution = 0
|
| 280 |
-
for line in proc.stdout:
|
| 281 |
-
captured.append(line)
|
| 282 |
-
stripped = line.strip()
|
| 283 |
-
if stripped.startswith("Resolution:"):
|
| 284 |
-
try:
|
| 285 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 286 |
-
except ValueError:
|
| 287 |
-
pass
|
| 288 |
-
elif iteration_line.match(line):
|
| 289 |
-
progress.update(1)
|
| 290 |
-
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 291 |
-
if len(columns) > 1:
|
| 292 |
-
try:
|
| 293 |
-
progress.set_description(
|
| 294 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 295 |
-
)
|
| 296 |
-
except ValueError:
|
| 297 |
-
pass
|
| 298 |
-
progress.close()
|
| 299 |
-
returncode = proc.wait()
|
| 300 |
-
if returncode != 0:
|
| 301 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 302 |
-
|
| 303 |
-
transforms = sorted(
|
| 304 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 305 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 306 |
-
)
|
| 307 |
-
if not transforms:
|
| 308 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 309 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 310 |
-
|
| 311 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 312 |
-
dvf = sitk.TransformToDisplacementField(
|
| 313 |
-
transform,
|
| 314 |
-
sitk.sitkVectorFloat64,
|
| 315 |
-
fixed.GetSize(),
|
| 316 |
-
fixed.GetOrigin(),
|
| 317 |
-
fixed.GetSpacing(),
|
| 318 |
-
fixed.GetDirection(),
|
| 319 |
-
)
|
| 320 |
-
moved_np, _ = image_to_data(moved)
|
| 321 |
-
dvf_np, _ = image_to_data(dvf)
|
| 322 |
-
return moved_np, dvf_np
|
| 323 |
-
finally:
|
| 324 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
class ElastixRegistration(torch.nn.Module):
|
| 328 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 329 |
-
|
| 330 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 331 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 332 |
-
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 333 |
-
"""
|
| 334 |
-
|
| 335 |
-
accepts_attributes = True
|
| 336 |
-
|
| 337 |
-
def __init__(
|
| 338 |
-
self,
|
| 339 |
-
engine: str,
|
| 340 |
-
parameter_maps: list[str],
|
| 341 |
-
max_iterations: int = 0,
|
| 342 |
-
final_grid_spacing: float = 0.0,
|
| 343 |
-
subset_features: int = 0,
|
| 344 |
-
spatial_samples: int = 0,
|
| 345 |
-
parameter_overrides: list[str] = [],
|
| 346 |
-
resolutions: dict = {},
|
| 347 |
-
mode: str = "Static",
|
| 348 |
-
) -> None:
|
| 349 |
-
super().__init__()
|
| 350 |
-
if engine != "elastix":
|
| 351 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 352 |
-
self._engine = ElastixEngine(
|
| 353 |
-
parameter_maps,
|
| 354 |
-
max_iterations,
|
| 355 |
-
final_grid_spacing,
|
| 356 |
-
subset_features,
|
| 357 |
-
spatial_samples,
|
| 358 |
-
parameter_overrides,
|
| 359 |
-
resolutions,
|
| 360 |
-
mode,
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
def forward(
|
| 364 |
-
self,
|
| 365 |
-
fixed: torch.Tensor,
|
| 366 |
-
moving: torch.Tensor,
|
| 367 |
-
fixed_mask: torch.Tensor,
|
| 368 |
-
moving_mask: torch.Tensor,
|
| 369 |
-
attributes: list[list[Attribute]],
|
| 370 |
-
) -> torch.Tensor:
|
| 371 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 372 |
-
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 373 |
-
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 374 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 375 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 376 |
-
combined = []
|
| 377 |
-
for b in range(fixed.shape[0]):
|
| 378 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 379 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 380 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 381 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 382 |
-
moved_np, dvf_np = self._engine.register(
|
| 383 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 384 |
-
)
|
| 385 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 386 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Generic_Rigid_BSpline/install.py
DELETED
|
@@ -1,325 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
import argparse
|
| 18 |
-
import platform
|
| 19 |
-
import re
|
| 20 |
-
import shutil
|
| 21 |
-
import stat
|
| 22 |
-
import subprocess # nosec B404
|
| 23 |
-
import zipfile
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
|
| 26 |
-
import requests
|
| 27 |
-
from tqdm import tqdm
|
| 28 |
-
|
| 29 |
-
# -----------------------------------------------------------------------------
|
| 30 |
-
# Elastix + IMPACT binary assets hosted on GitHub Releases.
|
| 31 |
-
#
|
| 32 |
-
# Key format: (OS, ARCH, FLAVOR)
|
| 33 |
-
# - OS : platform.system() -> "Linux", "Windows", "Darwin"
|
| 34 |
-
# - ARCH : normalized architecture -> "x86_64"
|
| 35 |
-
# - FLAVOR : "cpu" or "cu128"
|
| 36 |
-
#
|
| 37 |
-
# CPU assets are standalone (LibTorch CPU bundled).
|
| 38 |
-
# CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
|
| 39 |
-
# -----------------------------------------------------------------------------
|
| 40 |
-
ELX_ASSET_TEMPLATE = {
|
| 41 |
-
("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
|
| 42 |
-
("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
|
| 43 |
-
("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
|
| 44 |
-
("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
|
| 45 |
-
("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
# -----------------------------------------------------------------------------
|
| 49 |
-
# Official LibTorch downloads (PyTorch).
|
| 50 |
-
#
|
| 51 |
-
# IMPORTANT:
|
| 52 |
-
# - Version MUST match the one used at build time (ABI compatibility).
|
| 53 |
-
# - Using "shared-with-deps" ensures CUDA runtime libraries are included
|
| 54 |
-
# (except the NVIDIA driver, which must be installed system-wide).
|
| 55 |
-
# -----------------------------------------------------------------------------
|
| 56 |
-
|
| 57 |
-
LIBTORCH_URL = {
|
| 58 |
-
(
|
| 59 |
-
"Linux",
|
| 60 |
-
"x86_64",
|
| 61 |
-
"cpu",
|
| 62 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 63 |
-
(
|
| 64 |
-
"Windows",
|
| 65 |
-
"x86_64",
|
| 66 |
-
"cpu",
|
| 67 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 68 |
-
(
|
| 69 |
-
"Darwin",
|
| 70 |
-
"arm64",
|
| 71 |
-
"cpu",
|
| 72 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 73 |
-
(
|
| 74 |
-
"Linux",
|
| 75 |
-
"x86_64",
|
| 76 |
-
"cu128",
|
| 77 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 78 |
-
(
|
| 79 |
-
"Windows",
|
| 80 |
-
"x86_64",
|
| 81 |
-
"cu128",
|
| 82 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
# -----------------------------------------------------------------------------
|
| 86 |
-
# Minimum NVIDIA driver versions required for CUDA 12.8.
|
| 87 |
-
#
|
| 88 |
-
# The CUDA Toolkit itself is NOT required.
|
| 89 |
-
# Only a sufficiently recent NVIDIA driver must be installed.
|
| 90 |
-
# -----------------------------------------------------------------------------
|
| 91 |
-
CUDA128_MIN_DRIVER_LINUX = (570, 26)
|
| 92 |
-
CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
|
| 93 |
-
|
| 94 |
-
GITHUB_OWNER = "vboussot"
|
| 95 |
-
GITHUB_REPO = "ImpactElastix"
|
| 96 |
-
GITHUB_TAG = "1.0.0"
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def run_cmd(cmd: list[str]) -> str:
|
| 103 |
-
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
|
| 107 |
-
"""
|
| 108 |
-
Detect presence of an NVIDIA GPU and extract the driver version.
|
| 109 |
-
Returns:
|
| 110 |
-
(True, (major, minor)) if detected
|
| 111 |
-
(False, None) if nvidia-smi is not available
|
| 112 |
-
"""
|
| 113 |
-
try:
|
| 114 |
-
nvidia_smi = shutil.which("nvidia-smi")
|
| 115 |
-
if nvidia_smi is None:
|
| 116 |
-
raise RuntimeError("nvidia-smi not found in PATH")
|
| 117 |
-
|
| 118 |
-
out = (
|
| 119 |
-
subprocess.check_output(
|
| 120 |
-
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
|
| 121 |
-
) # nosec B603
|
| 122 |
-
.decode("utf-8")
|
| 123 |
-
.strip()
|
| 124 |
-
)
|
| 125 |
-
except Exception:
|
| 126 |
-
return (False, None)
|
| 127 |
-
|
| 128 |
-
# Exemple: "575.64"
|
| 129 |
-
m = re.match(r"([0-9]+)\.([0-9]+)", out)
|
| 130 |
-
if not m:
|
| 131 |
-
return (True, None)
|
| 132 |
-
|
| 133 |
-
return (True, (int(m.group(1)), int(m.group(2))))
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
|
| 137 |
-
"""
|
| 138 |
-
Check whether the detected NVIDIA driver satisfies the minimum
|
| 139 |
-
requirement for CUDA 12.8 on the given operating system.
|
| 140 |
-
"""
|
| 141 |
-
if drv is None:
|
| 142 |
-
return False
|
| 143 |
-
if os_name == "Linux":
|
| 144 |
-
return drv >= CUDA128_MIN_DRIVER_LINUX
|
| 145 |
-
if os_name == "Windows":
|
| 146 |
-
return drv >= CUDA128_MIN_DRIVER_WINDOWS
|
| 147 |
-
return False
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def normalize_arch(machine: str) -> str:
|
| 151 |
-
"""
|
| 152 |
-
Normalize platform.machine() output across operating systems.
|
| 153 |
-
|
| 154 |
-
Examples:
|
| 155 |
-
AMD64 -> x86_64
|
| 156 |
-
aarch64 -> arm64
|
| 157 |
-
"""
|
| 158 |
-
m = machine.lower()
|
| 159 |
-
if m in ("x86_64", "amd64"):
|
| 160 |
-
return "x86_64"
|
| 161 |
-
if m in ("aarch64", "arm64"):
|
| 162 |
-
return "arm64"
|
| 163 |
-
return machine
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def download_file(url: str, dst: Path) -> None:
|
| 167 |
-
"""
|
| 168 |
-
Download a file from the given URL with a progress indicator.
|
| 169 |
-
"""
|
| 170 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
-
print(f"Downloading: {url}", flush=True)
|
| 172 |
-
print(f" to: {dst}", flush=True)
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
with requests.get(url, stream=True, timeout=60) as r:
|
| 176 |
-
r.raise_for_status()
|
| 177 |
-
total = int(r.headers.get("content-length", 0))
|
| 178 |
-
with open(dst, "wb") as f:
|
| 179 |
-
with tqdm(
|
| 180 |
-
total=total,
|
| 181 |
-
unit="B",
|
| 182 |
-
unit_scale=True,
|
| 183 |
-
desc=f"Downloading {dst.name}",
|
| 184 |
-
) as pbar:
|
| 185 |
-
for chunk in r.iter_content(chunk_size=8192):
|
| 186 |
-
f.write(chunk)
|
| 187 |
-
pbar.update(len(chunk))
|
| 188 |
-
except Exception as e:
|
| 189 |
-
raise e
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def extract_archive(archive: Path, dst_dir: Path) -> None:
|
| 193 |
-
"""
|
| 194 |
-
Extract a ZIP archive to the destination directory.
|
| 195 |
-
"""
|
| 196 |
-
dst_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
-
print(f"Extracting: {archive} -> {dst_dir}", flush=True)
|
| 198 |
-
with zipfile.ZipFile(archive, "r") as z:
|
| 199 |
-
z.extractall(dst_dir)
|
| 200 |
-
archive.unlink()
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
|
| 204 |
-
os_name = platform.system()
|
| 205 |
-
arch = normalize_arch(platform.machine())
|
| 206 |
-
has_nvidia, drv = detect_nvidia_driver()
|
| 207 |
-
|
| 208 |
-
if os_name not in ("Linux", "Windows", "Darwin"):
|
| 209 |
-
raise NameError(f"Unsupported OS: {os_name}")
|
| 210 |
-
|
| 211 |
-
if arch not in ("x86_64", "arm64"):
|
| 212 |
-
raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
|
| 213 |
-
|
| 214 |
-
flavor = "cpu"
|
| 215 |
-
if force_cuda:
|
| 216 |
-
if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
|
| 217 |
-
raise NameError(
|
| 218 |
-
"CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
flavor = "cu128"
|
| 222 |
-
elif not force_cpu:
|
| 223 |
-
if has_nvidia and driver_ok_for_cuda(os_name, drv):
|
| 224 |
-
flavor = "cu128"
|
| 225 |
-
else:
|
| 226 |
-
flavor = "cpu"
|
| 227 |
-
|
| 228 |
-
print(f"System: {os_name} {arch}", flush=True)
|
| 229 |
-
print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
|
| 230 |
-
print(f"Selected flavor: {flavor}", flush=True)
|
| 231 |
-
|
| 232 |
-
key = (os_name, arch, flavor)
|
| 233 |
-
if key not in ELX_ASSET_TEMPLATE:
|
| 234 |
-
raise NameError(f"No elastix asset configured for {key}")
|
| 235 |
-
if flavor != "cpu" and key not in LIBTORCH_URL:
|
| 236 |
-
raise NameError(f"No libtorch url configured for {key}")
|
| 237 |
-
|
| 238 |
-
install_path = install_path.resolve()
|
| 239 |
-
install_path.mkdir(parents=True, exist_ok=True)
|
| 240 |
-
|
| 241 |
-
elx_asset = ELX_ASSET_TEMPLATE[key]
|
| 242 |
-
elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
|
| 243 |
-
elx_archive = install_path / elx_asset
|
| 244 |
-
download_file(elx_url, elx_archive)
|
| 245 |
-
extract_archive(elx_archive, install_path)
|
| 246 |
-
|
| 247 |
-
# -------------------------------------------------------------------------
|
| 248 |
-
# ZIP archives may drop executable permissions.
|
| 249 |
-
# Ensure elastix and transformix are executable on Unix platforms.
|
| 250 |
-
# -------------------------------------------------------------------------
|
| 251 |
-
if os_name in ("Linux", "Darwin"):
|
| 252 |
-
for exe in ("elastix", "transformix"):
|
| 253 |
-
p = install_path / "bin" / exe
|
| 254 |
-
if p.exists():
|
| 255 |
-
p.chmod(p.stat().st_mode | stat.S_IEXEC)
|
| 256 |
-
|
| 257 |
-
# ---------------------------------------------------------------------
|
| 258 |
-
# - Download matching LibTorch 12.8 with or without CUDA 12.8
|
| 259 |
-
# - Extract it
|
| 260 |
-
# - Move runtime libraries to a location visible to the dynamic loader
|
| 261 |
-
#
|
| 262 |
-
# Linux / macOS : prefix/lib
|
| 263 |
-
# Windows : prefix (next to elastix.exe)
|
| 264 |
-
# ---------------------------------------------------------------------
|
| 265 |
-
lt_url = LIBTORCH_URL[key]
|
| 266 |
-
lt_archive = install_path / Path(lt_url).name
|
| 267 |
-
download_file(lt_url, lt_archive)
|
| 268 |
-
extract_archive(lt_archive, install_path)
|
| 269 |
-
for p in (install_path / "libtorch" / "lib").iterdir():
|
| 270 |
-
if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
|
| 271 |
-
shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
|
| 272 |
-
|
| 273 |
-
shutil.rmtree(install_path / "libtorch")
|
| 274 |
-
if os_name == "Linux" and flavor == "cu128":
|
| 275 |
-
shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def get_elastix_bin(install_path: Path) -> Path:
|
| 279 |
-
return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def try_elastix(install_path: Path) -> None:
|
| 283 |
-
try:
|
| 284 |
-
subprocess.run(
|
| 285 |
-
[str(get_elastix_bin(install_path)), "-h"],
|
| 286 |
-
capture_output=True,
|
| 287 |
-
text=True,
|
| 288 |
-
check=True,
|
| 289 |
-
) # nosec B603
|
| 290 |
-
except subprocess.CalledProcessError as e:
|
| 291 |
-
msg = "Elastix execution failed.\n\n"
|
| 292 |
-
|
| 293 |
-
msg += f"Command:\n{' '.join(e.cmd)}\n"
|
| 294 |
-
msg += f"Return code: {e.returncode}\n\n"
|
| 295 |
-
|
| 296 |
-
if e.stderr:
|
| 297 |
-
msg += "Error output:\n"
|
| 298 |
-
msg += e.stderr.strip()
|
| 299 |
-
raise NameError(msg)
|
| 300 |
-
|
| 301 |
-
except OSError as e:
|
| 302 |
-
msg = (
|
| 303 |
-
"Elastix could not be started.\n\n"
|
| 304 |
-
"This is usually caused by missing shared libraries "
|
| 305 |
-
"(e.g. LibTorch or CUDA runtime).\n\n"
|
| 306 |
-
f"System error:\n{str(e)}"
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
raise NameError(msg)
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def main() -> None:
|
| 313 |
-
ap = argparse.ArgumentParser()
|
| 314 |
-
ap.add_argument(
|
| 315 |
-
"--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
|
| 316 |
-
)
|
| 317 |
-
group = ap.add_mutually_exclusive_group()
|
| 318 |
-
group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
|
| 319 |
-
group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
|
| 320 |
-
args = vars(ap.parse_args())
|
| 321 |
-
install_elastix_impact(**args)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if __name__ == "__main__":
|
| 325 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Generic_Rigid_BSpline/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
impact_reg_konfai>=1.6.1
|
MR_CT_HeadNeck/Model.py
DELETED
|
@@ -1,301 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
|
| 18 |
-
|
| 19 |
-
``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
|
| 20 |
-
2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
|
| 21 |
-
the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
|
| 22 |
-
the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
|
| 23 |
-
subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
|
| 24 |
-
|
| 25 |
-
A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
|
| 26 |
-
``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
|
| 27 |
-
|
| 28 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
|
| 29 |
-
(``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import os
|
| 34 |
-
import re
|
| 35 |
-
from dataclasses import dataclass, field
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import Annotated, Literal
|
| 38 |
-
|
| 39 |
-
import torch
|
| 40 |
-
from huggingface_hub import hf_hub_download
|
| 41 |
-
from konfai.network import network
|
| 42 |
-
from konfai.utils.config import Choices, Range
|
| 43 |
-
|
| 44 |
-
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 45 |
-
# A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
|
| 46 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
|
| 47 |
-
# iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
|
| 48 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 49 |
-
|
| 50 |
-
# ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
|
| 51 |
-
# Static anyway; in Jacobian we clamp ``l`` to this plateau.
|
| 52 |
-
_FOV_RAMP_MAX_LAYER = 6
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry_choices() -> list[str]:
|
| 56 |
-
"""The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
|
| 57 |
-
(offline-first). A user may still point ``ref`` at a local model."""
|
| 58 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 59 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _num(x: object) -> str:
|
| 63 |
-
"""Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
|
| 64 |
-
return "%g" % float(x)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
@dataclass
|
| 68 |
-
class ModelSpec:
|
| 69 |
-
"""One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
|
| 70 |
-
rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
|
| 71 |
-
(``models.json``) keyed by ``ref`` — never tuned."""
|
| 72 |
-
|
| 73 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 74 |
-
voxel_size: list[float] = field(default_factory=list)
|
| 75 |
-
layers_weight: list[float] = field(default_factory=lambda: [1.0])
|
| 76 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0
|
| 77 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 78 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 79 |
-
layers_mask: str = ""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
@dataclass
|
| 83 |
-
class ResolutionSpec:
|
| 84 |
-
"""One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
|
| 85 |
-
|
| 86 |
-
max_iterations: Annotated[int, Range(1, 100000)]
|
| 87 |
-
models: dict[str, ModelSpec]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 91 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order."""
|
| 92 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 96 |
-
"""Load models.json (the fixed params per model) from the model repo on Hugging Face.
|
| 97 |
-
|
| 98 |
-
The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
|
| 99 |
-
dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
|
| 100 |
-
"""
|
| 101 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 102 |
-
if local:
|
| 103 |
-
path = Path(local)
|
| 104 |
-
elif ":" in ref:
|
| 105 |
-
repo, filename = ref.split(":", 1)
|
| 106 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(
|
| 109 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
|
| 110 |
-
f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 111 |
-
)
|
| 112 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _model_key(ref: str) -> str:
|
| 116 |
-
"""Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
|
| 117 |
-
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _deepest_active_layer(layers_mask: str) -> int:
|
| 121 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
|
| 122 |
-
|
| 123 |
-
A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
|
| 124 |
-
== ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
|
| 125 |
-
receptive field, so the FOV is governed by the rightmost ``'1'``.
|
| 126 |
-
"""
|
| 127 |
-
mask = layers_mask.strip().strip('"')
|
| 128 |
-
active = [i for i, char in enumerate(mask) if char == "1"]
|
| 129 |
-
if not active:
|
| 130 |
-
raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
|
| 131 |
-
return max(active)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 135 |
-
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 136 |
-
|
| 137 |
-
Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
|
| 138 |
-
``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
|
| 139 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
|
| 140 |
-
to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
|
| 141 |
-
a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
|
| 142 |
-
``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
|
| 143 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut.
|
| 144 |
-
"""
|
| 145 |
-
formula = str(fov.get("formula", "")).strip()
|
| 146 |
-
key = re.sub(r"\s+", "", formula).lower()
|
| 147 |
-
if key.isdigit():
|
| 148 |
-
return int(key)
|
| 149 |
-
if key == "2*r*d+1":
|
| 150 |
-
return 2 * int(fov["r"]) * int(fov["d"]) + 1
|
| 151 |
-
if key == "2^l+3":
|
| 152 |
-
return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
|
| 153 |
-
if "global" in key:
|
| 154 |
-
raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
|
| 155 |
-
if fov.get("value") is not None:
|
| 156 |
-
return int(fov["value"])
|
| 157 |
-
raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 161 |
-
"""PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
|
| 162 |
-
image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
|
| 163 |
-
e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
|
| 164 |
-
dim = int(entry.get("dimension", 3))
|
| 165 |
-
if mode.strip().strip('"').lower() != "jacobian":
|
| 166 |
-
return " ".join(["0"] * dim)
|
| 167 |
-
fov = _fov_value(entry.get("fov", {}), layers_mask)
|
| 168 |
-
return " ".join([str(fov)] * dim)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
|
| 172 |
-
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 173 |
-
|
| 174 |
-
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 175 |
-
ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
|
| 176 |
-
is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
|
| 177 |
-
from the registry formula and the cell's ``layers_mask``.
|
| 178 |
-
"""
|
| 179 |
-
res = _sorted_specs(resolutions)
|
| 180 |
-
n = len(res)
|
| 181 |
-
mode_clean = mode.strip().strip('"') or "Static"
|
| 182 |
-
|
| 183 |
-
impact: list[str] = []
|
| 184 |
-
for k, r in enumerate(res):
|
| 185 |
-
models = _sorted_specs(r.models)
|
| 186 |
-
entries = [registry[_model_key(m.ref)] for m in models]
|
| 187 |
-
|
| 188 |
-
def row(stem: str, values: list[str]) -> None:
|
| 189 |
-
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 190 |
-
|
| 191 |
-
# From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
|
| 192 |
-
# model FOV); everything else is a per-model knob taken straight from the cell.
|
| 193 |
-
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 194 |
-
row("Dimension", [e["dimension"] for e in entries])
|
| 195 |
-
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
| 196 |
-
row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
|
| 197 |
-
row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
|
| 198 |
-
row("LayersMask", [f'"{m.layers_mask}"' for m in models])
|
| 199 |
-
row("SubsetFeatures", [str(m.subset_features) for m in models])
|
| 200 |
-
row("PCA", [str(m.pca) for m in models])
|
| 201 |
-
row("Distance", [f'"{m.distance}"' for m in models])
|
| 202 |
-
row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
|
| 203 |
-
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 204 |
-
|
| 205 |
-
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 206 |
-
# (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
|
| 207 |
-
lines = template_text.splitlines()
|
| 208 |
-
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 209 |
-
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
| 210 |
-
block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
|
| 211 |
-
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for i, (m, line) in enumerate(indexed):
|
| 214 |
-
key = m.group(1) if m else None
|
| 215 |
-
if block_lo <= i <= block_hi:
|
| 216 |
-
if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
|
| 217 |
-
out.extend(impact[:-1])
|
| 218 |
-
elif key == "MaximumNumberOfIterations":
|
| 219 |
-
out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
|
| 220 |
-
elif key == "NumberOfResolutions":
|
| 221 |
-
out.append(f"(NumberOfResolutions {n})")
|
| 222 |
-
elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
|
| 223 |
-
out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
|
| 224 |
-
elif key == "ImpactMode":
|
| 225 |
-
out.append(f'(ImpactMode "{mode_clean}")')
|
| 226 |
-
else:
|
| 227 |
-
out.append(line)
|
| 228 |
-
return "\n".join(out)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class ChannelSelect(torch.nn.Module):
|
| 232 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 235 |
-
super().__init__()
|
| 236 |
-
self._start = start
|
| 237 |
-
self._stop = stop
|
| 238 |
-
|
| 239 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 240 |
-
return tensor[:, self._start : self._stop]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
class RegistrationNet(network.Network):
|
| 244 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
|
| 245 |
-
moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 246 |
-
|
| 247 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 248 |
-
(the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
|
| 249 |
-
``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
|
| 250 |
-
``same_as_group: Volume_0:Fixed``.
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
def __init__(
|
| 254 |
-
self,
|
| 255 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 256 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 257 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 258 |
-
},
|
| 259 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 260 |
-
engine: str = "elastix",
|
| 261 |
-
parameter_maps: list[str] = [],
|
| 262 |
-
max_iterations: Annotated[int, Range(0, 100000)] = 0,
|
| 263 |
-
final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
|
| 264 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0,
|
| 265 |
-
spatial_samples: Annotated[int, Range(0, 100000)] = 0,
|
| 266 |
-
parameter_overrides: list[str] = [],
|
| 267 |
-
resolutions: dict[str, ResolutionSpec] = {},
|
| 268 |
-
mode: Literal["Static", "Jacobian"] = "Static",
|
| 269 |
-
) -> None:
|
| 270 |
-
# The registration is fully described by ``resolutions`` (config = source of truth): each resolution
|
| 271 |
-
# lists its self-configured models; the download list is derived from the cells. Global knobs override
|
| 272 |
-
# the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
|
| 273 |
-
# NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
|
| 274 |
-
# preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
|
| 275 |
-
from elastix_engine import ElastixRegistration
|
| 276 |
-
|
| 277 |
-
super().__init__(
|
| 278 |
-
in_channels=1,
|
| 279 |
-
optimizer=optimizer,
|
| 280 |
-
schedulers=schedulers,
|
| 281 |
-
outputs_criterions=outputs_criterions,
|
| 282 |
-
dim=3,
|
| 283 |
-
)
|
| 284 |
-
self.add_module(
|
| 285 |
-
"Registration",
|
| 286 |
-
ElastixRegistration(
|
| 287 |
-
engine,
|
| 288 |
-
parameter_maps,
|
| 289 |
-
max_iterations,
|
| 290 |
-
final_grid_spacing,
|
| 291 |
-
subset_features,
|
| 292 |
-
spatial_samples,
|
| 293 |
-
parameter_overrides,
|
| 294 |
-
resolutions,
|
| 295 |
-
mode,
|
| 296 |
-
),
|
| 297 |
-
in_branch=[0, 1, 2, 3],
|
| 298 |
-
out_branch=["registration"],
|
| 299 |
-
)
|
| 300 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 301 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MR_CT_HeadNeck/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.elastix:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
MR_CT_HeadNeck/elastix_engine.py
DELETED
|
@@ -1,386 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Elastix-IMPACT runtime for the registration bundle.
|
| 18 |
-
|
| 19 |
-
``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
|
| 20 |
-
parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
|
| 21 |
-
``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
|
| 22 |
-
images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
import os
|
| 26 |
-
import re
|
| 27 |
-
import shutil
|
| 28 |
-
import subprocess # nosec B404
|
| 29 |
-
import tempfile
|
| 30 |
-
from pathlib import Path
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
import SimpleITK as sitk
|
| 34 |
-
import torch
|
| 35 |
-
import tqdm
|
| 36 |
-
from huggingface_hub import hf_hub_download
|
| 37 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 38 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 39 |
-
|
| 40 |
-
from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
|
| 41 |
-
|
| 42 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 43 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 44 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 48 |
-
"""True only for a mask that actually restricts the metric region — some voxels in, some out. An
|
| 49 |
-
absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
|
| 50 |
-
is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
|
| 51 |
-
the whole image) instead of paying for a mask that restricts nothing."""
|
| 52 |
-
if mask is None:
|
| 53 |
-
return False
|
| 54 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 55 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class ElastixEngine:
|
| 59 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 60 |
-
|
| 61 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 62 |
-
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 63 |
-
"""
|
| 64 |
-
|
| 65 |
-
def __init__(
|
| 66 |
-
self,
|
| 67 |
-
parameter_maps: list[str],
|
| 68 |
-
max_iterations: int = 0,
|
| 69 |
-
final_grid_spacing: float = 0.0,
|
| 70 |
-
subset_features: int = 0,
|
| 71 |
-
spatial_samples: int = 0,
|
| 72 |
-
parameter_overrides: list[str] = [],
|
| 73 |
-
resolutions: dict = {},
|
| 74 |
-
mode: str = "Static",
|
| 75 |
-
) -> None:
|
| 76 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 77 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 78 |
-
self._max_iterations = max_iterations
|
| 79 |
-
self._final_grid_spacing = final_grid_spacing
|
| 80 |
-
self._subset_features = subset_features
|
| 81 |
-
self._spatial_samples = spatial_samples
|
| 82 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 83 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 84 |
-
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 85 |
-
self._mode = mode
|
| 86 |
-
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 87 |
-
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 88 |
-
self._resolutions = resolutions
|
| 89 |
-
self._registry = load_models_registry() if resolutions else {}
|
| 90 |
-
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 91 |
-
models: list[str] = []
|
| 92 |
-
for res in _sorted_specs(resolutions):
|
| 93 |
-
for model in _sorted_specs(res.models):
|
| 94 |
-
if model.ref not in models:
|
| 95 |
-
models.append(model.ref)
|
| 96 |
-
self._models = models
|
| 97 |
-
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 98 |
-
self._iterations = self._total_iterations()
|
| 99 |
-
self._elastix_bin = self._ensure_binary()
|
| 100 |
-
self._local_models = self._download_models()
|
| 101 |
-
|
| 102 |
-
def _total_iterations(self) -> int:
|
| 103 |
-
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 104 |
-
if self._resolutions:
|
| 105 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 106 |
-
total = 0
|
| 107 |
-
for src in self._parameter_maps:
|
| 108 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 109 |
-
if match:
|
| 110 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 111 |
-
return total
|
| 112 |
-
|
| 113 |
-
def _ensure_binary(self) -> Path:
|
| 114 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 115 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 116 |
-
if override:
|
| 117 |
-
try_elastix(Path(override))
|
| 118 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 119 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 120 |
-
try:
|
| 121 |
-
try_elastix(ELASTIX_CACHE)
|
| 122 |
-
except Exception:
|
| 123 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 124 |
-
try_elastix(ELASTIX_CACHE)
|
| 125 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 126 |
-
|
| 127 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 128 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 129 |
-
models = []
|
| 130 |
-
for ref in self._models:
|
| 131 |
-
repo, filename = ref.split(":", 1)
|
| 132 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 133 |
-
models.append((filename, local))
|
| 134 |
-
return models
|
| 135 |
-
|
| 136 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 137 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 138 |
-
|
| 139 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 140 |
-
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 141 |
-
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 142 |
-
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 143 |
-
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 144 |
-
"""
|
| 145 |
-
per_token: dict[str, str] = {}
|
| 146 |
-
if not global_only and self._max_iterations > 0:
|
| 147 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 148 |
-
if self._final_grid_spacing > 0:
|
| 149 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 150 |
-
if not global_only and self._subset_features > 0:
|
| 151 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 152 |
-
if self._spatial_samples > 0:
|
| 153 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 154 |
-
exact: list[tuple[str, str]] = []
|
| 155 |
-
for entry in self._parameter_overrides:
|
| 156 |
-
key, sep, value = entry.partition("=")
|
| 157 |
-
if not sep or not key.strip():
|
| 158 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 159 |
-
exact.append((key.strip(), value.strip()))
|
| 160 |
-
return per_token, exact
|
| 161 |
-
|
| 162 |
-
@staticmethod
|
| 163 |
-
def _apply_map_overrides(
|
| 164 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 165 |
-
) -> str:
|
| 166 |
-
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 167 |
-
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 168 |
-
"""
|
| 169 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 170 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 171 |
-
seen: set[str] = set()
|
| 172 |
-
lines = []
|
| 173 |
-
for line in text.splitlines():
|
| 174 |
-
match = entry_pattern.match(line)
|
| 175 |
-
if match:
|
| 176 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 177 |
-
if key == "ImpactGPU":
|
| 178 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 179 |
-
else:
|
| 180 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 181 |
-
if exact_value is not None:
|
| 182 |
-
seen.add(key)
|
| 183 |
-
line = f"{indent}({key} {exact_value})"
|
| 184 |
-
else:
|
| 185 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 186 |
-
if token_key in per_token:
|
| 187 |
-
seen.add(token_key)
|
| 188 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 189 |
-
line = f"{indent}({key} {replaced})"
|
| 190 |
-
lines.append(line)
|
| 191 |
-
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 192 |
-
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 193 |
-
for key in sorted(requested - seen):
|
| 194 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 195 |
-
return "\n".join(lines)
|
| 196 |
-
|
| 197 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 198 |
-
"""Stage the parameter maps into ``work``.
|
| 199 |
-
|
| 200 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 201 |
-
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 202 |
-
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 203 |
-
"""
|
| 204 |
-
staged = []
|
| 205 |
-
for src in self._parameter_maps:
|
| 206 |
-
if self._resolutions:
|
| 207 |
-
text = generate_impact_parameter_map(
|
| 208 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 209 |
-
)
|
| 210 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 211 |
-
else:
|
| 212 |
-
text = src.read_text(encoding="utf-8")
|
| 213 |
-
per_token, exact = self._parameter_map_overrides()
|
| 214 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 215 |
-
dst = work / src.name
|
| 216 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 217 |
-
staged.append(dst)
|
| 218 |
-
return staged
|
| 219 |
-
|
| 220 |
-
def register(
|
| 221 |
-
self,
|
| 222 |
-
fixed: sitk.Image,
|
| 223 |
-
moving: sitk.Image,
|
| 224 |
-
device_index: int,
|
| 225 |
-
fixed_mask: sitk.Image | None = None,
|
| 226 |
-
moving_mask: sitk.Image | None = None,
|
| 227 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 228 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 229 |
-
|
| 230 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 231 |
-
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 232 |
-
"""
|
| 233 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 234 |
-
try:
|
| 235 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 236 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 237 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 238 |
-
|
| 239 |
-
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 240 |
-
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 241 |
-
for rel_name, model_path in self._local_models:
|
| 242 |
-
dst = work / rel_name
|
| 243 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 244 |
-
if not dst.exists():
|
| 245 |
-
dst.symlink_to(model_path)
|
| 246 |
-
|
| 247 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 248 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 249 |
-
if _is_partial_mask(mask):
|
| 250 |
-
mask_path = work / name
|
| 251 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 252 |
-
args += [flag, str(mask_path)]
|
| 253 |
-
args += ["-out", str(work)]
|
| 254 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 255 |
-
args += ["-p", str(pmap)]
|
| 256 |
-
|
| 257 |
-
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 258 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 259 |
-
env = os.environ.copy()
|
| 260 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 261 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 262 |
-
proc = subprocess.Popen( # nosec B603
|
| 263 |
-
args,
|
| 264 |
-
cwd=str(work),
|
| 265 |
-
stdout=subprocess.PIPE,
|
| 266 |
-
stderr=subprocess.STDOUT,
|
| 267 |
-
text=True,
|
| 268 |
-
bufsize=1,
|
| 269 |
-
env=env,
|
| 270 |
-
)
|
| 271 |
-
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 272 |
-
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 273 |
-
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 274 |
-
captured: list[str] = []
|
| 275 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 276 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 277 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 278 |
-
assert proc.stdout is not None
|
| 279 |
-
resolution = 0
|
| 280 |
-
for line in proc.stdout:
|
| 281 |
-
captured.append(line)
|
| 282 |
-
stripped = line.strip()
|
| 283 |
-
if stripped.startswith("Resolution:"):
|
| 284 |
-
try:
|
| 285 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 286 |
-
except ValueError:
|
| 287 |
-
pass
|
| 288 |
-
elif iteration_line.match(line):
|
| 289 |
-
progress.update(1)
|
| 290 |
-
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 291 |
-
if len(columns) > 1:
|
| 292 |
-
try:
|
| 293 |
-
progress.set_description(
|
| 294 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 295 |
-
)
|
| 296 |
-
except ValueError:
|
| 297 |
-
pass
|
| 298 |
-
progress.close()
|
| 299 |
-
returncode = proc.wait()
|
| 300 |
-
if returncode != 0:
|
| 301 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 302 |
-
|
| 303 |
-
transforms = sorted(
|
| 304 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 305 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 306 |
-
)
|
| 307 |
-
if not transforms:
|
| 308 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 309 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 310 |
-
|
| 311 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 312 |
-
dvf = sitk.TransformToDisplacementField(
|
| 313 |
-
transform,
|
| 314 |
-
sitk.sitkVectorFloat64,
|
| 315 |
-
fixed.GetSize(),
|
| 316 |
-
fixed.GetOrigin(),
|
| 317 |
-
fixed.GetSpacing(),
|
| 318 |
-
fixed.GetDirection(),
|
| 319 |
-
)
|
| 320 |
-
moved_np, _ = image_to_data(moved)
|
| 321 |
-
dvf_np, _ = image_to_data(dvf)
|
| 322 |
-
return moved_np, dvf_np
|
| 323 |
-
finally:
|
| 324 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
class ElastixRegistration(torch.nn.Module):
|
| 328 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 329 |
-
|
| 330 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 331 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 332 |
-
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 333 |
-
"""
|
| 334 |
-
|
| 335 |
-
accepts_attributes = True
|
| 336 |
-
|
| 337 |
-
def __init__(
|
| 338 |
-
self,
|
| 339 |
-
engine: str,
|
| 340 |
-
parameter_maps: list[str],
|
| 341 |
-
max_iterations: int = 0,
|
| 342 |
-
final_grid_spacing: float = 0.0,
|
| 343 |
-
subset_features: int = 0,
|
| 344 |
-
spatial_samples: int = 0,
|
| 345 |
-
parameter_overrides: list[str] = [],
|
| 346 |
-
resolutions: dict = {},
|
| 347 |
-
mode: str = "Static",
|
| 348 |
-
) -> None:
|
| 349 |
-
super().__init__()
|
| 350 |
-
if engine != "elastix":
|
| 351 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 352 |
-
self._engine = ElastixEngine(
|
| 353 |
-
parameter_maps,
|
| 354 |
-
max_iterations,
|
| 355 |
-
final_grid_spacing,
|
| 356 |
-
subset_features,
|
| 357 |
-
spatial_samples,
|
| 358 |
-
parameter_overrides,
|
| 359 |
-
resolutions,
|
| 360 |
-
mode,
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
def forward(
|
| 364 |
-
self,
|
| 365 |
-
fixed: torch.Tensor,
|
| 366 |
-
moving: torch.Tensor,
|
| 367 |
-
fixed_mask: torch.Tensor,
|
| 368 |
-
moving_mask: torch.Tensor,
|
| 369 |
-
attributes: list[list[Attribute]],
|
| 370 |
-
) -> torch.Tensor:
|
| 371 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 372 |
-
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 373 |
-
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 374 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 375 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 376 |
-
combined = []
|
| 377 |
-
for b in range(fixed.shape[0]):
|
| 378 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 379 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 380 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 381 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 382 |
-
moved_np, dvf_np = self._engine.register(
|
| 383 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 384 |
-
)
|
| 385 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 386 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MR_CT_HeadNeck/install.py
DELETED
|
@@ -1,325 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
import argparse
|
| 18 |
-
import platform
|
| 19 |
-
import re
|
| 20 |
-
import shutil
|
| 21 |
-
import stat
|
| 22 |
-
import subprocess # nosec B404
|
| 23 |
-
import zipfile
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
|
| 26 |
-
import requests
|
| 27 |
-
from tqdm import tqdm
|
| 28 |
-
|
| 29 |
-
# -----------------------------------------------------------------------------
|
| 30 |
-
# Elastix + IMPACT binary assets hosted on GitHub Releases.
|
| 31 |
-
#
|
| 32 |
-
# Key format: (OS, ARCH, FLAVOR)
|
| 33 |
-
# - OS : platform.system() -> "Linux", "Windows", "Darwin"
|
| 34 |
-
# - ARCH : normalized architecture -> "x86_64"
|
| 35 |
-
# - FLAVOR : "cpu" or "cu128"
|
| 36 |
-
#
|
| 37 |
-
# CPU assets are standalone (LibTorch CPU bundled).
|
| 38 |
-
# CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
|
| 39 |
-
# -----------------------------------------------------------------------------
|
| 40 |
-
ELX_ASSET_TEMPLATE = {
|
| 41 |
-
("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
|
| 42 |
-
("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
|
| 43 |
-
("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
|
| 44 |
-
("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
|
| 45 |
-
("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
# -----------------------------------------------------------------------------
|
| 49 |
-
# Official LibTorch downloads (PyTorch).
|
| 50 |
-
#
|
| 51 |
-
# IMPORTANT:
|
| 52 |
-
# - Version MUST match the one used at build time (ABI compatibility).
|
| 53 |
-
# - Using "shared-with-deps" ensures CUDA runtime libraries are included
|
| 54 |
-
# (except the NVIDIA driver, which must be installed system-wide).
|
| 55 |
-
# -----------------------------------------------------------------------------
|
| 56 |
-
|
| 57 |
-
LIBTORCH_URL = {
|
| 58 |
-
(
|
| 59 |
-
"Linux",
|
| 60 |
-
"x86_64",
|
| 61 |
-
"cpu",
|
| 62 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 63 |
-
(
|
| 64 |
-
"Windows",
|
| 65 |
-
"x86_64",
|
| 66 |
-
"cpu",
|
| 67 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 68 |
-
(
|
| 69 |
-
"Darwin",
|
| 70 |
-
"arm64",
|
| 71 |
-
"cpu",
|
| 72 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 73 |
-
(
|
| 74 |
-
"Linux",
|
| 75 |
-
"x86_64",
|
| 76 |
-
"cu128",
|
| 77 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 78 |
-
(
|
| 79 |
-
"Windows",
|
| 80 |
-
"x86_64",
|
| 81 |
-
"cu128",
|
| 82 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
# -----------------------------------------------------------------------------
|
| 86 |
-
# Minimum NVIDIA driver versions required for CUDA 12.8.
|
| 87 |
-
#
|
| 88 |
-
# The CUDA Toolkit itself is NOT required.
|
| 89 |
-
# Only a sufficiently recent NVIDIA driver must be installed.
|
| 90 |
-
# -----------------------------------------------------------------------------
|
| 91 |
-
CUDA128_MIN_DRIVER_LINUX = (570, 26)
|
| 92 |
-
CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
|
| 93 |
-
|
| 94 |
-
GITHUB_OWNER = "vboussot"
|
| 95 |
-
GITHUB_REPO = "ImpactElastix"
|
| 96 |
-
GITHUB_TAG = "1.0.0"
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def run_cmd(cmd: list[str]) -> str:
|
| 103 |
-
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
|
| 107 |
-
"""
|
| 108 |
-
Detect presence of an NVIDIA GPU and extract the driver version.
|
| 109 |
-
Returns:
|
| 110 |
-
(True, (major, minor)) if detected
|
| 111 |
-
(False, None) if nvidia-smi is not available
|
| 112 |
-
"""
|
| 113 |
-
try:
|
| 114 |
-
nvidia_smi = shutil.which("nvidia-smi")
|
| 115 |
-
if nvidia_smi is None:
|
| 116 |
-
raise RuntimeError("nvidia-smi not found in PATH")
|
| 117 |
-
|
| 118 |
-
out = (
|
| 119 |
-
subprocess.check_output(
|
| 120 |
-
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
|
| 121 |
-
) # nosec B603
|
| 122 |
-
.decode("utf-8")
|
| 123 |
-
.strip()
|
| 124 |
-
)
|
| 125 |
-
except Exception:
|
| 126 |
-
return (False, None)
|
| 127 |
-
|
| 128 |
-
# Exemple: "575.64"
|
| 129 |
-
m = re.match(r"([0-9]+)\.([0-9]+)", out)
|
| 130 |
-
if not m:
|
| 131 |
-
return (True, None)
|
| 132 |
-
|
| 133 |
-
return (True, (int(m.group(1)), int(m.group(2))))
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
|
| 137 |
-
"""
|
| 138 |
-
Check whether the detected NVIDIA driver satisfies the minimum
|
| 139 |
-
requirement for CUDA 12.8 on the given operating system.
|
| 140 |
-
"""
|
| 141 |
-
if drv is None:
|
| 142 |
-
return False
|
| 143 |
-
if os_name == "Linux":
|
| 144 |
-
return drv >= CUDA128_MIN_DRIVER_LINUX
|
| 145 |
-
if os_name == "Windows":
|
| 146 |
-
return drv >= CUDA128_MIN_DRIVER_WINDOWS
|
| 147 |
-
return False
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def normalize_arch(machine: str) -> str:
|
| 151 |
-
"""
|
| 152 |
-
Normalize platform.machine() output across operating systems.
|
| 153 |
-
|
| 154 |
-
Examples:
|
| 155 |
-
AMD64 -> x86_64
|
| 156 |
-
aarch64 -> arm64
|
| 157 |
-
"""
|
| 158 |
-
m = machine.lower()
|
| 159 |
-
if m in ("x86_64", "amd64"):
|
| 160 |
-
return "x86_64"
|
| 161 |
-
if m in ("aarch64", "arm64"):
|
| 162 |
-
return "arm64"
|
| 163 |
-
return machine
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def download_file(url: str, dst: Path) -> None:
|
| 167 |
-
"""
|
| 168 |
-
Download a file from the given URL with a progress indicator.
|
| 169 |
-
"""
|
| 170 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
-
print(f"Downloading: {url}", flush=True)
|
| 172 |
-
print(f" to: {dst}", flush=True)
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
with requests.get(url, stream=True, timeout=60) as r:
|
| 176 |
-
r.raise_for_status()
|
| 177 |
-
total = int(r.headers.get("content-length", 0))
|
| 178 |
-
with open(dst, "wb") as f:
|
| 179 |
-
with tqdm(
|
| 180 |
-
total=total,
|
| 181 |
-
unit="B",
|
| 182 |
-
unit_scale=True,
|
| 183 |
-
desc=f"Downloading {dst.name}",
|
| 184 |
-
) as pbar:
|
| 185 |
-
for chunk in r.iter_content(chunk_size=8192):
|
| 186 |
-
f.write(chunk)
|
| 187 |
-
pbar.update(len(chunk))
|
| 188 |
-
except Exception as e:
|
| 189 |
-
raise e
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def extract_archive(archive: Path, dst_dir: Path) -> None:
|
| 193 |
-
"""
|
| 194 |
-
Extract a ZIP archive to the destination directory.
|
| 195 |
-
"""
|
| 196 |
-
dst_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
-
print(f"Extracting: {archive} -> {dst_dir}", flush=True)
|
| 198 |
-
with zipfile.ZipFile(archive, "r") as z:
|
| 199 |
-
z.extractall(dst_dir)
|
| 200 |
-
archive.unlink()
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
|
| 204 |
-
os_name = platform.system()
|
| 205 |
-
arch = normalize_arch(platform.machine())
|
| 206 |
-
has_nvidia, drv = detect_nvidia_driver()
|
| 207 |
-
|
| 208 |
-
if os_name not in ("Linux", "Windows", "Darwin"):
|
| 209 |
-
raise NameError(f"Unsupported OS: {os_name}")
|
| 210 |
-
|
| 211 |
-
if arch not in ("x86_64", "arm64"):
|
| 212 |
-
raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
|
| 213 |
-
|
| 214 |
-
flavor = "cpu"
|
| 215 |
-
if force_cuda:
|
| 216 |
-
if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
|
| 217 |
-
raise NameError(
|
| 218 |
-
"CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
flavor = "cu128"
|
| 222 |
-
elif not force_cpu:
|
| 223 |
-
if has_nvidia and driver_ok_for_cuda(os_name, drv):
|
| 224 |
-
flavor = "cu128"
|
| 225 |
-
else:
|
| 226 |
-
flavor = "cpu"
|
| 227 |
-
|
| 228 |
-
print(f"System: {os_name} {arch}", flush=True)
|
| 229 |
-
print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
|
| 230 |
-
print(f"Selected flavor: {flavor}", flush=True)
|
| 231 |
-
|
| 232 |
-
key = (os_name, arch, flavor)
|
| 233 |
-
if key not in ELX_ASSET_TEMPLATE:
|
| 234 |
-
raise NameError(f"No elastix asset configured for {key}")
|
| 235 |
-
if flavor != "cpu" and key not in LIBTORCH_URL:
|
| 236 |
-
raise NameError(f"No libtorch url configured for {key}")
|
| 237 |
-
|
| 238 |
-
install_path = install_path.resolve()
|
| 239 |
-
install_path.mkdir(parents=True, exist_ok=True)
|
| 240 |
-
|
| 241 |
-
elx_asset = ELX_ASSET_TEMPLATE[key]
|
| 242 |
-
elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
|
| 243 |
-
elx_archive = install_path / elx_asset
|
| 244 |
-
download_file(elx_url, elx_archive)
|
| 245 |
-
extract_archive(elx_archive, install_path)
|
| 246 |
-
|
| 247 |
-
# -------------------------------------------------------------------------
|
| 248 |
-
# ZIP archives may drop executable permissions.
|
| 249 |
-
# Ensure elastix and transformix are executable on Unix platforms.
|
| 250 |
-
# -------------------------------------------------------------------------
|
| 251 |
-
if os_name in ("Linux", "Darwin"):
|
| 252 |
-
for exe in ("elastix", "transformix"):
|
| 253 |
-
p = install_path / "bin" / exe
|
| 254 |
-
if p.exists():
|
| 255 |
-
p.chmod(p.stat().st_mode | stat.S_IEXEC)
|
| 256 |
-
|
| 257 |
-
# ---------------------------------------------------------------------
|
| 258 |
-
# - Download matching LibTorch 12.8 with or without CUDA 12.8
|
| 259 |
-
# - Extract it
|
| 260 |
-
# - Move runtime libraries to a location visible to the dynamic loader
|
| 261 |
-
#
|
| 262 |
-
# Linux / macOS : prefix/lib
|
| 263 |
-
# Windows : prefix (next to elastix.exe)
|
| 264 |
-
# ---------------------------------------------------------------------
|
| 265 |
-
lt_url = LIBTORCH_URL[key]
|
| 266 |
-
lt_archive = install_path / Path(lt_url).name
|
| 267 |
-
download_file(lt_url, lt_archive)
|
| 268 |
-
extract_archive(lt_archive, install_path)
|
| 269 |
-
for p in (install_path / "libtorch" / "lib").iterdir():
|
| 270 |
-
if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
|
| 271 |
-
shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
|
| 272 |
-
|
| 273 |
-
shutil.rmtree(install_path / "libtorch")
|
| 274 |
-
if os_name == "Linux" and flavor == "cu128":
|
| 275 |
-
shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def get_elastix_bin(install_path: Path) -> Path:
|
| 279 |
-
return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def try_elastix(install_path: Path) -> None:
|
| 283 |
-
try:
|
| 284 |
-
subprocess.run(
|
| 285 |
-
[str(get_elastix_bin(install_path)), "-h"],
|
| 286 |
-
capture_output=True,
|
| 287 |
-
text=True,
|
| 288 |
-
check=True,
|
| 289 |
-
) # nosec B603
|
| 290 |
-
except subprocess.CalledProcessError as e:
|
| 291 |
-
msg = "Elastix execution failed.\n\n"
|
| 292 |
-
|
| 293 |
-
msg += f"Command:\n{' '.join(e.cmd)}\n"
|
| 294 |
-
msg += f"Return code: {e.returncode}\n\n"
|
| 295 |
-
|
| 296 |
-
if e.stderr:
|
| 297 |
-
msg += "Error output:\n"
|
| 298 |
-
msg += e.stderr.strip()
|
| 299 |
-
raise NameError(msg)
|
| 300 |
-
|
| 301 |
-
except OSError as e:
|
| 302 |
-
msg = (
|
| 303 |
-
"Elastix could not be started.\n\n"
|
| 304 |
-
"This is usually caused by missing shared libraries "
|
| 305 |
-
"(e.g. LibTorch or CUDA runtime).\n\n"
|
| 306 |
-
f"System error:\n{str(e)}"
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
raise NameError(msg)
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def main() -> None:
|
| 313 |
-
ap = argparse.ArgumentParser()
|
| 314 |
-
ap.add_argument(
|
| 315 |
-
"--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
|
| 316 |
-
)
|
| 317 |
-
group = ap.add_mutually_exclusive_group()
|
| 318 |
-
group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
|
| 319 |
-
group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
|
| 320 |
-
args = vars(ap.parse_args())
|
| 321 |
-
install_elastix_impact(**args)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if __name__ == "__main__":
|
| 325 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MR_CT_HeadNeck/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
impact_reg_konfai>=1.6.1
|
MR_CT_MRSeg/Model.py
DELETED
|
@@ -1,301 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
|
| 18 |
-
|
| 19 |
-
``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
|
| 20 |
-
2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
|
| 21 |
-
the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
|
| 22 |
-
the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
|
| 23 |
-
subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
|
| 24 |
-
|
| 25 |
-
A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
|
| 26 |
-
``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
|
| 27 |
-
|
| 28 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
|
| 29 |
-
(``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import os
|
| 34 |
-
import re
|
| 35 |
-
from dataclasses import dataclass, field
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import Annotated, Literal
|
| 38 |
-
|
| 39 |
-
import torch
|
| 40 |
-
from huggingface_hub import hf_hub_download
|
| 41 |
-
from konfai.network import network
|
| 42 |
-
from konfai.utils.config import Choices, Range
|
| 43 |
-
|
| 44 |
-
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 45 |
-
# A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
|
| 46 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
|
| 47 |
-
# iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
|
| 48 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 49 |
-
|
| 50 |
-
# ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
|
| 51 |
-
# Static anyway; in Jacobian we clamp ``l`` to this plateau.
|
| 52 |
-
_FOV_RAMP_MAX_LAYER = 6
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry_choices() -> list[str]:
|
| 56 |
-
"""The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
|
| 57 |
-
(offline-first). A user may still point ``ref`` at a local model."""
|
| 58 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 59 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _num(x: object) -> str:
|
| 63 |
-
"""Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
|
| 64 |
-
return "%g" % float(x)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
@dataclass
|
| 68 |
-
class ModelSpec:
|
| 69 |
-
"""One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
|
| 70 |
-
rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
|
| 71 |
-
(``models.json``) keyed by ``ref`` — never tuned."""
|
| 72 |
-
|
| 73 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 74 |
-
voxel_size: list[float] = field(default_factory=list)
|
| 75 |
-
layers_weight: list[float] = field(default_factory=lambda: [1.0])
|
| 76 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0
|
| 77 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 78 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 79 |
-
layers_mask: str = ""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
@dataclass
|
| 83 |
-
class ResolutionSpec:
|
| 84 |
-
"""One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
|
| 85 |
-
|
| 86 |
-
max_iterations: Annotated[int, Range(1, 100000)]
|
| 87 |
-
models: dict[str, ModelSpec]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 91 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order."""
|
| 92 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 96 |
-
"""Load models.json (the fixed params per model) from the model repo on Hugging Face.
|
| 97 |
-
|
| 98 |
-
The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
|
| 99 |
-
dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
|
| 100 |
-
"""
|
| 101 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 102 |
-
if local:
|
| 103 |
-
path = Path(local)
|
| 104 |
-
elif ":" in ref:
|
| 105 |
-
repo, filename = ref.split(":", 1)
|
| 106 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(
|
| 109 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
|
| 110 |
-
f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 111 |
-
)
|
| 112 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _model_key(ref: str) -> str:
|
| 116 |
-
"""Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
|
| 117 |
-
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _deepest_active_layer(layers_mask: str) -> int:
|
| 121 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
|
| 122 |
-
|
| 123 |
-
A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
|
| 124 |
-
== ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
|
| 125 |
-
receptive field, so the FOV is governed by the rightmost ``'1'``.
|
| 126 |
-
"""
|
| 127 |
-
mask = layers_mask.strip().strip('"')
|
| 128 |
-
active = [i for i, char in enumerate(mask) if char == "1"]
|
| 129 |
-
if not active:
|
| 130 |
-
raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
|
| 131 |
-
return max(active)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 135 |
-
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 136 |
-
|
| 137 |
-
Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
|
| 138 |
-
``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
|
| 139 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
|
| 140 |
-
to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
|
| 141 |
-
a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
|
| 142 |
-
``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
|
| 143 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut.
|
| 144 |
-
"""
|
| 145 |
-
formula = str(fov.get("formula", "")).strip()
|
| 146 |
-
key = re.sub(r"\s+", "", formula).lower()
|
| 147 |
-
if key.isdigit():
|
| 148 |
-
return int(key)
|
| 149 |
-
if key == "2*r*d+1":
|
| 150 |
-
return 2 * int(fov["r"]) * int(fov["d"]) + 1
|
| 151 |
-
if key == "2^l+3":
|
| 152 |
-
return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
|
| 153 |
-
if "global" in key:
|
| 154 |
-
raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
|
| 155 |
-
if fov.get("value") is not None:
|
| 156 |
-
return int(fov["value"])
|
| 157 |
-
raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 161 |
-
"""PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
|
| 162 |
-
image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
|
| 163 |
-
e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
|
| 164 |
-
dim = int(entry.get("dimension", 3))
|
| 165 |
-
if mode.strip().strip('"').lower() != "jacobian":
|
| 166 |
-
return " ".join(["0"] * dim)
|
| 167 |
-
fov = _fov_value(entry.get("fov", {}), layers_mask)
|
| 168 |
-
return " ".join([str(fov)] * dim)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
|
| 172 |
-
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 173 |
-
|
| 174 |
-
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 175 |
-
ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
|
| 176 |
-
is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
|
| 177 |
-
from the registry formula and the cell's ``layers_mask``.
|
| 178 |
-
"""
|
| 179 |
-
res = _sorted_specs(resolutions)
|
| 180 |
-
n = len(res)
|
| 181 |
-
mode_clean = mode.strip().strip('"') or "Static"
|
| 182 |
-
|
| 183 |
-
impact: list[str] = []
|
| 184 |
-
for k, r in enumerate(res):
|
| 185 |
-
models = _sorted_specs(r.models)
|
| 186 |
-
entries = [registry[_model_key(m.ref)] for m in models]
|
| 187 |
-
|
| 188 |
-
def row(stem: str, values: list[str]) -> None:
|
| 189 |
-
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 190 |
-
|
| 191 |
-
# From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
|
| 192 |
-
# model FOV); everything else is a per-model knob taken straight from the cell.
|
| 193 |
-
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 194 |
-
row("Dimension", [e["dimension"] for e in entries])
|
| 195 |
-
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
| 196 |
-
row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
|
| 197 |
-
row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
|
| 198 |
-
row("LayersMask", [f'"{m.layers_mask}"' for m in models])
|
| 199 |
-
row("SubsetFeatures", [str(m.subset_features) for m in models])
|
| 200 |
-
row("PCA", [str(m.pca) for m in models])
|
| 201 |
-
row("Distance", [f'"{m.distance}"' for m in models])
|
| 202 |
-
row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
|
| 203 |
-
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 204 |
-
|
| 205 |
-
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 206 |
-
# (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
|
| 207 |
-
lines = template_text.splitlines()
|
| 208 |
-
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 209 |
-
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
| 210 |
-
block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
|
| 211 |
-
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for i, (m, line) in enumerate(indexed):
|
| 214 |
-
key = m.group(1) if m else None
|
| 215 |
-
if block_lo <= i <= block_hi:
|
| 216 |
-
if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
|
| 217 |
-
out.extend(impact[:-1])
|
| 218 |
-
elif key == "MaximumNumberOfIterations":
|
| 219 |
-
out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
|
| 220 |
-
elif key == "NumberOfResolutions":
|
| 221 |
-
out.append(f"(NumberOfResolutions {n})")
|
| 222 |
-
elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
|
| 223 |
-
out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
|
| 224 |
-
elif key == "ImpactMode":
|
| 225 |
-
out.append(f'(ImpactMode "{mode_clean}")')
|
| 226 |
-
else:
|
| 227 |
-
out.append(line)
|
| 228 |
-
return "\n".join(out)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class ChannelSelect(torch.nn.Module):
|
| 232 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 235 |
-
super().__init__()
|
| 236 |
-
self._start = start
|
| 237 |
-
self._stop = stop
|
| 238 |
-
|
| 239 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 240 |
-
return tensor[:, self._start : self._stop]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
class RegistrationNet(network.Network):
|
| 244 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
|
| 245 |
-
moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 246 |
-
|
| 247 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 248 |
-
(the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
|
| 249 |
-
``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
|
| 250 |
-
``same_as_group: Volume_0:Fixed``.
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
def __init__(
|
| 254 |
-
self,
|
| 255 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 256 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 257 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 258 |
-
},
|
| 259 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 260 |
-
engine: str = "elastix",
|
| 261 |
-
parameter_maps: list[str] = [],
|
| 262 |
-
max_iterations: Annotated[int, Range(0, 100000)] = 0,
|
| 263 |
-
final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
|
| 264 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0,
|
| 265 |
-
spatial_samples: Annotated[int, Range(0, 100000)] = 0,
|
| 266 |
-
parameter_overrides: list[str] = [],
|
| 267 |
-
resolutions: dict[str, ResolutionSpec] = {},
|
| 268 |
-
mode: Literal["Static", "Jacobian"] = "Static",
|
| 269 |
-
) -> None:
|
| 270 |
-
# The registration is fully described by ``resolutions`` (config = source of truth): each resolution
|
| 271 |
-
# lists its self-configured models; the download list is derived from the cells. Global knobs override
|
| 272 |
-
# the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
|
| 273 |
-
# NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
|
| 274 |
-
# preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
|
| 275 |
-
from elastix_engine import ElastixRegistration
|
| 276 |
-
|
| 277 |
-
super().__init__(
|
| 278 |
-
in_channels=1,
|
| 279 |
-
optimizer=optimizer,
|
| 280 |
-
schedulers=schedulers,
|
| 281 |
-
outputs_criterions=outputs_criterions,
|
| 282 |
-
dim=3,
|
| 283 |
-
)
|
| 284 |
-
self.add_module(
|
| 285 |
-
"Registration",
|
| 286 |
-
ElastixRegistration(
|
| 287 |
-
engine,
|
| 288 |
-
parameter_maps,
|
| 289 |
-
max_iterations,
|
| 290 |
-
final_grid_spacing,
|
| 291 |
-
subset_features,
|
| 292 |
-
spatial_samples,
|
| 293 |
-
parameter_overrides,
|
| 294 |
-
resolutions,
|
| 295 |
-
mode,
|
| 296 |
-
),
|
| 297 |
-
in_branch=[0, 1, 2, 3],
|
| 298 |
-
out_branch=["registration"],
|
| 299 |
-
)
|
| 300 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 301 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MR_CT_MRSeg/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.elastix:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
MR_CT_MRSeg/elastix_engine.py
DELETED
|
@@ -1,386 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Elastix-IMPACT runtime for the registration bundle.
|
| 18 |
-
|
| 19 |
-
``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
|
| 20 |
-
parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
|
| 21 |
-
``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
|
| 22 |
-
images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
import os
|
| 26 |
-
import re
|
| 27 |
-
import shutil
|
| 28 |
-
import subprocess # nosec B404
|
| 29 |
-
import tempfile
|
| 30 |
-
from pathlib import Path
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
import SimpleITK as sitk
|
| 34 |
-
import torch
|
| 35 |
-
import tqdm
|
| 36 |
-
from huggingface_hub import hf_hub_download
|
| 37 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 38 |
-
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 39 |
-
|
| 40 |
-
from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
|
| 41 |
-
|
| 42 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 43 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 44 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
|
| 48 |
-
"""True only for a mask that actually restricts the metric region — some voxels in, some out. An
|
| 49 |
-
absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
|
| 50 |
-
is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
|
| 51 |
-
the whole image) instead of paying for a mask that restricts nothing."""
|
| 52 |
-
if mask is None:
|
| 53 |
-
return False
|
| 54 |
-
arr = sitk.GetArrayViewFromImage(mask)
|
| 55 |
-
return bool((arr > 0).any()) and bool((arr == 0).any())
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class ElastixEngine:
|
| 59 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 60 |
-
|
| 61 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 62 |
-
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 63 |
-
"""
|
| 64 |
-
|
| 65 |
-
def __init__(
|
| 66 |
-
self,
|
| 67 |
-
parameter_maps: list[str],
|
| 68 |
-
max_iterations: int = 0,
|
| 69 |
-
final_grid_spacing: float = 0.0,
|
| 70 |
-
subset_features: int = 0,
|
| 71 |
-
spatial_samples: int = 0,
|
| 72 |
-
parameter_overrides: list[str] = [],
|
| 73 |
-
resolutions: dict = {},
|
| 74 |
-
mode: str = "Static",
|
| 75 |
-
) -> None:
|
| 76 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 77 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 78 |
-
self._max_iterations = max_iterations
|
| 79 |
-
self._final_grid_spacing = final_grid_spacing
|
| 80 |
-
self._subset_features = subset_features
|
| 81 |
-
self._spatial_samples = spatial_samples
|
| 82 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 83 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 84 |
-
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 85 |
-
self._mode = mode
|
| 86 |
-
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 87 |
-
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 88 |
-
self._resolutions = resolutions
|
| 89 |
-
self._registry = load_models_registry() if resolutions else {}
|
| 90 |
-
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 91 |
-
models: list[str] = []
|
| 92 |
-
for res in _sorted_specs(resolutions):
|
| 93 |
-
for model in _sorted_specs(res.models):
|
| 94 |
-
if model.ref not in models:
|
| 95 |
-
models.append(model.ref)
|
| 96 |
-
self._models = models
|
| 97 |
-
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 98 |
-
self._iterations = self._total_iterations()
|
| 99 |
-
self._elastix_bin = self._ensure_binary()
|
| 100 |
-
self._local_models = self._download_models()
|
| 101 |
-
|
| 102 |
-
def _total_iterations(self) -> int:
|
| 103 |
-
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 104 |
-
if self._resolutions:
|
| 105 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 106 |
-
total = 0
|
| 107 |
-
for src in self._parameter_maps:
|
| 108 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 109 |
-
if match:
|
| 110 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 111 |
-
return total
|
| 112 |
-
|
| 113 |
-
def _ensure_binary(self) -> Path:
|
| 114 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 115 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 116 |
-
if override:
|
| 117 |
-
try_elastix(Path(override))
|
| 118 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 119 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 120 |
-
try:
|
| 121 |
-
try_elastix(ELASTIX_CACHE)
|
| 122 |
-
except Exception:
|
| 123 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 124 |
-
try_elastix(ELASTIX_CACHE)
|
| 125 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 126 |
-
|
| 127 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 128 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 129 |
-
models = []
|
| 130 |
-
for ref in self._models:
|
| 131 |
-
repo, filename = ref.split(":", 1)
|
| 132 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 133 |
-
models.append((filename, local))
|
| 134 |
-
return models
|
| 135 |
-
|
| 136 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 137 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 138 |
-
|
| 139 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 140 |
-
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 141 |
-
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 142 |
-
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 143 |
-
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 144 |
-
"""
|
| 145 |
-
per_token: dict[str, str] = {}
|
| 146 |
-
if not global_only and self._max_iterations > 0:
|
| 147 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 148 |
-
if self._final_grid_spacing > 0:
|
| 149 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 150 |
-
if not global_only and self._subset_features > 0:
|
| 151 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 152 |
-
if self._spatial_samples > 0:
|
| 153 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 154 |
-
exact: list[tuple[str, str]] = []
|
| 155 |
-
for entry in self._parameter_overrides:
|
| 156 |
-
key, sep, value = entry.partition("=")
|
| 157 |
-
if not sep or not key.strip():
|
| 158 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 159 |
-
exact.append((key.strip(), value.strip()))
|
| 160 |
-
return per_token, exact
|
| 161 |
-
|
| 162 |
-
@staticmethod
|
| 163 |
-
def _apply_map_overrides(
|
| 164 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 165 |
-
) -> str:
|
| 166 |
-
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 167 |
-
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 168 |
-
"""
|
| 169 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 170 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 171 |
-
seen: set[str] = set()
|
| 172 |
-
lines = []
|
| 173 |
-
for line in text.splitlines():
|
| 174 |
-
match = entry_pattern.match(line)
|
| 175 |
-
if match:
|
| 176 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 177 |
-
if key == "ImpactGPU":
|
| 178 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 179 |
-
else:
|
| 180 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 181 |
-
if exact_value is not None:
|
| 182 |
-
seen.add(key)
|
| 183 |
-
line = f"{indent}({key} {exact_value})"
|
| 184 |
-
else:
|
| 185 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 186 |
-
if token_key in per_token:
|
| 187 |
-
seen.add(token_key)
|
| 188 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 189 |
-
line = f"{indent}({key} {replaced})"
|
| 190 |
-
lines.append(line)
|
| 191 |
-
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 192 |
-
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 193 |
-
for key in sorted(requested - seen):
|
| 194 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 195 |
-
return "\n".join(lines)
|
| 196 |
-
|
| 197 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 198 |
-
"""Stage the parameter maps into ``work``.
|
| 199 |
-
|
| 200 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 201 |
-
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 202 |
-
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 203 |
-
"""
|
| 204 |
-
staged = []
|
| 205 |
-
for src in self._parameter_maps:
|
| 206 |
-
if self._resolutions:
|
| 207 |
-
text = generate_impact_parameter_map(
|
| 208 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 209 |
-
)
|
| 210 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 211 |
-
else:
|
| 212 |
-
text = src.read_text(encoding="utf-8")
|
| 213 |
-
per_token, exact = self._parameter_map_overrides()
|
| 214 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 215 |
-
dst = work / src.name
|
| 216 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 217 |
-
staged.append(dst)
|
| 218 |
-
return staged
|
| 219 |
-
|
| 220 |
-
def register(
|
| 221 |
-
self,
|
| 222 |
-
fixed: sitk.Image,
|
| 223 |
-
moving: sitk.Image,
|
| 224 |
-
device_index: int,
|
| 225 |
-
fixed_mask: sitk.Image | None = None,
|
| 226 |
-
moving_mask: sitk.Image | None = None,
|
| 227 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 228 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 229 |
-
|
| 230 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 231 |
-
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 232 |
-
"""
|
| 233 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 234 |
-
try:
|
| 235 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 236 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 237 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 238 |
-
|
| 239 |
-
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 240 |
-
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 241 |
-
for rel_name, model_path in self._local_models:
|
| 242 |
-
dst = work / rel_name
|
| 243 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 244 |
-
if not dst.exists():
|
| 245 |
-
dst.symlink_to(model_path)
|
| 246 |
-
|
| 247 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 248 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 249 |
-
if _is_partial_mask(mask):
|
| 250 |
-
mask_path = work / name
|
| 251 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 252 |
-
args += [flag, str(mask_path)]
|
| 253 |
-
args += ["-out", str(work)]
|
| 254 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 255 |
-
args += ["-p", str(pmap)]
|
| 256 |
-
|
| 257 |
-
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 258 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 259 |
-
env = os.environ.copy()
|
| 260 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 261 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 262 |
-
proc = subprocess.Popen( # nosec B603
|
| 263 |
-
args,
|
| 264 |
-
cwd=str(work),
|
| 265 |
-
stdout=subprocess.PIPE,
|
| 266 |
-
stderr=subprocess.STDOUT,
|
| 267 |
-
text=True,
|
| 268 |
-
bufsize=1,
|
| 269 |
-
env=env,
|
| 270 |
-
)
|
| 271 |
-
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 272 |
-
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 273 |
-
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 274 |
-
captured: list[str] = []
|
| 275 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 276 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 277 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 278 |
-
assert proc.stdout is not None
|
| 279 |
-
resolution = 0
|
| 280 |
-
for line in proc.stdout:
|
| 281 |
-
captured.append(line)
|
| 282 |
-
stripped = line.strip()
|
| 283 |
-
if stripped.startswith("Resolution:"):
|
| 284 |
-
try:
|
| 285 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 286 |
-
except ValueError:
|
| 287 |
-
pass
|
| 288 |
-
elif iteration_line.match(line):
|
| 289 |
-
progress.update(1)
|
| 290 |
-
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 291 |
-
if len(columns) > 1:
|
| 292 |
-
try:
|
| 293 |
-
progress.set_description(
|
| 294 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 295 |
-
)
|
| 296 |
-
except ValueError:
|
| 297 |
-
pass
|
| 298 |
-
progress.close()
|
| 299 |
-
returncode = proc.wait()
|
| 300 |
-
if returncode != 0:
|
| 301 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 302 |
-
|
| 303 |
-
transforms = sorted(
|
| 304 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 305 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 306 |
-
)
|
| 307 |
-
if not transforms:
|
| 308 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 309 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 310 |
-
|
| 311 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 312 |
-
dvf = sitk.TransformToDisplacementField(
|
| 313 |
-
transform,
|
| 314 |
-
sitk.sitkVectorFloat64,
|
| 315 |
-
fixed.GetSize(),
|
| 316 |
-
fixed.GetOrigin(),
|
| 317 |
-
fixed.GetSpacing(),
|
| 318 |
-
fixed.GetDirection(),
|
| 319 |
-
)
|
| 320 |
-
moved_np, _ = image_to_data(moved)
|
| 321 |
-
dvf_np, _ = image_to_data(dvf)
|
| 322 |
-
return moved_np, dvf_np
|
| 323 |
-
finally:
|
| 324 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
class ElastixRegistration(torch.nn.Module):
|
| 328 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 329 |
-
|
| 330 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 331 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 332 |
-
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 333 |
-
"""
|
| 334 |
-
|
| 335 |
-
accepts_attributes = True
|
| 336 |
-
|
| 337 |
-
def __init__(
|
| 338 |
-
self,
|
| 339 |
-
engine: str,
|
| 340 |
-
parameter_maps: list[str],
|
| 341 |
-
max_iterations: int = 0,
|
| 342 |
-
final_grid_spacing: float = 0.0,
|
| 343 |
-
subset_features: int = 0,
|
| 344 |
-
spatial_samples: int = 0,
|
| 345 |
-
parameter_overrides: list[str] = [],
|
| 346 |
-
resolutions: dict = {},
|
| 347 |
-
mode: str = "Static",
|
| 348 |
-
) -> None:
|
| 349 |
-
super().__init__()
|
| 350 |
-
if engine != "elastix":
|
| 351 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 352 |
-
self._engine = ElastixEngine(
|
| 353 |
-
parameter_maps,
|
| 354 |
-
max_iterations,
|
| 355 |
-
final_grid_spacing,
|
| 356 |
-
subset_features,
|
| 357 |
-
spatial_samples,
|
| 358 |
-
parameter_overrides,
|
| 359 |
-
resolutions,
|
| 360 |
-
mode,
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
def forward(
|
| 364 |
-
self,
|
| 365 |
-
fixed: torch.Tensor,
|
| 366 |
-
moving: torch.Tensor,
|
| 367 |
-
fixed_mask: torch.Tensor,
|
| 368 |
-
moving_mask: torch.Tensor,
|
| 369 |
-
attributes: list[list[Attribute]],
|
| 370 |
-
) -> torch.Tensor:
|
| 371 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 372 |
-
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 373 |
-
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 374 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 375 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 376 |
-
combined = []
|
| 377 |
-
for b in range(fixed.shape[0]):
|
| 378 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 379 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 380 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 381 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 382 |
-
moved_np, dvf_np = self._engine.register(
|
| 383 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 384 |
-
)
|
| 385 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 386 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MR_CT_MRSeg/install.py
DELETED
|
@@ -1,325 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
import argparse
|
| 18 |
-
import platform
|
| 19 |
-
import re
|
| 20 |
-
import shutil
|
| 21 |
-
import stat
|
| 22 |
-
import subprocess # nosec B404
|
| 23 |
-
import zipfile
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
|
| 26 |
-
import requests
|
| 27 |
-
from tqdm import tqdm
|
| 28 |
-
|
| 29 |
-
# -----------------------------------------------------------------------------
|
| 30 |
-
# Elastix + IMPACT binary assets hosted on GitHub Releases.
|
| 31 |
-
#
|
| 32 |
-
# Key format: (OS, ARCH, FLAVOR)
|
| 33 |
-
# - OS : platform.system() -> "Linux", "Windows", "Darwin"
|
| 34 |
-
# - ARCH : normalized architecture -> "x86_64"
|
| 35 |
-
# - FLAVOR : "cpu" or "cu128"
|
| 36 |
-
#
|
| 37 |
-
# CPU assets are standalone (LibTorch CPU bundled).
|
| 38 |
-
# CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
|
| 39 |
-
# -----------------------------------------------------------------------------
|
| 40 |
-
ELX_ASSET_TEMPLATE = {
|
| 41 |
-
("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
|
| 42 |
-
("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
|
| 43 |
-
("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
|
| 44 |
-
("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
|
| 45 |
-
("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
# -----------------------------------------------------------------------------
|
| 49 |
-
# Official LibTorch downloads (PyTorch).
|
| 50 |
-
#
|
| 51 |
-
# IMPORTANT:
|
| 52 |
-
# - Version MUST match the one used at build time (ABI compatibility).
|
| 53 |
-
# - Using "shared-with-deps" ensures CUDA runtime libraries are included
|
| 54 |
-
# (except the NVIDIA driver, which must be installed system-wide).
|
| 55 |
-
# -----------------------------------------------------------------------------
|
| 56 |
-
|
| 57 |
-
LIBTORCH_URL = {
|
| 58 |
-
(
|
| 59 |
-
"Linux",
|
| 60 |
-
"x86_64",
|
| 61 |
-
"cpu",
|
| 62 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 63 |
-
(
|
| 64 |
-
"Windows",
|
| 65 |
-
"x86_64",
|
| 66 |
-
"cpu",
|
| 67 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 68 |
-
(
|
| 69 |
-
"Darwin",
|
| 70 |
-
"arm64",
|
| 71 |
-
"cpu",
|
| 72 |
-
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
|
| 73 |
-
(
|
| 74 |
-
"Linux",
|
| 75 |
-
"x86_64",
|
| 76 |
-
"cu128",
|
| 77 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 78 |
-
(
|
| 79 |
-
"Windows",
|
| 80 |
-
"x86_64",
|
| 81 |
-
"cu128",
|
| 82 |
-
): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
# -----------------------------------------------------------------------------
|
| 86 |
-
# Minimum NVIDIA driver versions required for CUDA 12.8.
|
| 87 |
-
#
|
| 88 |
-
# The CUDA Toolkit itself is NOT required.
|
| 89 |
-
# Only a sufficiently recent NVIDIA driver must be installed.
|
| 90 |
-
# -----------------------------------------------------------------------------
|
| 91 |
-
CUDA128_MIN_DRIVER_LINUX = (570, 26)
|
| 92 |
-
CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
|
| 93 |
-
|
| 94 |
-
GITHUB_OWNER = "vboussot"
|
| 95 |
-
GITHUB_REPO = "ImpactElastix"
|
| 96 |
-
GITHUB_TAG = "1.0.0"
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def run_cmd(cmd: list[str]) -> str:
|
| 103 |
-
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
|
| 107 |
-
"""
|
| 108 |
-
Detect presence of an NVIDIA GPU and extract the driver version.
|
| 109 |
-
Returns:
|
| 110 |
-
(True, (major, minor)) if detected
|
| 111 |
-
(False, None) if nvidia-smi is not available
|
| 112 |
-
"""
|
| 113 |
-
try:
|
| 114 |
-
nvidia_smi = shutil.which("nvidia-smi")
|
| 115 |
-
if nvidia_smi is None:
|
| 116 |
-
raise RuntimeError("nvidia-smi not found in PATH")
|
| 117 |
-
|
| 118 |
-
out = (
|
| 119 |
-
subprocess.check_output(
|
| 120 |
-
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
|
| 121 |
-
) # nosec B603
|
| 122 |
-
.decode("utf-8")
|
| 123 |
-
.strip()
|
| 124 |
-
)
|
| 125 |
-
except Exception:
|
| 126 |
-
return (False, None)
|
| 127 |
-
|
| 128 |
-
# Exemple: "575.64"
|
| 129 |
-
m = re.match(r"([0-9]+)\.([0-9]+)", out)
|
| 130 |
-
if not m:
|
| 131 |
-
return (True, None)
|
| 132 |
-
|
| 133 |
-
return (True, (int(m.group(1)), int(m.group(2))))
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
|
| 137 |
-
"""
|
| 138 |
-
Check whether the detected NVIDIA driver satisfies the minimum
|
| 139 |
-
requirement for CUDA 12.8 on the given operating system.
|
| 140 |
-
"""
|
| 141 |
-
if drv is None:
|
| 142 |
-
return False
|
| 143 |
-
if os_name == "Linux":
|
| 144 |
-
return drv >= CUDA128_MIN_DRIVER_LINUX
|
| 145 |
-
if os_name == "Windows":
|
| 146 |
-
return drv >= CUDA128_MIN_DRIVER_WINDOWS
|
| 147 |
-
return False
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def normalize_arch(machine: str) -> str:
|
| 151 |
-
"""
|
| 152 |
-
Normalize platform.machine() output across operating systems.
|
| 153 |
-
|
| 154 |
-
Examples:
|
| 155 |
-
AMD64 -> x86_64
|
| 156 |
-
aarch64 -> arm64
|
| 157 |
-
"""
|
| 158 |
-
m = machine.lower()
|
| 159 |
-
if m in ("x86_64", "amd64"):
|
| 160 |
-
return "x86_64"
|
| 161 |
-
if m in ("aarch64", "arm64"):
|
| 162 |
-
return "arm64"
|
| 163 |
-
return machine
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def download_file(url: str, dst: Path) -> None:
|
| 167 |
-
"""
|
| 168 |
-
Download a file from the given URL with a progress indicator.
|
| 169 |
-
"""
|
| 170 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
-
print(f"Downloading: {url}", flush=True)
|
| 172 |
-
print(f" to: {dst}", flush=True)
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
with requests.get(url, stream=True, timeout=60) as r:
|
| 176 |
-
r.raise_for_status()
|
| 177 |
-
total = int(r.headers.get("content-length", 0))
|
| 178 |
-
with open(dst, "wb") as f:
|
| 179 |
-
with tqdm(
|
| 180 |
-
total=total,
|
| 181 |
-
unit="B",
|
| 182 |
-
unit_scale=True,
|
| 183 |
-
desc=f"Downloading {dst.name}",
|
| 184 |
-
) as pbar:
|
| 185 |
-
for chunk in r.iter_content(chunk_size=8192):
|
| 186 |
-
f.write(chunk)
|
| 187 |
-
pbar.update(len(chunk))
|
| 188 |
-
except Exception as e:
|
| 189 |
-
raise e
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def extract_archive(archive: Path, dst_dir: Path) -> None:
|
| 193 |
-
"""
|
| 194 |
-
Extract a ZIP archive to the destination directory.
|
| 195 |
-
"""
|
| 196 |
-
dst_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
-
print(f"Extracting: {archive} -> {dst_dir}", flush=True)
|
| 198 |
-
with zipfile.ZipFile(archive, "r") as z:
|
| 199 |
-
z.extractall(dst_dir)
|
| 200 |
-
archive.unlink()
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
|
| 204 |
-
os_name = platform.system()
|
| 205 |
-
arch = normalize_arch(platform.machine())
|
| 206 |
-
has_nvidia, drv = detect_nvidia_driver()
|
| 207 |
-
|
| 208 |
-
if os_name not in ("Linux", "Windows", "Darwin"):
|
| 209 |
-
raise NameError(f"Unsupported OS: {os_name}")
|
| 210 |
-
|
| 211 |
-
if arch not in ("x86_64", "arm64"):
|
| 212 |
-
raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
|
| 213 |
-
|
| 214 |
-
flavor = "cpu"
|
| 215 |
-
if force_cuda:
|
| 216 |
-
if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
|
| 217 |
-
raise NameError(
|
| 218 |
-
"CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
flavor = "cu128"
|
| 222 |
-
elif not force_cpu:
|
| 223 |
-
if has_nvidia and driver_ok_for_cuda(os_name, drv):
|
| 224 |
-
flavor = "cu128"
|
| 225 |
-
else:
|
| 226 |
-
flavor = "cpu"
|
| 227 |
-
|
| 228 |
-
print(f"System: {os_name} {arch}", flush=True)
|
| 229 |
-
print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
|
| 230 |
-
print(f"Selected flavor: {flavor}", flush=True)
|
| 231 |
-
|
| 232 |
-
key = (os_name, arch, flavor)
|
| 233 |
-
if key not in ELX_ASSET_TEMPLATE:
|
| 234 |
-
raise NameError(f"No elastix asset configured for {key}")
|
| 235 |
-
if flavor != "cpu" and key not in LIBTORCH_URL:
|
| 236 |
-
raise NameError(f"No libtorch url configured for {key}")
|
| 237 |
-
|
| 238 |
-
install_path = install_path.resolve()
|
| 239 |
-
install_path.mkdir(parents=True, exist_ok=True)
|
| 240 |
-
|
| 241 |
-
elx_asset = ELX_ASSET_TEMPLATE[key]
|
| 242 |
-
elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
|
| 243 |
-
elx_archive = install_path / elx_asset
|
| 244 |
-
download_file(elx_url, elx_archive)
|
| 245 |
-
extract_archive(elx_archive, install_path)
|
| 246 |
-
|
| 247 |
-
# -------------------------------------------------------------------------
|
| 248 |
-
# ZIP archives may drop executable permissions.
|
| 249 |
-
# Ensure elastix and transformix are executable on Unix platforms.
|
| 250 |
-
# -------------------------------------------------------------------------
|
| 251 |
-
if os_name in ("Linux", "Darwin"):
|
| 252 |
-
for exe in ("elastix", "transformix"):
|
| 253 |
-
p = install_path / "bin" / exe
|
| 254 |
-
if p.exists():
|
| 255 |
-
p.chmod(p.stat().st_mode | stat.S_IEXEC)
|
| 256 |
-
|
| 257 |
-
# ---------------------------------------------------------------------
|
| 258 |
-
# - Download matching LibTorch 12.8 with or without CUDA 12.8
|
| 259 |
-
# - Extract it
|
| 260 |
-
# - Move runtime libraries to a location visible to the dynamic loader
|
| 261 |
-
#
|
| 262 |
-
# Linux / macOS : prefix/lib
|
| 263 |
-
# Windows : prefix (next to elastix.exe)
|
| 264 |
-
# ---------------------------------------------------------------------
|
| 265 |
-
lt_url = LIBTORCH_URL[key]
|
| 266 |
-
lt_archive = install_path / Path(lt_url).name
|
| 267 |
-
download_file(lt_url, lt_archive)
|
| 268 |
-
extract_archive(lt_archive, install_path)
|
| 269 |
-
for p in (install_path / "libtorch" / "lib").iterdir():
|
| 270 |
-
if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
|
| 271 |
-
shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
|
| 272 |
-
|
| 273 |
-
shutil.rmtree(install_path / "libtorch")
|
| 274 |
-
if os_name == "Linux" and flavor == "cu128":
|
| 275 |
-
shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def get_elastix_bin(install_path: Path) -> Path:
|
| 279 |
-
return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def try_elastix(install_path: Path) -> None:
|
| 283 |
-
try:
|
| 284 |
-
subprocess.run(
|
| 285 |
-
[str(get_elastix_bin(install_path)), "-h"],
|
| 286 |
-
capture_output=True,
|
| 287 |
-
text=True,
|
| 288 |
-
check=True,
|
| 289 |
-
) # nosec B603
|
| 290 |
-
except subprocess.CalledProcessError as e:
|
| 291 |
-
msg = "Elastix execution failed.\n\n"
|
| 292 |
-
|
| 293 |
-
msg += f"Command:\n{' '.join(e.cmd)}\n"
|
| 294 |
-
msg += f"Return code: {e.returncode}\n\n"
|
| 295 |
-
|
| 296 |
-
if e.stderr:
|
| 297 |
-
msg += "Error output:\n"
|
| 298 |
-
msg += e.stderr.strip()
|
| 299 |
-
raise NameError(msg)
|
| 300 |
-
|
| 301 |
-
except OSError as e:
|
| 302 |
-
msg = (
|
| 303 |
-
"Elastix could not be started.\n\n"
|
| 304 |
-
"This is usually caused by missing shared libraries "
|
| 305 |
-
"(e.g. LibTorch or CUDA runtime).\n\n"
|
| 306 |
-
f"System error:\n{str(e)}"
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
raise NameError(msg)
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def main() -> None:
|
| 313 |
-
ap = argparse.ArgumentParser()
|
| 314 |
-
ap.add_argument(
|
| 315 |
-
"--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
|
| 316 |
-
)
|
| 317 |
-
group = ap.add_mutually_exclusive_group()
|
| 318 |
-
group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
|
| 319 |
-
group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
|
| 320 |
-
args = vars(ap.parse_args())
|
| 321 |
-
install_elastix_impact(**args)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if __name__ == "__main__":
|
| 325 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MR_CT_MRSeg/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
impact_reg_konfai>=1.6.1
|
MR_CT_TS/Model.py
DELETED
|
@@ -1,301 +0,0 @@
|
|
| 1 |
-
# Copyright (c) 2025 Valentin Boussot
|
| 2 |
-
#
|
| 3 |
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
-
# you may not use this file except in compliance with the License.
|
| 5 |
-
# You may obtain a copy of the License at
|
| 6 |
-
#
|
| 7 |
-
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
-
#
|
| 9 |
-
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
-
# See the License for the specific language governing permissions and
|
| 13 |
-
# limitations under the License.
|
| 14 |
-
#
|
| 15 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
-
|
| 17 |
-
"""Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
|
| 18 |
-
|
| 19 |
-
``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
|
| 20 |
-
2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
|
| 21 |
-
the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
|
| 22 |
-
the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
|
| 23 |
-
subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
|
| 24 |
-
|
| 25 |
-
A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
|
| 26 |
-
``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
|
| 27 |
-
|
| 28 |
-
NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
|
| 29 |
-
(``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import os
|
| 34 |
-
import re
|
| 35 |
-
from dataclasses import dataclass, field
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import Annotated, Literal
|
| 38 |
-
|
| 39 |
-
import torch
|
| 40 |
-
from huggingface_hub import hf_hub_download
|
| 41 |
-
from konfai.network import network
|
| 42 |
-
from konfai.utils.config import Choices, Range
|
| 43 |
-
|
| 44 |
-
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 45 |
-
# A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
|
| 46 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
|
| 47 |
-
# iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
|
| 48 |
-
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 49 |
-
|
| 50 |
-
# ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
|
| 51 |
-
# Static anyway; in Jacobian we clamp ``l`` to this plateau.
|
| 52 |
-
_FOV_RAMP_MAX_LAYER = 6
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry_choices() -> list[str]:
|
| 56 |
-
"""The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
|
| 57 |
-
(offline-first). A user may still point ``ref`` at a local model."""
|
| 58 |
-
repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
|
| 59 |
-
return [f"{repo}:{key}" for key in load_models_registry()]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _num(x: object) -> str:
|
| 63 |
-
"""Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
|
| 64 |
-
return "%g" % float(x)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
@dataclass
|
| 68 |
-
class ModelSpec:
|
| 69 |
-
"""One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
|
| 70 |
-
rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
|
| 71 |
-
(``models.json``) keyed by ``ref`` — never tuned."""
|
| 72 |
-
|
| 73 |
-
ref: Annotated[str, Choices(registry_choices)]
|
| 74 |
-
voxel_size: list[float] = field(default_factory=list)
|
| 75 |
-
layers_weight: list[float] = field(default_factory=lambda: [1.0])
|
| 76 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0
|
| 77 |
-
pca: Annotated[int, Range(0, 100)] = 0
|
| 78 |
-
distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
|
| 79 |
-
layers_mask: str = ""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
@dataclass
|
| 83 |
-
class ResolutionSpec:
|
| 84 |
-
"""One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
|
| 85 |
-
|
| 86 |
-
max_iterations: Annotated[int, Range(1, 100000)]
|
| 87 |
-
models: dict[str, ModelSpec]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _sorted_specs(mapping: dict) -> list:
|
| 91 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order."""
|
| 92 |
-
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 96 |
-
"""Load models.json (the fixed params per model) from the model repo on Hugging Face.
|
| 97 |
-
|
| 98 |
-
The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
|
| 99 |
-
dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
|
| 100 |
-
"""
|
| 101 |
-
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 102 |
-
if local:
|
| 103 |
-
path = Path(local)
|
| 104 |
-
elif ":" in ref:
|
| 105 |
-
repo, filename = ref.split(":", 1)
|
| 106 |
-
path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(
|
| 109 |
-
f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
|
| 110 |
-
f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
|
| 111 |
-
)
|
| 112 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _model_key(ref: str) -> str:
|
| 116 |
-
"""Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
|
| 117 |
-
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _deepest_active_layer(layers_mask: str) -> int:
|
| 121 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
|
| 122 |
-
|
| 123 |
-
A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
|
| 124 |
-
== ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
|
| 125 |
-
receptive field, so the FOV is governed by the rightmost ``'1'``.
|
| 126 |
-
"""
|
| 127 |
-
mask = layers_mask.strip().strip('"')
|
| 128 |
-
active = [i for i, char in enumerate(mask) if char == "1"]
|
| 129 |
-
if not active:
|
| 130 |
-
raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
|
| 131 |
-
return max(active)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 135 |
-
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 136 |
-
|
| 137 |
-
Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
|
| 138 |
-
``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
|
| 139 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
|
| 140 |
-
to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
|
| 141 |
-
a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
|
| 142 |
-
``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
|
| 143 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut.
|
| 144 |
-
"""
|
| 145 |
-
formula = str(fov.get("formula", "")).strip()
|
| 146 |
-
key = re.sub(r"\s+", "", formula).lower()
|
| 147 |
-
if key.isdigit():
|
| 148 |
-
return int(key)
|
| 149 |
-
if key == "2*r*d+1":
|
| 150 |
-
return 2 * int(fov["r"]) * int(fov["d"]) + 1
|
| 151 |
-
if key == "2^l+3":
|
| 152 |
-
return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
|
| 153 |
-
if "global" in key:
|
| 154 |
-
raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
|
| 155 |
-
if fov.get("value") is not None:
|
| 156 |
-
return int(fov["value"])
|
| 157 |
-
raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 161 |
-
"""PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
|
| 162 |
-
image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
|
| 163 |
-
e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
|
| 164 |
-
dim = int(entry.get("dimension", 3))
|
| 165 |
-
if mode.strip().strip('"').lower() != "jacobian":
|
| 166 |
-
return " ".join(["0"] * dim)
|
| 167 |
-
fov = _fov_value(entry.get("fov", {}), layers_mask)
|
| 168 |
-
return " ".join([str(fov)] * dim)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
|
| 172 |
-
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 173 |
-
|
| 174 |
-
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 175 |
-
ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
|
| 176 |
-
is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
|
| 177 |
-
from the registry formula and the cell's ``layers_mask``.
|
| 178 |
-
"""
|
| 179 |
-
res = _sorted_specs(resolutions)
|
| 180 |
-
n = len(res)
|
| 181 |
-
mode_clean = mode.strip().strip('"') or "Static"
|
| 182 |
-
|
| 183 |
-
impact: list[str] = []
|
| 184 |
-
for k, r in enumerate(res):
|
| 185 |
-
models = _sorted_specs(r.models)
|
| 186 |
-
entries = [registry[_model_key(m.ref)] for m in models]
|
| 187 |
-
|
| 188 |
-
def row(stem: str, values: list[str]) -> None:
|
| 189 |
-
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 190 |
-
|
| 191 |
-
# From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
|
| 192 |
-
# model FOV); everything else is a per-model knob taken straight from the cell.
|
| 193 |
-
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 194 |
-
row("Dimension", [e["dimension"] for e in entries])
|
| 195 |
-
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
| 196 |
-
row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
|
| 197 |
-
row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
|
| 198 |
-
row("LayersMask", [f'"{m.layers_mask}"' for m in models])
|
| 199 |
-
row("SubsetFeatures", [str(m.subset_features) for m in models])
|
| 200 |
-
row("PCA", [str(m.pca) for m in models])
|
| 201 |
-
row("Distance", [f'"{m.distance}"' for m in models])
|
| 202 |
-
row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
|
| 203 |
-
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 204 |
-
|
| 205 |
-
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 206 |
-
# (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
|
| 207 |
-
lines = template_text.splitlines()
|
| 208 |
-
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 209 |
-
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
| 210 |
-
block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
|
| 211 |
-
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for i, (m, line) in enumerate(indexed):
|
| 214 |
-
key = m.group(1) if m else None
|
| 215 |
-
if block_lo <= i <= block_hi:
|
| 216 |
-
if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
|
| 217 |
-
out.extend(impact[:-1])
|
| 218 |
-
elif key == "MaximumNumberOfIterations":
|
| 219 |
-
out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
|
| 220 |
-
elif key == "NumberOfResolutions":
|
| 221 |
-
out.append(f"(NumberOfResolutions {n})")
|
| 222 |
-
elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
|
| 223 |
-
out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
|
| 224 |
-
elif key == "ImpactMode":
|
| 225 |
-
out.append(f'(ImpactMode "{mode_clean}")')
|
| 226 |
-
else:
|
| 227 |
-
out.append(line)
|
| 228 |
-
return "\n".join(out)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class ChannelSelect(torch.nn.Module):
|
| 232 |
-
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, start: int, stop: int) -> None:
|
| 235 |
-
super().__init__()
|
| 236 |
-
self._start = start
|
| 237 |
-
self._stop = stop
|
| 238 |
-
|
| 239 |
-
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 240 |
-
return tensor[:, self._start : self._stop]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
class RegistrationNet(network.Network):
|
| 244 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
|
| 245 |
-
moving mask = 3; masks restrict the metric, whole-image = no restriction).
|
| 246 |
-
|
| 247 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
|
| 248 |
-
(the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
|
| 249 |
-
``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
|
| 250 |
-
``same_as_group: Volume_0:Fixed``.
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
def __init__(
|
| 254 |
-
self,
|
| 255 |
-
optimizer: network.OptimizerLoader = network.OptimizerLoader(),
|
| 256 |
-
schedulers: dict[str, network.LRSchedulersLoader] = {
|
| 257 |
-
"default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
|
| 258 |
-
},
|
| 259 |
-
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 260 |
-
engine: str = "elastix",
|
| 261 |
-
parameter_maps: list[str] = [],
|
| 262 |
-
max_iterations: Annotated[int, Range(0, 100000)] = 0,
|
| 263 |
-
final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
|
| 264 |
-
subset_features: Annotated[int, Range(0, 1000)] = 0,
|
| 265 |
-
spatial_samples: Annotated[int, Range(0, 100000)] = 0,
|
| 266 |
-
parameter_overrides: list[str] = [],
|
| 267 |
-
resolutions: dict[str, ResolutionSpec] = {},
|
| 268 |
-
mode: Literal["Static", "Jacobian"] = "Static",
|
| 269 |
-
) -> None:
|
| 270 |
-
# The registration is fully described by ``resolutions`` (config = source of truth): each resolution
|
| 271 |
-
# lists its self-configured models; the download list is derived from the cells. Global knobs override
|
| 272 |
-
# the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
|
| 273 |
-
# NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
|
| 274 |
-
# preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
|
| 275 |
-
from elastix_engine import ElastixRegistration
|
| 276 |
-
|
| 277 |
-
super().__init__(
|
| 278 |
-
in_channels=1,
|
| 279 |
-
optimizer=optimizer,
|
| 280 |
-
schedulers=schedulers,
|
| 281 |
-
outputs_criterions=outputs_criterions,
|
| 282 |
-
dim=3,
|
| 283 |
-
)
|
| 284 |
-
self.add_module(
|
| 285 |
-
"Registration",
|
| 286 |
-
ElastixRegistration(
|
| 287 |
-
engine,
|
| 288 |
-
parameter_maps,
|
| 289 |
-
max_iterations,
|
| 290 |
-
final_grid_spacing,
|
| 291 |
-
subset_features,
|
| 292 |
-
spatial_samples,
|
| 293 |
-
parameter_overrides,
|
| 294 |
-
resolutions,
|
| 295 |
-
mode,
|
| 296 |
-
),
|
| 297 |
-
in_branch=[0, 1, 2, 3],
|
| 298 |
-
out_branch=["registration"],
|
| 299 |
-
)
|
| 300 |
-
self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
|
| 301 |
-
self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MR_CT_TS/Prediction.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
-
classpath:
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|
|
|
|
| 1 |
Predictor:
|
| 2 |
Model:
|
| 3 |
+
classpath: impact_reg_konfai.models.elastix:RegistrationNet
|
| 4 |
RegistrationNet:
|
| 5 |
engine: elastix
|
| 6 |
parameter_maps:
|