Valentin Boussot commited on
Commit ·
269b189
1
Parent(s): 6a3ecbf
refactor: split elastix engine and type model parameters across presets
Browse filesSplit each elastix preset's Model.py into a parameter mapper plus an elastix_engine.py runtime, and type the constructor parameters (Literal / Annotated Range / Choices) so UIs derive their constraints. Reg presets set tta=0 and carry the real grid-spacing / spatial-samples in the config (source of truth). ConvexAdam presets carry typed Range parameters.
- CBCT_CT_HeadNeck/Model.py +85 -466
- CBCT_CT_HeadNeck/Prediction.yml +2 -3
- CBCT_CT_HeadNeck/app.json +1 -1
- CBCT_CT_HeadNeck/elastix_engine.py +375 -0
- CBCT_CT_MRSeg/Model.py +85 -466
- CBCT_CT_MRSeg/Prediction.yml +2 -3
- CBCT_CT_MRSeg/app.json +1 -1
- CBCT_CT_MRSeg/elastix_engine.py +375 -0
- CBCT_CT_TS/Model.py +85 -466
- CBCT_CT_TS/Prediction.yml +2 -2
- CBCT_CT_TS/app.json +1 -1
- CBCT_CT_TS/elastix_engine.py +375 -0
- ConvexAdam_Coarse/Model.py +15 -8
- ConvexAdam_Coarse/app.json +1 -1
- ConvexAdam_Composite/Model.py +15 -8
- ConvexAdam_Composite/app.json +1 -1
- ConvexAdam_Fine/Model.py +15 -8
- ConvexAdam_Fine/app.json +1 -1
- Generic_Rigid/Model.py +85 -466
- Generic_Rigid/Prediction.yml +1 -1
- Generic_Rigid/app.json +1 -1
- Generic_Rigid/elastix_engine.py +375 -0
- Generic_Rigid_BSpline/Model.py +85 -466
- Generic_Rigid_BSpline/Prediction.yml +2 -2
- Generic_Rigid_BSpline/app.json +1 -1
- Generic_Rigid_BSpline/elastix_engine.py +375 -0
- MR_CT_HeadNeck/Model.py +85 -466
- MR_CT_HeadNeck/Prediction.yml +2 -3
- MR_CT_HeadNeck/app.json +1 -1
- MR_CT_HeadNeck/elastix_engine.py +375 -0
- MR_CT_MRSeg/Model.py +85 -466
- MR_CT_MRSeg/Prediction.yml +2 -3
- MR_CT_MRSeg/app.json +1 -1
- MR_CT_MRSeg/elastix_engine.py +375 -0
- MR_CT_TS/Model.py +85 -466
- MR_CT_TS/Prediction.yml +2 -3
- MR_CT_TS/app.json +1 -1
- MR_CT_TS/elastix_engine.py +375 -0
CBCT_CT_HeadNeck/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
CBCT_CT_HeadNeck/Prediction.yml
CHANGED
|
@@ -7,9 +7,9 @@ Predictor:
|
|
| 7 |
- ParameterMap_CBCT_HN.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
-
final_grid_spacing:
|
| 11 |
subset_features: 0
|
| 12 |
-
spatial_samples:
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
@@ -87,7 +87,6 @@ Predictor:
|
|
| 87 |
subset_features: 64
|
| 88 |
pca: 0
|
| 89 |
distance: L1
|
| 90 |
-
models_registry: VBoussot/impact-torchscript-models:models.json
|
| 91 |
mode: Static
|
| 92 |
Dataset:
|
| 93 |
groups_src:
|
|
|
|
| 7 |
- ParameterMap_CBCT_HN.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
+
final_grid_spacing: 10.0
|
| 11 |
subset_features: 0
|
| 12 |
+
spatial_samples: 2000
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
|
|
| 87 |
subset_features: 64
|
| 88 |
pca: 0
|
| 89 |
distance: L1
|
|
|
|
| 90 |
mode: Static
|
| 91 |
Dataset:
|
| 92 |
groups_src:
|
CBCT_CT_HeadNeck/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Optimized preset for CBCT/CT registration on head & neck",
|
| 4 |
"description": "A five-level recursive B-spline deformable registration optimized for CBCT/CT head-and-neck alignment, driven by the IMPACT metric using semantic features extracted from pretrained TotalSegmentator TorchScript models. The optimization follows a multi-resolution ASGD scheme with up to 300, 300, 200, 200, and 150 iterations and 2000 stochastic spatial samples per level. Features are extracted at progressively finer voxel scales (6 mm, 3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm) using L1 distances on selected internal layers of the network. A composite objective (IMPACT + mutual information + bending energy penalty, with increased MI weight) ensures robust cross-modality alignment in complex head-and-neck anatomy while enforcing smooth, physically plausible deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Optimized preset for CBCT/CT registration on head & neck",
|
| 4 |
"description": "A five-level recursive B-spline deformable registration optimized for CBCT/CT head-and-neck alignment, driven by the IMPACT metric using semantic features extracted from pretrained TotalSegmentator TorchScript models. The optimization follows a multi-resolution ASGD scheme with up to 300, 300, 200, 200, and 150 iterations and 2000 stochastic spatial samples per level. Features are extracted at progressively finer voxel scales (6 mm, 3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm) using L1 distances on selected internal layers of the network. A composite objective (IMPACT + mutual information + bending energy penalty, with increased MI weight) ensures robust cross-modality alignment in complex head-and-neck anatomy while enforcing smooth, physically plausible deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
CBCT_CT_HeadNeck/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|
CBCT_CT_MRSeg/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
CBCT_CT_MRSeg/Prediction.yml
CHANGED
|
@@ -7,9 +7,9 @@ Predictor:
|
|
| 7 |
- ParameterMap_CBCT_generic_MRSeg.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
-
final_grid_spacing:
|
| 11 |
subset_features: 0
|
| 12 |
-
spatial_samples:
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
@@ -120,7 +120,6 @@ Predictor:
|
|
| 120 |
subset_features: 64
|
| 121 |
pca: 0
|
| 122 |
distance: Dice
|
| 123 |
-
models_registry: VBoussot/impact-torchscript-models:models.json
|
| 124 |
mode: Static
|
| 125 |
Dataset:
|
| 126 |
groups_src:
|
|
|
|
| 7 |
- ParameterMap_CBCT_generic_MRSeg.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
+
final_grid_spacing: 14.0
|
| 11 |
subset_features: 0
|
| 12 |
+
spatial_samples: 2000
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
|
|
| 120 |
subset_features: 64
|
| 121 |
pca: 0
|
| 122 |
distance: Dice
|
|
|
|
| 123 |
mode: Static
|
| 124 |
Dataset:
|
| 125 |
groups_src:
|
CBCT_CT_MRSeg/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Generic CBCT/CT deformable registration using MRSegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic CBCT/CT alignment, driven by the IMPACT metric using semantic features extracted from the pretrained MRSegmentator model. The scheme follows a multi-resolution strategy with up to 300, 300, 250, and 200 ASGD iterations and 2000 stochastic spatial samples per level. Features are extracted at progressively finer voxel scales (3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm), with a level-dependent combination of Dice-based segmentation overlap and L1 feature distances on selected internal layers of MRSegmentator. Early levels rely on pure Dice supervision, while finer stages progressively integrate feature-level alignment with increasing L1 contribution (0.3/0.7, 0.5/0.5) and a final purely feature-based stage. The optimization minimizes a composite objective (IMPACT + mutual information + bending energy penalty), enabling robust cross-modality alignment between CBCT and CT while enforcing smooth, physically plausible deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Generic CBCT/CT deformable registration using MRSegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic CBCT/CT alignment, driven by the IMPACT metric using semantic features extracted from the pretrained MRSegmentator model. The scheme follows a multi-resolution strategy with up to 300, 300, 250, and 200 ASGD iterations and 2000 stochastic spatial samples per level. Features are extracted at progressively finer voxel scales (3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm), with a level-dependent combination of Dice-based segmentation overlap and L1 feature distances on selected internal layers of MRSegmentator. Early levels rely on pure Dice supervision, while finer stages progressively integrate feature-level alignment with increasing L1 contribution (0.3/0.7, 0.5/0.5) and a final purely feature-based stage. The optimization minimizes a composite objective (IMPACT + mutual information + bending energy penalty), enabling robust cross-modality alignment between CBCT and CT while enforcing smooth, physically plausible deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
CBCT_CT_MRSeg/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|
CBCT_CT_TS/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
CBCT_CT_TS/Prediction.yml
CHANGED
|
@@ -7,9 +7,9 @@ Predictor:
|
|
| 7 |
- ParameterMap_CBCT_generic_TS.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
-
final_grid_spacing:
|
| 11 |
subset_features: 0
|
| 12 |
-
spatial_samples:
|
| 13 |
parameter_overrides: []
|
| 14 |
Dataset:
|
| 15 |
groups_src:
|
|
|
|
| 7 |
- ParameterMap_CBCT_generic_TS.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
+
final_grid_spacing: 14.0
|
| 11 |
subset_features: 0
|
| 12 |
+
spatial_samples: 2000
|
| 13 |
parameter_overrides: []
|
| 14 |
Dataset:
|
| 15 |
groups_src:
|
CBCT_CT_TS/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Generic CBCT/CT deformable registration using TotalSegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic CBCT/CT alignment, driven by the IMPACT metric using semantic features extracted from pretrained TotalSegmentator TorchScript models. The optimization follows a multi-resolution ASGD scheme with up to 300, 300, 250, and 200 iterations using 2000 random spatial samples per level. Features are extracted at progressively finer voxel scales (3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm), starting with Dice-based overlap on segmentation outputs and progressively integrating feature-level alignment via L1 distances on selected internal layers (0.3/0.7 then 0.5/0.5 L1/Dice), ending with a final purely feature-based stage. A composite objective (IMPACT + mutual information + bending energy penalty) ensures robust cross-modality alignment while enforcing smooth, physically plausible deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Generic CBCT/CT deformable registration using TotalSegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic CBCT/CT alignment, driven by the IMPACT metric using semantic features extracted from pretrained TotalSegmentator TorchScript models. The optimization follows a multi-resolution ASGD scheme with up to 300, 300, 250, and 200 iterations using 2000 random spatial samples per level. Features are extracted at progressively finer voxel scales (3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm), starting with Dice-based overlap on segmentation outputs and progressively integrating feature-level alignment via L1 distances on selected internal layers (0.3/0.7 then 0.5/0.5 L1/Dice), ending with a final purely feature-based stage. A composite objective (IMPACT + mutual information + bending energy penalty) ensures robust cross-modality alignment while enforcing smooth, physically plausible deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
CBCT_CT_TS/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|
ConvexAdam_Coarse/Model.py
CHANGED
|
@@ -33,6 +33,8 @@ NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engi
|
|
| 33 |
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
"""
|
| 35 |
|
|
|
|
|
|
|
| 36 |
import itk
|
| 37 |
import numpy as np
|
| 38 |
import SimpleITK as sitk
|
|
@@ -40,12 +42,17 @@ import torch
|
|
| 40 |
import tqdm
|
| 41 |
from huggingface_hub import hf_hub_download
|
| 42 |
from konfai.network import network
|
|
|
|
| 43 |
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 44 |
|
| 45 |
DIM = 3
|
| 46 |
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 47 |
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 48 |
NUM_CHANNELS = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 50 |
|
| 51 |
|
|
@@ -417,22 +424,22 @@ class RegistrationNet(network.Network):
|
|
| 417 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 418 |
models: list[str] = [],
|
| 419 |
voxel_size: list[float] = [3.0, 3.0, 3.0],
|
| 420 |
-
overlap: int = 2,
|
| 421 |
layers_mask: list[bool] = [True],
|
| 422 |
mixed_precision: bool = False,
|
| 423 |
-
grid_spacing: int = 4,
|
| 424 |
-
displacement_half_width: int = 6,
|
| 425 |
-
iterations: int = 150,
|
| 426 |
-
learning_rate: float = 0.2,
|
| 427 |
-
regularization_weight: float = 1.0,
|
| 428 |
-
grid_shrink: int = 4,
|
| 429 |
distance: list[str] = ["L1"],
|
| 430 |
layers_weight: list[float] = [1.0],
|
| 431 |
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 432 |
pca: list[int] = [0],
|
| 433 |
stages: list[str] = ["coarse", "fine"],
|
| 434 |
linear: bool = True,
|
| 435 |
-
linear_iterations: int = 200,
|
| 436 |
seed: int = 42,
|
| 437 |
) -> None:
|
| 438 |
super().__init__(
|
|
|
|
| 33 |
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
"""
|
| 35 |
|
| 36 |
+
from typing import Annotated
|
| 37 |
+
|
| 38 |
import itk
|
| 39 |
import numpy as np
|
| 40 |
import SimpleITK as sitk
|
|
|
|
| 42 |
import tqdm
|
| 43 |
from huggingface_hub import hf_hub_download
|
| 44 |
from konfai.network import network
|
| 45 |
+
from konfai.utils.config import Range
|
| 46 |
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 47 |
|
| 48 |
DIM = 3
|
| 49 |
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 50 |
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 51 |
NUM_CHANNELS = 1
|
| 52 |
+
|
| 53 |
+
# A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__``: ``Annotated[.., Range]``
|
| 54 |
+
# gives numeric spin bounds; a flat list (``models`` / ``distance`` / ``voxel_size``) has no constraint and is
|
| 55 |
+
# edited as a plain list. The engine downloads models straight from the ``models`` refs (a local path is OK).
|
| 56 |
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 57 |
|
| 58 |
|
|
|
|
| 424 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 425 |
models: list[str] = [],
|
| 426 |
voxel_size: list[float] = [3.0, 3.0, 3.0],
|
| 427 |
+
overlap: Annotated[int, Range(1, 128)] = 2,
|
| 428 |
layers_mask: list[bool] = [True],
|
| 429 |
mixed_precision: bool = False,
|
| 430 |
+
grid_spacing: Annotated[int, Range(1, 512)] = 4,
|
| 431 |
+
displacement_half_width: Annotated[int, Range(1, 512)] = 6,
|
| 432 |
+
iterations: Annotated[int, Range(0, 100000)] = 150,
|
| 433 |
+
learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
|
| 434 |
+
regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
|
| 435 |
+
grid_shrink: Annotated[int, Range(1, 128)] = 4,
|
| 436 |
distance: list[str] = ["L1"],
|
| 437 |
layers_weight: list[float] = [1.0],
|
| 438 |
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 439 |
pca: list[int] = [0],
|
| 440 |
stages: list[str] = ["coarse", "fine"],
|
| 441 |
linear: bool = True,
|
| 442 |
+
linear_iterations: Annotated[int, Range(0, 100000)] = 200,
|
| 443 |
seed: int = 42,
|
| 444 |
) -> None:
|
| 445 |
super().__init__(
|
ConvexAdam_Coarse/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Global coarse ConvexAdam initialization (whole-volume, IMPACT/MIND features).",
|
| 4 |
"description": "First stage of the ConvexAdam pipeline: an optional moments+affine linear pre-align followed by the ConvexAdam coarse coupled-convex initialization on the whole volume, driven by the IMPACT metric on MIND features. Produces a robust low-resolution displacement field on the fixed grid, meant to warm-start (and pre-resample the moving for) the fine stage.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Global coarse ConvexAdam initialization (whole-volume, IMPACT/MIND features).",
|
| 4 |
"description": "First stage of the ConvexAdam pipeline: an optional moments+affine linear pre-align followed by the ConvexAdam coarse coupled-convex initialization on the whole volume, driven by the IMPACT metric on MIND features. Produces a robust low-resolution displacement field on the fixed grid, meant to warm-start (and pre-resample the moving for) the fine stage.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
ConvexAdam_Composite/Model.py
CHANGED
|
@@ -33,6 +33,8 @@ NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engi
|
|
| 33 |
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
"""
|
| 35 |
|
|
|
|
|
|
|
| 36 |
import itk
|
| 37 |
import numpy as np
|
| 38 |
import SimpleITK as sitk
|
|
@@ -40,12 +42,17 @@ import torch
|
|
| 40 |
import tqdm
|
| 41 |
from huggingface_hub import hf_hub_download
|
| 42 |
from konfai.network import network
|
|
|
|
| 43 |
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 44 |
|
| 45 |
DIM = 3
|
| 46 |
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 47 |
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 48 |
NUM_CHANNELS = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 50 |
|
| 51 |
|
|
@@ -417,22 +424,22 @@ class RegistrationNet(network.Network):
|
|
| 417 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 418 |
models: list[str] = [],
|
| 419 |
voxel_size: list[float] = [3.0, 3.0, 3.0],
|
| 420 |
-
overlap: int = 2,
|
| 421 |
layers_mask: list[bool] = [True],
|
| 422 |
mixed_precision: bool = False,
|
| 423 |
-
grid_spacing: int = 4,
|
| 424 |
-
displacement_half_width: int = 6,
|
| 425 |
-
iterations: int = 150,
|
| 426 |
-
learning_rate: float = 0.2,
|
| 427 |
-
regularization_weight: float = 1.0,
|
| 428 |
-
grid_shrink: int = 4,
|
| 429 |
distance: list[str] = ["L1"],
|
| 430 |
layers_weight: list[float] = [1.0],
|
| 431 |
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 432 |
pca: list[int] = [0],
|
| 433 |
stages: list[str] = ["coarse", "fine"],
|
| 434 |
linear: bool = True,
|
| 435 |
-
linear_iterations: int = 200,
|
| 436 |
seed: int = 42,
|
| 437 |
) -> None:
|
| 438 |
super().__init__(
|
|
|
|
| 33 |
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
"""
|
| 35 |
|
| 36 |
+
from typing import Annotated
|
| 37 |
+
|
| 38 |
import itk
|
| 39 |
import numpy as np
|
| 40 |
import SimpleITK as sitk
|
|
|
|
| 42 |
import tqdm
|
| 43 |
from huggingface_hub import hf_hub_download
|
| 44 |
from konfai.network import network
|
| 45 |
+
from konfai.utils.config import Range
|
| 46 |
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 47 |
|
| 48 |
DIM = 3
|
| 49 |
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 50 |
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 51 |
NUM_CHANNELS = 1
|
| 52 |
+
|
| 53 |
+
# A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__``: ``Annotated[.., Range]``
|
| 54 |
+
# gives numeric spin bounds; a flat list (``models`` / ``distance`` / ``voxel_size``) has no constraint and is
|
| 55 |
+
# edited as a plain list. The engine downloads models straight from the ``models`` refs (a local path is OK).
|
| 56 |
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 57 |
|
| 58 |
|
|
|
|
| 424 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 425 |
models: list[str] = [],
|
| 426 |
voxel_size: list[float] = [3.0, 3.0, 3.0],
|
| 427 |
+
overlap: Annotated[int, Range(1, 128)] = 2,
|
| 428 |
layers_mask: list[bool] = [True],
|
| 429 |
mixed_precision: bool = False,
|
| 430 |
+
grid_spacing: Annotated[int, Range(1, 512)] = 4,
|
| 431 |
+
displacement_half_width: Annotated[int, Range(1, 512)] = 6,
|
| 432 |
+
iterations: Annotated[int, Range(0, 100000)] = 150,
|
| 433 |
+
learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
|
| 434 |
+
regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
|
| 435 |
+
grid_shrink: Annotated[int, Range(1, 128)] = 4,
|
| 436 |
distance: list[str] = ["L1"],
|
| 437 |
layers_weight: list[float] = [1.0],
|
| 438 |
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 439 |
pca: list[int] = [0],
|
| 440 |
stages: list[str] = ["coarse", "fine"],
|
| 441 |
linear: bool = True,
|
| 442 |
+
linear_iterations: Annotated[int, Range(0, 100000)] = 200,
|
| 443 |
seed: int = 42,
|
| 444 |
) -> None:
|
| 445 |
super().__init__(
|
ConvexAdam_Composite/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Coarse + fine ConvexAdam registration in one app (IMPACT/MIND features).",
|
| 4 |
"description": "The full ConvexAdam pipeline chained in one app: optional moments+affine linear pre-align, then a ConvexAdam coarse coupled-convex initialization, then an Adam instance-optimisation refinement, all driven by the IMPACT metric on MIND features. Produces the moved image and the displacement field on the fixed grid.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Coarse + fine ConvexAdam registration in one app (IMPACT/MIND features).",
|
| 4 |
"description": "The full ConvexAdam pipeline chained in one app: optional moments+affine linear pre-align, then a ConvexAdam coarse coupled-convex initialization, then an Adam instance-optimisation refinement, all driven by the IMPACT metric on MIND features. Produces the moved image and the displacement field on the fixed grid.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
ConvexAdam_Fine/Model.py
CHANGED
|
@@ -33,6 +33,8 @@ NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engi
|
|
| 33 |
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
"""
|
| 35 |
|
|
|
|
|
|
|
| 36 |
import itk
|
| 37 |
import numpy as np
|
| 38 |
import SimpleITK as sitk
|
|
@@ -40,12 +42,17 @@ import torch
|
|
| 40 |
import tqdm
|
| 41 |
from huggingface_hub import hf_hub_download
|
| 42 |
from konfai.network import network
|
|
|
|
| 43 |
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 44 |
|
| 45 |
DIM = 3
|
| 46 |
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 47 |
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 48 |
NUM_CHANNELS = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 50 |
|
| 51 |
|
|
@@ -417,22 +424,22 @@ class RegistrationNet(network.Network):
|
|
| 417 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 418 |
models: list[str] = [],
|
| 419 |
voxel_size: list[float] = [3.0, 3.0, 3.0],
|
| 420 |
-
overlap: int = 2,
|
| 421 |
layers_mask: list[bool] = [True],
|
| 422 |
mixed_precision: bool = False,
|
| 423 |
-
grid_spacing: int = 4,
|
| 424 |
-
displacement_half_width: int = 6,
|
| 425 |
-
iterations: int = 150,
|
| 426 |
-
learning_rate: float = 0.2,
|
| 427 |
-
regularization_weight: float = 1.0,
|
| 428 |
-
grid_shrink: int = 4,
|
| 429 |
distance: list[str] = ["L1"],
|
| 430 |
layers_weight: list[float] = [1.0],
|
| 431 |
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 432 |
pca: list[int] = [0],
|
| 433 |
stages: list[str] = ["coarse", "fine"],
|
| 434 |
linear: bool = True,
|
| 435 |
-
linear_iterations: int = 200,
|
| 436 |
seed: int = 42,
|
| 437 |
) -> None:
|
| 438 |
super().__init__(
|
|
|
|
| 33 |
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
|
| 34 |
"""
|
| 35 |
|
| 36 |
+
from typing import Annotated
|
| 37 |
+
|
| 38 |
import itk
|
| 39 |
import numpy as np
|
| 40 |
import SimpleITK as sitk
|
|
|
|
| 42 |
import tqdm
|
| 43 |
from huggingface_hub import hf_hub_download
|
| 44 |
from konfai.network import network
|
| 45 |
+
from konfai.utils.config import Range
|
| 46 |
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
|
| 47 |
|
| 48 |
DIM = 3
|
| 49 |
# The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
|
| 50 |
# medical images), not a tunable — so it's fixed here, never a config/signature parameter.
|
| 51 |
NUM_CHANNELS = 1
|
| 52 |
+
|
| 53 |
+
# A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__``: ``Annotated[.., Range]``
|
| 54 |
+
# gives numeric spin bounds; a flat list (``models`` / ``distance`` / ``voxel_size``) has no constraint and is
|
| 55 |
+
# edited as a plain list. The engine downloads models straight from the ``models`` refs (a local path is OK).
|
| 56 |
_IMAGE_F = itk.Image[itk.F, DIM]
|
| 57 |
|
| 58 |
|
|
|
|
| 424 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 425 |
models: list[str] = [],
|
| 426 |
voxel_size: list[float] = [3.0, 3.0, 3.0],
|
| 427 |
+
overlap: Annotated[int, Range(1, 128)] = 2,
|
| 428 |
layers_mask: list[bool] = [True],
|
| 429 |
mixed_precision: bool = False,
|
| 430 |
+
grid_spacing: Annotated[int, Range(1, 512)] = 4,
|
| 431 |
+
displacement_half_width: Annotated[int, Range(1, 512)] = 6,
|
| 432 |
+
iterations: Annotated[int, Range(0, 100000)] = 150,
|
| 433 |
+
learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
|
| 434 |
+
regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
|
| 435 |
+
grid_shrink: Annotated[int, Range(1, 128)] = 4,
|
| 436 |
distance: list[str] = ["L1"],
|
| 437 |
layers_weight: list[float] = [1.0],
|
| 438 |
subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
|
| 439 |
pca: list[int] = [0],
|
| 440 |
stages: list[str] = ["coarse", "fine"],
|
| 441 |
linear: bool = True,
|
| 442 |
+
linear_iterations: Annotated[int, Range(0, 100000)] = 200,
|
| 443 |
seed: int = 42,
|
| 444 |
) -> None:
|
| 445 |
super().__init__(
|
ConvexAdam_Fine/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Fine Adam refinement (tileable; expects a coarse/pre-aligned start).",
|
| 4 |
"description": "Second stage of the ConvexAdam pipeline: the Adam instance-optimisation refinement driven by the IMPACT metric on MIND features, with no linear pre-align (it assumes the moving is already on the fixed grid, e.g. resampled by the coarse stage). Runs from a zero warm-start and tiles naturally for large images (set a patch size).",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"vram_plan": {
|
| 9 |
"8": {"patch_size": [128, 128, 128], "batch_size": 1},
|
|
|
|
| 3 |
"short_description": "Fine Adam refinement (tileable; expects a coarse/pre-aligned start).",
|
| 4 |
"description": "Second stage of the ConvexAdam pipeline: the Adam instance-optimisation refinement driven by the IMPACT metric on MIND features, with no linear pre-align (it assumes the moving is already on the fixed grid, e.g. resampled by the coarse stage). Runs from a zero warm-start and tiles naturally for large images (set a patch size).",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"vram_plan": {
|
| 9 |
"8": {"patch_size": [128, 128, 128], "batch_size": 1},
|
Generic_Rigid/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
Generic_Rigid/Prediction.yml
CHANGED
|
@@ -7,7 +7,7 @@ Predictor:
|
|
| 7 |
- Parameters_Rigid.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
-
spatial_samples:
|
| 11 |
parameter_overrides: []
|
| 12 |
Dataset:
|
| 13 |
groups_src:
|
|
|
|
| 7 |
- Parameters_Rigid.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
+
spatial_samples: 2048
|
| 11 |
parameter_overrides: []
|
| 12 |
Dataset:
|
| 13 |
groups_src:
|
Generic_Rigid/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Rigid registration using mutual information and a multi-resolution pyramid.",
|
| 4 |
"description": "This preset performs rigid alignment using an Euler transform optimized with Adaptive Stochastic Gradient Descent. It uses a 4-level multi-resolution strategy and Mattes mutual information as similarity metric. Initial alignment based on image centers are enabled to ensure robust convergence for multimodal images.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Rigid registration using mutual information and a multi-resolution pyramid.",
|
| 4 |
"description": "This preset performs rigid alignment using an Euler transform optimized with Adaptive Stochastic Gradient Descent. It uses a 4-level multi-resolution strategy and Mattes mutual information as similarity metric. Initial alignment based on image centers are enabled to ensure robust convergence for multimodal images.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
Generic_Rigid/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|
Generic_Rigid_BSpline/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
Generic_Rigid_BSpline/Prediction.yml
CHANGED
|
@@ -8,8 +8,8 @@ Predictor:
|
|
| 8 |
- Parameters_BSpline.txt
|
| 9 |
outputs_criterions: None
|
| 10 |
max_iterations: 0
|
| 11 |
-
final_grid_spacing:
|
| 12 |
-
spatial_samples:
|
| 13 |
parameter_overrides: []
|
| 14 |
Dataset:
|
| 15 |
groups_src:
|
|
|
|
| 8 |
- Parameters_BSpline.txt
|
| 9 |
outputs_criterions: None
|
| 10 |
max_iterations: 0
|
| 11 |
+
final_grid_spacing: 16.0
|
| 12 |
+
spatial_samples: 2048
|
| 13 |
parameter_overrides: []
|
| 14 |
Dataset:
|
| 15 |
groups_src:
|
Generic_Rigid_BSpline/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Two-stage registration: rigid alignment followed by BSpline refinement.",
|
| 4 |
"description": "A combined registration strategy: first a rigid Euler transform corrects global misalignment, then a BSpline model captures localized anatomical deformations. Both stages use a multi-resolution pyramid, mutual information, and stochastic optimization for robust performance across a wide range of multimodal imaging scenarios.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Two-stage registration: rigid alignment followed by BSpline refinement.",
|
| 4 |
"description": "A combined registration strategy: first a rigid Euler transform corrects global misalignment, then a BSpline model captures localized anatomical deformations. Both stages use a multi-resolution pyramid, mutual information, and stochastic optimization for robust performance across a wide range of multimodal imaging scenarios.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
Generic_Rigid_BSpline/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|
MR_CT_HeadNeck/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
MR_CT_HeadNeck/Prediction.yml
CHANGED
|
@@ -7,9 +7,9 @@ Predictor:
|
|
| 7 |
- ParameterMap_MRI_HN.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
-
final_grid_spacing:
|
| 11 |
subset_features: 0
|
| 12 |
-
spatial_samples:
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
@@ -72,7 +72,6 @@ Predictor:
|
|
| 72 |
subset_features: 32
|
| 73 |
pca: 0
|
| 74 |
distance: L1
|
| 75 |
-
models_registry: VBoussot/impact-torchscript-models:models.json
|
| 76 |
mode: Static
|
| 77 |
Dataset:
|
| 78 |
groups_src:
|
|
|
|
| 7 |
- ParameterMap_MRI_HN.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
+
final_grid_spacing: 12.0
|
| 11 |
subset_features: 0
|
| 12 |
+
spatial_samples: 2000
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
|
|
| 72 |
subset_features: 32
|
| 73 |
pca: 0
|
| 74 |
distance: L1
|
|
|
|
| 75 |
mode: Static
|
| 76 |
Dataset:
|
| 77 |
groups_src:
|
MR_CT_HeadNeck/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Optimized preset for MR/CT registration on head & neck",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for MRI head-and-neck images, driven by the IMPACT metric and combining semantic features extracted from the pretrained MIND model at progressively finer voxel scales (6 mm, 3 mm, 2 mm, 2 mm). The optimization follows a multi-resolution scheme with up to 300, 300, 250, and 200 ASGD iterations, using stochastic sampling and a composite metric (IMPACT + mutual information + bending energy) to achieve robust semantic alignment in complex head-and-neck anatomy.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Optimized preset for MR/CT registration on head & neck",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for MRI head-and-neck images, driven by the IMPACT metric and combining semantic features extracted from the pretrained MIND model at progressively finer voxel scales (6 mm, 3 mm, 2 mm, 2 mm). The optimization follows a multi-resolution scheme with up to 300, 300, 250, and 200 ASGD iterations, using stochastic sampling and a composite metric (IMPACT + mutual information + bending energy) to achieve robust semantic alignment in complex head-and-neck anatomy.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
MR_CT_HeadNeck/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|
MR_CT_MRSeg/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
MR_CT_MRSeg/Prediction.yml
CHANGED
|
@@ -7,9 +7,9 @@ Predictor:
|
|
| 7 |
- ParameterMap_MRI_MRSeg.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
-
final_grid_spacing:
|
| 11 |
subset_features: 0
|
| 12 |
-
spatial_samples:
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
@@ -120,7 +120,6 @@ Predictor:
|
|
| 120 |
subset_features: 64
|
| 121 |
pca: 0
|
| 122 |
distance: Dice
|
| 123 |
-
models_registry: VBoussot/impact-torchscript-models:models.json
|
| 124 |
mode: Static
|
| 125 |
Dataset:
|
| 126 |
groups_src:
|
|
|
|
| 7 |
- ParameterMap_MRI_MRSeg.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
+
final_grid_spacing: 14.0
|
| 11 |
subset_features: 0
|
| 12 |
+
spatial_samples: 2000
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
|
|
| 120 |
subset_features: 64
|
| 121 |
pca: 0
|
| 122 |
distance: Dice
|
|
|
|
| 123 |
mode: Static
|
| 124 |
Dataset:
|
| 125 |
groups_src:
|
MR_CT_MRSeg/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Generic MR/CT deformable registration using MIND + MRSegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic MR/CT alignment, driven by the IMPACT metric and combining semantic features from two pretrained models: MIND (L1 distance on a subset of 32 features) and MRSegmentator (Dice overlap on segmentation outputs with 64 features). Features are extracted at progressively finer voxel scales with level-dependent weighting between MIND and MRSegmentator. The optimization follows a multi-resolution ASGD scheme with a composite objective (IMPACT + mutual information + bending energy penalty) to ensure robust cross-modality semantic alignment and smooth deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Generic MR/CT deformable registration using MIND + MRSegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic MR/CT alignment, driven by the IMPACT metric and combining semantic features from two pretrained models: MIND (L1 distance on a subset of 32 features) and MRSegmentator (Dice overlap on segmentation outputs with 64 features). Features are extracted at progressively finer voxel scales with level-dependent weighting between MIND and MRSegmentator. The optimization follows a multi-resolution ASGD scheme with a composite objective (IMPACT + mutual information + bending energy penalty) to ensure robust cross-modality semantic alignment and smooth deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
MR_CT_MRSeg/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|
MR_CT_TS/Model.py
CHANGED
|
@@ -14,115 +14,89 @@
|
|
| 14 |
#
|
| 15 |
# SPDX-License-Identifier: Apache-2.0
|
| 16 |
|
| 17 |
-
"""Registration as a KonfAI model
|
| 18 |
|
| 19 |
-
``RegistrationNet`` wires
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
``
|
| 24 |
-
needs to register in physical space.
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
|
| 32 |
-
runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
|
| 33 |
"""
|
| 34 |
|
| 35 |
import json
|
| 36 |
import os
|
| 37 |
import re
|
| 38 |
-
import
|
| 39 |
-
import subprocess # nosec B404
|
| 40 |
-
import tempfile
|
| 41 |
from pathlib import Path
|
|
|
|
| 42 |
|
| 43 |
-
import numpy as np
|
| 44 |
-
import SimpleITK as sitk
|
| 45 |
import torch
|
| 46 |
-
import tqdm
|
| 47 |
from huggingface_hub import hf_hub_download
|
| 48 |
-
from install import get_elastix_bin, install_elastix_impact, try_elastix
|
| 49 |
from konfai.network import network
|
| 50 |
-
from konfai.utils.
|
| 51 |
-
|
| 52 |
-
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
|
| 53 |
-
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
|
| 54 |
-
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
|
| 55 |
|
| 56 |
-
# ---------------------------------------------------------------------------------------------------
|
| 57 |
-
# Per-resolution model matrix (the config is the source of truth) -> generated IMPACT parameter map.
|
| 58 |
# IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
|
| 59 |
-
#
|
| 60 |
-
# VBoussot/impact-torchscript-models); the config carries the FREE knobs (
|
| 61 |
-
#
|
| 62 |
-
# ``mode``. PatchSize follows ImpactMode: Static -> "0 0 0" (whole image); Jacobian -> the model FOV
|
| 63 |
-
# evaluated from the registry formula (MIND 2*r*d+1, TS/MRSeg 2^l+3, SAM 29, DINOv2 14) as a cube.
|
| 64 |
-
# ---------------------------------------------------------------------------------------------------
|
| 65 |
-
|
| 66 |
_IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
|
| 67 |
|
| 68 |
-
# ``2^l+3``
|
| 69 |
-
#
|
| 70 |
-
# we clamp ``l`` to this plateau so the patch stays finite and matches the real FOV.
|
| 71 |
_FOV_RAMP_MAX_LAYER = 6
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _num(x: object) -> str:
|
| 75 |
-
"""Format a number the elastix way:
|
| 76 |
return "%g" % float(x)
|
| 77 |
|
| 78 |
|
|
|
|
| 79 |
class ModelSpec:
|
| 80 |
-
"""One feature model at one resolution
|
| 81 |
-
|
| 82 |
-
``
|
| 83 |
-
``distance`` are its free per-(resolution, model) tuning knobs (the doc's per-model *tuning* fields).
|
| 84 |
-
The intrinsic per-model props — dimension, channels, ``layers_mask``, patch-size (FOV) — come from the
|
| 85 |
-
registry (read-only); ``layers_mask`` / ``distance`` left empty fall back to the registry default.
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
distance: str = "",
|
| 96 |
-
layers_mask: str = "",
|
| 97 |
-
) -> None:
|
| 98 |
-
self.ref = ref
|
| 99 |
-
self.voxel_size = voxel_size
|
| 100 |
-
self.layers_weight = layers_weight
|
| 101 |
-
self.subset_features = subset_features
|
| 102 |
-
self.pca = pca
|
| 103 |
-
self.distance = distance
|
| 104 |
-
self.layers_mask = layers_mask
|
| 105 |
|
| 106 |
|
|
|
|
| 107 |
class ResolutionSpec:
|
| 108 |
-
"""One elastix resolution level: its iteration budget and the
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
self.models = models
|
| 113 |
|
| 114 |
|
| 115 |
def _sorted_specs(mapping: dict) -> list:
|
| 116 |
-
"""dict keyed by string indices ('0','1',...) -> values in numeric order
|
| 117 |
return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
|
| 118 |
|
| 119 |
|
| 120 |
def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
| 121 |
-
"""Load models.json (
|
| 122 |
|
| 123 |
-
The registry is NOT bundled with the preset
|
| 124 |
-
|
| 125 |
-
a ``repo:file`` Hugging Face reference.
|
| 126 |
"""
|
| 127 |
local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
|
| 128 |
if local:
|
|
@@ -139,17 +113,16 @@ def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
|
|
| 139 |
|
| 140 |
|
| 141 |
def _model_key(ref: str) -> str:
|
| 142 |
-
"""Registry key / staged relative path = the model file within the
|
| 143 |
return ref.split(":", 1)[1] if ":" in ref else ref
|
| 144 |
|
| 145 |
|
| 146 |
def _deepest_active_layer(layers_mask: str) -> int:
|
| 147 |
-
"""Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index
|
| 148 |
|
| 149 |
-
A model returns its
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
FOV is governed by the rightmost ``'1'``.
|
| 153 |
"""
|
| 154 |
mask = layers_mask.strip().strip('"')
|
| 155 |
active = [i for i, char in enumerate(mask) if char == "1"]
|
|
@@ -161,13 +134,13 @@ def _deepest_active_layer(layers_mask: str) -> int:
|
|
| 161 |
def _fov_value(fov: dict, layers_mask: str) -> int:
|
| 162 |
"""Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
|
| 163 |
|
| 164 |
-
|
| 165 |
-
``2*r*d+1`` MIND, from
|
| 166 |
-
``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` =
|
| 167 |
-
|
| 168 |
-
a bare int
|
| 169 |
-
``Global`` Anatomix — whole-image only (Static);
|
| 170 |
-
An explicit ``value`` in the spec is honoured as a precomputed shortcut
|
| 171 |
"""
|
| 172 |
formula = str(fov.get("formula", "")).strip()
|
| 173 |
key = re.sub(r"\s+", "", formula).lower()
|
|
@@ -185,9 +158,9 @@ def _fov_value(fov: dict, layers_mask: str) -> int:
|
|
| 185 |
|
| 186 |
|
| 187 |
def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
| 188 |
-
"""PatchSize from the model FOV, one token per model axis (2D
|
| 189 |
-
|
| 190 |
-
|
| 191 |
dim = int(entry.get("dimension", 3))
|
| 192 |
if mode.strip().strip('"').lower() != "jacobian":
|
| 193 |
return " ".join(["0"] * dim)
|
|
@@ -195,16 +168,13 @@ def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
|
|
| 195 |
return " ".join([str(fov)] * dim)
|
| 196 |
|
| 197 |
|
| 198 |
-
def generate_impact_parameter_map(
|
| 199 |
-
template_text: str, resolutions: dict, registry: dict, mode: str = "Static"
|
| 200 |
-
) -> str:
|
| 201 |
"""Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
|
| 202 |
|
| 203 |
Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
|
| 204 |
-
ImpactMode
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
per-model FOV evaluated from the registry formula and the cell's ``layers_mask``.
|
| 208 |
"""
|
| 209 |
res = _sorted_specs(resolutions)
|
| 210 |
n = len(res)
|
|
@@ -218,9 +188,8 @@ def generate_impact_parameter_map(
|
|
| 218 |
def row(stem: str, values: list[str]) -> None:
|
| 219 |
impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
|
| 220 |
|
| 221 |
-
# From the registry
|
| 222 |
-
#
|
| 223 |
-
# taken straight from the cell: VoxelSize / LayersMask / SubsetFeatures / PCA / Distance / LayersWeight.
|
| 224 |
row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
|
| 225 |
row("Dimension", [e["dimension"] for e in entries])
|
| 226 |
row("NumberOfChannels", [e["numberofchannels"] for e in entries])
|
|
@@ -234,8 +203,7 @@ def generate_impact_parameter_map(
|
|
| 234 |
impact.append("") # blank line between resolutions, mirroring the reference maps
|
| 235 |
|
| 236 |
# The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
|
| 237 |
-
# (
|
| 238 |
-
# span in one shot with the generated block, so the reference blanks are not kept on top of ours.
|
| 239 |
lines = template_text.splitlines()
|
| 240 |
indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
|
| 241 |
block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
|
|
@@ -260,352 +228,6 @@ def generate_impact_parameter_map(
|
|
| 260 |
return "\n".join(out)
|
| 261 |
|
| 262 |
|
| 263 |
-
class ElastixEngine:
|
| 264 |
-
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 265 |
-
|
| 266 |
-
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
|
| 267 |
-
does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
def __init__(
|
| 271 |
-
self,
|
| 272 |
-
parameter_maps: list[str],
|
| 273 |
-
max_iterations: int = 0,
|
| 274 |
-
final_grid_spacing: float = 0.0,
|
| 275 |
-
subset_features: int = 0,
|
| 276 |
-
spatial_samples: int = 0,
|
| 277 |
-
parameter_overrides: list[str] = [],
|
| 278 |
-
resolutions: dict = {},
|
| 279 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 280 |
-
mode: str = "Static",
|
| 281 |
-
) -> None:
|
| 282 |
-
self._bundle_dir = Path(__file__).resolve().parent
|
| 283 |
-
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 284 |
-
self._max_iterations = max_iterations
|
| 285 |
-
self._final_grid_spacing = final_grid_spacing
|
| 286 |
-
self._subset_features = subset_features
|
| 287 |
-
self._spatial_samples = spatial_samples
|
| 288 |
-
self._parameter_overrides = list(parameter_overrides)
|
| 289 |
-
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 290 |
-
# samples random patches sized to the model FOV each iteration. Global knob: one mode per preset.
|
| 291 |
-
self._mode = mode
|
| 292 |
-
# Matrix mode: when `resolutions` is given the parameter map is GENERATED from it (the config is the
|
| 293 |
-
# source of truth). An empty `resolutions` = an intensity preset (no IMPACT feature models): the fixed
|
| 294 |
-
# parameter maps are staged with only the global knob overrides.
|
| 295 |
-
self._resolutions = resolutions
|
| 296 |
-
self._registry = load_models_registry(models_registry) if resolutions else {}
|
| 297 |
-
# The feature models are DERIVED — the unique refs across the matrix cells (no flat `models` param).
|
| 298 |
-
models: list[str] = []
|
| 299 |
-
for res in _sorted_specs(resolutions):
|
| 300 |
-
for model in _sorted_specs(res.models):
|
| 301 |
-
if model.ref not in models:
|
| 302 |
-
models.append(model.ref)
|
| 303 |
-
self._models = models
|
| 304 |
-
# `iterations` (the progress-bar total) is NOT a config parameter — it is DERIVED: the sum of the
|
| 305 |
-
# per-resolution iteration budgets, read from the matrix (matrix mode) or the maps (legacy).
|
| 306 |
-
self._iterations = self._total_iterations()
|
| 307 |
-
self._elastix_bin = self._ensure_binary()
|
| 308 |
-
self._local_models = self._download_models()
|
| 309 |
-
|
| 310 |
-
def _total_iterations(self) -> int:
|
| 311 |
-
"""Total iterations across all resolutions — the progress-bar budget, derived from the config."""
|
| 312 |
-
if self._resolutions:
|
| 313 |
-
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 314 |
-
total = 0
|
| 315 |
-
for src in self._parameter_maps:
|
| 316 |
-
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 317 |
-
if match:
|
| 318 |
-
total += sum(int(token) for token in match.group(1).split())
|
| 319 |
-
return total
|
| 320 |
-
|
| 321 |
-
def _ensure_binary(self) -> Path:
|
| 322 |
-
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 323 |
-
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 324 |
-
if override:
|
| 325 |
-
try_elastix(Path(override))
|
| 326 |
-
return get_elastix_bin(Path(override)).resolve()
|
| 327 |
-
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 328 |
-
try:
|
| 329 |
-
try_elastix(ELASTIX_CACHE)
|
| 330 |
-
except Exception:
|
| 331 |
-
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 332 |
-
try_elastix(ELASTIX_CACHE)
|
| 333 |
-
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 334 |
-
|
| 335 |
-
def _download_models(self) -> list[tuple[str, Path]]:
|
| 336 |
-
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 337 |
-
models = []
|
| 338 |
-
for ref in self._models:
|
| 339 |
-
repo, filename = ref.split(":", 1)
|
| 340 |
-
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 341 |
-
models.append((filename, local))
|
| 342 |
-
return models
|
| 343 |
-
|
| 344 |
-
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 345 |
-
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 346 |
-
|
| 347 |
-
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value that replaces
|
| 348 |
-
**each** existing token, so per-resolution / per-model multiplicity is preserved (e.g.
|
| 349 |
-
``(MaximumNumberOfIterations 500 250)`` -> ``(MaximumNumberOfIterations 300 300)``). ``exact``
|
| 350 |
-
entries (from ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win
|
| 351 |
-
over the named knobs. Overrides only REPLACE keys already present in a map — never inject new ones.
|
| 352 |
-
``global_only`` (matrix mode) keeps just the map-wide knobs and drops ``max_iterations`` /
|
| 353 |
-
``subset_features`` — the per-resolution matrix already sets those per cell.
|
| 354 |
-
"""
|
| 355 |
-
per_token: dict[str, str] = {}
|
| 356 |
-
if not global_only and self._max_iterations > 0:
|
| 357 |
-
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 358 |
-
if self._final_grid_spacing > 0:
|
| 359 |
-
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 360 |
-
if not global_only and self._subset_features > 0:
|
| 361 |
-
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 362 |
-
if self._spatial_samples > 0:
|
| 363 |
-
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 364 |
-
exact: list[tuple[str, str]] = []
|
| 365 |
-
for entry in self._parameter_overrides:
|
| 366 |
-
key, sep, value = entry.partition("=")
|
| 367 |
-
if not sep or not key.strip():
|
| 368 |
-
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 369 |
-
exact.append((key.strip(), value.strip()))
|
| 370 |
-
return per_token, exact
|
| 371 |
-
|
| 372 |
-
@staticmethod
|
| 373 |
-
def _apply_map_overrides(
|
| 374 |
-
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 375 |
-
) -> str:
|
| 376 |
-
"""Patch a parameter map's text: set ImpactGPU to the device, apply exact key overrides, replace each
|
| 377 |
-
token of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 378 |
-
"""
|
| 379 |
-
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 380 |
-
requested = set(per_token) | {key for key, _ in exact}
|
| 381 |
-
seen: set[str] = set()
|
| 382 |
-
lines = []
|
| 383 |
-
for line in text.splitlines():
|
| 384 |
-
match = entry_pattern.match(line)
|
| 385 |
-
if match:
|
| 386 |
-
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 387 |
-
if key == "ImpactGPU":
|
| 388 |
-
line = f"{indent}(ImpactGPU {device_index})"
|
| 389 |
-
else:
|
| 390 |
-
exact_value = next((value for k, value in exact if k == key), None)
|
| 391 |
-
if exact_value is not None:
|
| 392 |
-
seen.add(key)
|
| 393 |
-
line = f"{indent}({key} {exact_value})"
|
| 394 |
-
else:
|
| 395 |
-
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 396 |
-
if token_key in per_token:
|
| 397 |
-
seen.add(token_key)
|
| 398 |
-
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 399 |
-
line = f"{indent}({key} {replaced})"
|
| 400 |
-
lines.append(line)
|
| 401 |
-
# Overrides never inject keys, so a knob set for a key absent from every map would silently do
|
| 402 |
-
# nothing — surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 403 |
-
for key in sorted(requested - seen):
|
| 404 |
-
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 405 |
-
return "\n".join(lines)
|
| 406 |
-
|
| 407 |
-
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 408 |
-
"""Stage the parameter maps into the work dir.
|
| 409 |
-
|
| 410 |
-
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 411 |
-
knobs (grid spacing, spatial samples, exact overrides) — the matrix already sets iterations and
|
| 412 |
-
features per cell. Legacy mode copies the preset's maps and applies every per-token / exact override.
|
| 413 |
-
Both set the ImpactGPU device.
|
| 414 |
-
"""
|
| 415 |
-
staged = []
|
| 416 |
-
for src in self._parameter_maps:
|
| 417 |
-
if self._resolutions:
|
| 418 |
-
text = generate_impact_parameter_map(
|
| 419 |
-
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 420 |
-
)
|
| 421 |
-
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 422 |
-
else:
|
| 423 |
-
text = src.read_text(encoding="utf-8")
|
| 424 |
-
per_token, exact = self._parameter_map_overrides()
|
| 425 |
-
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 426 |
-
dst = work / src.name
|
| 427 |
-
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 428 |
-
staged.append(dst)
|
| 429 |
-
return staged
|
| 430 |
-
|
| 431 |
-
def register(
|
| 432 |
-
self,
|
| 433 |
-
fixed: sitk.Image,
|
| 434 |
-
moving: sitk.Image,
|
| 435 |
-
device_index: int,
|
| 436 |
-
fixed_mask: sitk.Image | None = None,
|
| 437 |
-
moving_mask: sitk.Image | None = None,
|
| 438 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
-
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 440 |
-
|
| 441 |
-
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
|
| 442 |
-
(elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 443 |
-
"""
|
| 444 |
-
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 445 |
-
try:
|
| 446 |
-
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 447 |
-
sitk.WriteImage(fixed, str(fixed_path))
|
| 448 |
-
sitk.WriteImage(moving, str(moving_path))
|
| 449 |
-
|
| 450 |
-
# Stage the feature models at the relative path the parameter maps reference
|
| 451 |
-
# (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 452 |
-
for rel_name, model_path in self._local_models:
|
| 453 |
-
dst = work / rel_name
|
| 454 |
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
-
if not dst.exists():
|
| 456 |
-
dst.symlink_to(model_path)
|
| 457 |
-
|
| 458 |
-
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 459 |
-
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 460 |
-
if mask is not None:
|
| 461 |
-
mask_path = work / name
|
| 462 |
-
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 463 |
-
args += [flag, str(mask_path)]
|
| 464 |
-
args += ["-out", str(work)]
|
| 465 |
-
for pmap in self._stage_parameter_maps(work, device_index):
|
| 466 |
-
args += ["-p", str(pmap)]
|
| 467 |
-
|
| 468 |
-
# Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
|
| 469 |
-
# the "N% done/total" progress line) shows real progress during the long registration.
|
| 470 |
-
# Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
|
| 471 |
-
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 472 |
-
env = os.environ.copy()
|
| 473 |
-
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 474 |
-
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 475 |
-
proc = subprocess.Popen( # nosec B603
|
| 476 |
-
args,
|
| 477 |
-
cwd=str(work),
|
| 478 |
-
stdout=subprocess.PIPE,
|
| 479 |
-
stderr=subprocess.STDOUT,
|
| 480 |
-
text=True,
|
| 481 |
-
bufsize=1,
|
| 482 |
-
env=env,
|
| 483 |
-
)
|
| 484 |
-
captured: list[str] = []
|
| 485 |
-
iteration_line = re.compile(r"^\d+\s")
|
| 486 |
-
# ``iterations`` is the total iteration budget declared for the preset (summed over the
|
| 487 |
-
# chained parameter maps), so the bar spans the whole chain of registration stages. A tuned
|
| 488 |
-
# ``max_iterations`` makes that declared budget stale — fall back to an open-ended bar.
|
| 489 |
-
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 490 |
-
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 491 |
-
assert proc.stdout is not None
|
| 492 |
-
resolution = 0
|
| 493 |
-
for line in proc.stdout:
|
| 494 |
-
captured.append(line)
|
| 495 |
-
stripped = line.strip()
|
| 496 |
-
if stripped.startswith("Resolution:"):
|
| 497 |
-
try:
|
| 498 |
-
resolution = int(stripped.split(":", 1)[1])
|
| 499 |
-
except ValueError:
|
| 500 |
-
pass
|
| 501 |
-
elif iteration_line.match(line):
|
| 502 |
-
progress.update(1)
|
| 503 |
-
# Mirror KonfAI's informative bars (which surface runtime state in the description):
|
| 504 |
-
# show the elastix resolution level and the similarity metric being optimised so the
|
| 505 |
-
# bar conveys convergence, not a bare iteration count. Column 2 of the iteration table
|
| 506 |
-
# is the metric (header: "1:ItNr 2:Metric ...").
|
| 507 |
-
columns = line.split()
|
| 508 |
-
if len(columns) > 1:
|
| 509 |
-
try:
|
| 510 |
-
progress.set_description(
|
| 511 |
-
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 512 |
-
)
|
| 513 |
-
except ValueError:
|
| 514 |
-
pass
|
| 515 |
-
progress.close()
|
| 516 |
-
returncode = proc.wait()
|
| 517 |
-
if returncode != 0:
|
| 518 |
-
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 519 |
-
|
| 520 |
-
transforms = sorted(
|
| 521 |
-
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 522 |
-
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 523 |
-
)
|
| 524 |
-
if not transforms:
|
| 525 |
-
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 526 |
-
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 527 |
-
|
| 528 |
-
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 529 |
-
dvf = sitk.TransformToDisplacementField(
|
| 530 |
-
transform,
|
| 531 |
-
sitk.sitkVectorFloat64,
|
| 532 |
-
fixed.GetSize(),
|
| 533 |
-
fixed.GetOrigin(),
|
| 534 |
-
fixed.GetSpacing(),
|
| 535 |
-
fixed.GetDirection(),
|
| 536 |
-
)
|
| 537 |
-
moved_np, _ = image_to_data(moved)
|
| 538 |
-
dvf_np, _ = image_to_data(dvf)
|
| 539 |
-
return moved_np, dvf_np
|
| 540 |
-
finally:
|
| 541 |
-
shutil.rmtree(work, ignore_errors=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
class ElastixRegistration(torch.nn.Module):
|
| 545 |
-
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 546 |
-
|
| 547 |
-
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 548 |
-
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
|
| 549 |
-
needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 550 |
-
"""
|
| 551 |
-
|
| 552 |
-
accepts_attributes = True
|
| 553 |
-
|
| 554 |
-
def __init__(
|
| 555 |
-
self,
|
| 556 |
-
engine: str,
|
| 557 |
-
parameter_maps: list[str],
|
| 558 |
-
max_iterations: int = 0,
|
| 559 |
-
final_grid_spacing: float = 0.0,
|
| 560 |
-
subset_features: int = 0,
|
| 561 |
-
spatial_samples: int = 0,
|
| 562 |
-
parameter_overrides: list[str] = [],
|
| 563 |
-
resolutions: dict = {},
|
| 564 |
-
models_registry: str = _IMPACT_MODELS_REGISTRY,
|
| 565 |
-
mode: str = "Static",
|
| 566 |
-
) -> None:
|
| 567 |
-
super().__init__()
|
| 568 |
-
if engine != "elastix":
|
| 569 |
-
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 570 |
-
self._engine = ElastixEngine(
|
| 571 |
-
parameter_maps,
|
| 572 |
-
max_iterations,
|
| 573 |
-
final_grid_spacing,
|
| 574 |
-
subset_features,
|
| 575 |
-
spatial_samples,
|
| 576 |
-
parameter_overrides,
|
| 577 |
-
resolutions,
|
| 578 |
-
models_registry,
|
| 579 |
-
mode,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def forward(
|
| 583 |
-
self,
|
| 584 |
-
fixed: torch.Tensor,
|
| 585 |
-
moving: torch.Tensor,
|
| 586 |
-
fixed_mask: torch.Tensor,
|
| 587 |
-
moving_mask: torch.Tensor,
|
| 588 |
-
attributes: list[list[Attribute]],
|
| 589 |
-
) -> torch.Tensor:
|
| 590 |
-
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
|
| 591 |
-
# Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
|
| 592 |
-
# (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
|
| 593 |
-
# the whole image (the auto-filled default when the user supplies none) restricts nothing.
|
| 594 |
-
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 595 |
-
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 596 |
-
combined = []
|
| 597 |
-
for b in range(fixed.shape[0]):
|
| 598 |
-
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 599 |
-
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 600 |
-
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 601 |
-
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 602 |
-
moved_np, dvf_np = self._engine.register(
|
| 603 |
-
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 604 |
-
)
|
| 605 |
-
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 606 |
-
return torch.stack(combined, dim=0).to(fixed.device)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
class ChannelSelect(torch.nn.Module):
|
| 610 |
"""Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
|
| 611 |
|
|
@@ -619,13 +241,13 @@ class ChannelSelect(torch.nn.Module):
|
|
| 619 |
|
| 620 |
|
| 621 |
class RegistrationNet(network.Network):
|
| 622 |
-
"""Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
|
| 623 |
-
|
| 624 |
|
| 625 |
-
Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
``
|
| 629 |
"""
|
| 630 |
|
| 631 |
def __init__(
|
|
@@ -637,23 +259,21 @@ class RegistrationNet(network.Network):
|
|
| 637 |
outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
|
| 638 |
engine: str = "elastix",
|
| 639 |
parameter_maps: list[str] = [],
|
| 640 |
-
max_iterations: int = 0,
|
| 641 |
-
final_grid_spacing: float = 0.0,
|
| 642 |
-
subset_features: int = 0,
|
| 643 |
-
spatial_samples: int = 0,
|
| 644 |
parameter_overrides: list[str] = [],
|
| 645 |
resolutions: dict[str, ResolutionSpec] = {},
|
| 646 |
-
|
| 647 |
-
mode: str = "Static",
|
| 648 |
) -> None:
|
| 649 |
-
# The registration is fully described by
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
#
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
# with just the global overrides. The total iteration count is derived (sum of per-resolution budgets).
|
| 657 |
super().__init__(
|
| 658 |
in_channels=1,
|
| 659 |
optimizer=optimizer,
|
|
@@ -672,7 +292,6 @@ class RegistrationNet(network.Network):
|
|
| 672 |
spatial_samples,
|
| 673 |
parameter_overrides,
|
| 674 |
resolutions,
|
| 675 |
-
models_registry,
|
| 676 |
mode,
|
| 677 |
),
|
| 678 |
in_branch=[0, 1, 2, 3],
|
|
|
|
| 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:
|
|
|
|
| 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"]
|
|
|
|
| 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()
|
|
|
|
| 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)
|
|
|
|
| 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)
|
|
|
|
| 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])
|
|
|
|
| 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))]
|
|
|
|
| 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 |
|
|
|
|
| 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__(
|
|
|
|
| 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,
|
|
|
|
| 292 |
spatial_samples,
|
| 293 |
parameter_overrides,
|
| 294 |
resolutions,
|
|
|
|
| 295 |
mode,
|
| 296 |
),
|
| 297 |
in_branch=[0, 1, 2, 3],
|
MR_CT_TS/Prediction.yml
CHANGED
|
@@ -7,9 +7,9 @@ Predictor:
|
|
| 7 |
- ParameterMap_MRI_TS.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
-
final_grid_spacing:
|
| 11 |
subset_features: 0
|
| 12 |
-
spatial_samples:
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
@@ -120,7 +120,6 @@ Predictor:
|
|
| 120 |
subset_features: 64
|
| 121 |
pca: 0
|
| 122 |
distance: Dice
|
| 123 |
-
models_registry: VBoussot/impact-torchscript-models:models.json
|
| 124 |
mode: Static
|
| 125 |
Dataset:
|
| 126 |
groups_src:
|
|
|
|
| 7 |
- ParameterMap_MRI_TS.txt
|
| 8 |
outputs_criterions: None
|
| 9 |
max_iterations: 0
|
| 10 |
+
final_grid_spacing: 14.0
|
| 11 |
subset_features: 0
|
| 12 |
+
spatial_samples: 2000
|
| 13 |
parameter_overrides: []
|
| 14 |
resolutions:
|
| 15 |
'0':
|
|
|
|
| 120 |
subset_features: 64
|
| 121 |
pca: 0
|
| 122 |
distance: Dice
|
|
|
|
| 123 |
mode: Static
|
| 124 |
Dataset:
|
| 125 |
groups_src:
|
MR_CT_TS/app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
"short_description": "Generic MR/CT deformable registration using MIND + Totalsegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic MR/CT alignment, driven by the IMPACT metric and combining semantic features from two pretrained models: MIND (L1 distance on a subset of 32 features) and Totalsegmentator (Dice overlap on segmentation outputs with 64 features). Features are extracted at progressively finer voxel scales (6 mm, 3 mm, 2 mm, 2 mm) with level-dependent weighting between MIND and MRSegmentator (0.2/0.8, 0.3/0.7, 0.6/0.4, 0.7/0.3). The optimization follows a multi-resolution ASGD scheme with up to 400, 300, 200, and 200 iterations using 2000 random spatial samples, and a composite objective (IMPACT + mutual information + bending energy penalty) to ensure robust cross-modality semantic alignment and smooth deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
-
"tta":
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
|
|
|
| 3 |
"short_description": "Generic MR/CT deformable registration using MIND + Totalsegmentator features",
|
| 4 |
"description": "A four-level recursive B-spline deformable registration optimized for generic MR/CT alignment, driven by the IMPACT metric and combining semantic features from two pretrained models: MIND (L1 distance on a subset of 32 features) and Totalsegmentator (Dice overlap on segmentation outputs with 64 features). Features are extracted at progressively finer voxel scales (6 mm, 3 mm, 2 mm, 2 mm) with level-dependent weighting between MIND and MRSegmentator (0.2/0.8, 0.3/0.7, 0.6/0.4, 0.7/0.3). The optimization follows a multi-resolution ASGD scheme with up to 400, 300, 200, and 200 iterations using 2000 random spatial samples, and a composite objective (IMPACT + mutual information + bending energy penalty) to ensure robust cross-modality semantic alignment and smooth deformations.",
|
| 5 |
"task": "registration",
|
| 6 |
+
"tta": 0,
|
| 7 |
"mc_dropout": 0,
|
| 8 |
"models": [
|
| 9 |
"model.pt"
|
MR_CT_TS/elastix_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ElastixEngine:
|
| 48 |
+
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
|
| 49 |
+
|
| 50 |
+
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
|
| 51 |
+
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
parameter_maps: list[str],
|
| 57 |
+
max_iterations: int = 0,
|
| 58 |
+
final_grid_spacing: float = 0.0,
|
| 59 |
+
subset_features: int = 0,
|
| 60 |
+
spatial_samples: int = 0,
|
| 61 |
+
parameter_overrides: list[str] = [],
|
| 62 |
+
resolutions: dict = {},
|
| 63 |
+
mode: str = "Static",
|
| 64 |
+
) -> None:
|
| 65 |
+
self._bundle_dir = Path(__file__).resolve().parent
|
| 66 |
+
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
|
| 67 |
+
self._max_iterations = max_iterations
|
| 68 |
+
self._final_grid_spacing = final_grid_spacing
|
| 69 |
+
self._subset_features = subset_features
|
| 70 |
+
self._spatial_samples = spatial_samples
|
| 71 |
+
self._parameter_overrides = list(parameter_overrides)
|
| 72 |
+
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
|
| 73 |
+
# samples random FOV-sized patches each iteration. One mode per preset.
|
| 74 |
+
self._mode = mode
|
| 75 |
+
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
|
| 76 |
+
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
|
| 77 |
+
self._resolutions = resolutions
|
| 78 |
+
self._registry = load_models_registry() if resolutions else {}
|
| 79 |
+
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
|
| 80 |
+
models: list[str] = []
|
| 81 |
+
for res in _sorted_specs(resolutions):
|
| 82 |
+
for model in _sorted_specs(res.models):
|
| 83 |
+
if model.ref not in models:
|
| 84 |
+
models.append(model.ref)
|
| 85 |
+
self._models = models
|
| 86 |
+
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
|
| 87 |
+
self._iterations = self._total_iterations()
|
| 88 |
+
self._elastix_bin = self._ensure_binary()
|
| 89 |
+
self._local_models = self._download_models()
|
| 90 |
+
|
| 91 |
+
def _total_iterations(self) -> int:
|
| 92 |
+
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
|
| 93 |
+
if self._resolutions:
|
| 94 |
+
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
|
| 95 |
+
total = 0
|
| 96 |
+
for src in self._parameter_maps:
|
| 97 |
+
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
|
| 98 |
+
if match:
|
| 99 |
+
total += sum(int(token) for token in match.group(1).split())
|
| 100 |
+
return total
|
| 101 |
+
|
| 102 |
+
def _ensure_binary(self) -> Path:
|
| 103 |
+
# Optional override: point at an existing elastix-IMPACT install (skips the download).
|
| 104 |
+
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
|
| 105 |
+
if override:
|
| 106 |
+
try_elastix(Path(override))
|
| 107 |
+
return get_elastix_bin(Path(override)).resolve()
|
| 108 |
+
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
try:
|
| 110 |
+
try_elastix(ELASTIX_CACHE)
|
| 111 |
+
except Exception:
|
| 112 |
+
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
|
| 113 |
+
try_elastix(ELASTIX_CACHE)
|
| 114 |
+
return get_elastix_bin(ELASTIX_CACHE).resolve()
|
| 115 |
+
|
| 116 |
+
def _download_models(self) -> list[tuple[str, Path]]:
|
| 117 |
+
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
|
| 118 |
+
models = []
|
| 119 |
+
for ref in self._models:
|
| 120 |
+
repo, filename = ref.split(":", 1)
|
| 121 |
+
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
|
| 122 |
+
models.append((filename, local))
|
| 123 |
+
return models
|
| 124 |
+
|
| 125 |
+
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
| 126 |
+
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
|
| 127 |
+
|
| 128 |
+
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
|
| 129 |
+
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
|
| 130 |
+
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
|
| 131 |
+
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
|
| 132 |
+
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
|
| 133 |
+
"""
|
| 134 |
+
per_token: dict[str, str] = {}
|
| 135 |
+
if not global_only and self._max_iterations > 0:
|
| 136 |
+
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
|
| 137 |
+
if self._final_grid_spacing > 0:
|
| 138 |
+
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
|
| 139 |
+
if not global_only and self._subset_features > 0:
|
| 140 |
+
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
|
| 141 |
+
if self._spatial_samples > 0:
|
| 142 |
+
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
|
| 143 |
+
exact: list[tuple[str, str]] = []
|
| 144 |
+
for entry in self._parameter_overrides:
|
| 145 |
+
key, sep, value = entry.partition("=")
|
| 146 |
+
if not sep or not key.strip():
|
| 147 |
+
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
|
| 148 |
+
exact.append((key.strip(), value.strip()))
|
| 149 |
+
return per_token, exact
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _apply_map_overrides(
|
| 153 |
+
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
|
| 154 |
+
) -> str:
|
| 155 |
+
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
|
| 156 |
+
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
|
| 157 |
+
"""
|
| 158 |
+
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
|
| 159 |
+
requested = set(per_token) | {key for key, _ in exact}
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
lines = []
|
| 162 |
+
for line in text.splitlines():
|
| 163 |
+
match = entry_pattern.match(line)
|
| 164 |
+
if match:
|
| 165 |
+
indent, key, values = match.group(1), match.group(2), match.group(3)
|
| 166 |
+
if key == "ImpactGPU":
|
| 167 |
+
line = f"{indent}(ImpactGPU {device_index})"
|
| 168 |
+
else:
|
| 169 |
+
exact_value = next((value for k, value in exact if k == key), None)
|
| 170 |
+
if exact_value is not None:
|
| 171 |
+
seen.add(key)
|
| 172 |
+
line = f"{indent}({key} {exact_value})"
|
| 173 |
+
else:
|
| 174 |
+
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
|
| 175 |
+
if token_key in per_token:
|
| 176 |
+
seen.add(token_key)
|
| 177 |
+
replaced = " ".join(per_token[token_key] for _ in values.split())
|
| 178 |
+
line = f"{indent}({key} {replaced})"
|
| 179 |
+
lines.append(line)
|
| 180 |
+
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
|
| 181 |
+
# surface it (e.g. final_grid_spacing on a rigid-only preset).
|
| 182 |
+
for key in sorted(requested - seen):
|
| 183 |
+
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
|
| 184 |
+
return "\n".join(lines)
|
| 185 |
+
|
| 186 |
+
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
|
| 187 |
+
"""Stage the parameter maps into ``work``.
|
| 188 |
+
|
| 189 |
+
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
|
| 190 |
+
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
|
| 191 |
+
applies every per-token / exact override. Both set the ImpactGPU device.
|
| 192 |
+
"""
|
| 193 |
+
staged = []
|
| 194 |
+
for src in self._parameter_maps:
|
| 195 |
+
if self._resolutions:
|
| 196 |
+
text = generate_impact_parameter_map(
|
| 197 |
+
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
|
| 198 |
+
)
|
| 199 |
+
per_token, exact = self._parameter_map_overrides(global_only=True)
|
| 200 |
+
else:
|
| 201 |
+
text = src.read_text(encoding="utf-8")
|
| 202 |
+
per_token, exact = self._parameter_map_overrides()
|
| 203 |
+
text = self._apply_map_overrides(text, per_token, exact, device_index)
|
| 204 |
+
dst = work / src.name
|
| 205 |
+
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
| 206 |
+
staged.append(dst)
|
| 207 |
+
return staged
|
| 208 |
+
|
| 209 |
+
def register(
|
| 210 |
+
self,
|
| 211 |
+
fixed: sitk.Image,
|
| 212 |
+
moving: sitk.Image,
|
| 213 |
+
device_index: int,
|
| 214 |
+
fixed_mask: sitk.Image | None = None,
|
| 215 |
+
moving_mask: sitk.Image | None = None,
|
| 216 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 217 |
+
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
|
| 218 |
+
|
| 219 |
+
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
|
| 220 |
+
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
|
| 221 |
+
"""
|
| 222 |
+
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
|
| 223 |
+
try:
|
| 224 |
+
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
|
| 225 |
+
sitk.WriteImage(fixed, str(fixed_path))
|
| 226 |
+
sitk.WriteImage(moving, str(moving_path))
|
| 227 |
+
|
| 228 |
+
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
|
| 229 |
+
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
|
| 230 |
+
for rel_name, model_path in self._local_models:
|
| 231 |
+
dst = work / rel_name
|
| 232 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
if not dst.exists():
|
| 234 |
+
dst.symlink_to(model_path)
|
| 235 |
+
|
| 236 |
+
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
|
| 237 |
+
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
|
| 238 |
+
if mask is not None:
|
| 239 |
+
mask_path = work / name
|
| 240 |
+
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
|
| 241 |
+
args += [flag, str(mask_path)]
|
| 242 |
+
args += ["-out", str(work)]
|
| 243 |
+
for pmap in self._stage_parameter_maps(work, device_index):
|
| 244 |
+
args += ["-p", str(pmap)]
|
| 245 |
+
|
| 246 |
+
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
|
| 247 |
+
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
|
| 248 |
+
env = os.environ.copy()
|
| 249 |
+
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
|
| 250 |
+
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
|
| 251 |
+
proc = subprocess.Popen( # nosec B603
|
| 252 |
+
args,
|
| 253 |
+
cwd=str(work),
|
| 254 |
+
stdout=subprocess.PIPE,
|
| 255 |
+
stderr=subprocess.STDOUT,
|
| 256 |
+
text=True,
|
| 257 |
+
bufsize=1,
|
| 258 |
+
env=env,
|
| 259 |
+
)
|
| 260 |
+
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
|
| 261 |
+
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
|
| 262 |
+
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
|
| 263 |
+
captured: list[str] = []
|
| 264 |
+
iteration_line = re.compile(r"^\d+\s")
|
| 265 |
+
budget = None if self._max_iterations > 0 else (self._iterations or None)
|
| 266 |
+
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
|
| 267 |
+
assert proc.stdout is not None
|
| 268 |
+
resolution = 0
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
captured.append(line)
|
| 271 |
+
stripped = line.strip()
|
| 272 |
+
if stripped.startswith("Resolution:"):
|
| 273 |
+
try:
|
| 274 |
+
resolution = int(stripped.split(":", 1)[1])
|
| 275 |
+
except ValueError:
|
| 276 |
+
pass
|
| 277 |
+
elif iteration_line.match(line):
|
| 278 |
+
progress.update(1)
|
| 279 |
+
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
|
| 280 |
+
if len(columns) > 1:
|
| 281 |
+
try:
|
| 282 |
+
progress.set_description(
|
| 283 |
+
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
|
| 284 |
+
)
|
| 285 |
+
except ValueError:
|
| 286 |
+
pass
|
| 287 |
+
progress.close()
|
| 288 |
+
returncode = proc.wait()
|
| 289 |
+
if returncode != 0:
|
| 290 |
+
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
|
| 291 |
+
|
| 292 |
+
transforms = sorted(
|
| 293 |
+
work.glob("TransformParameters.*-Composite.itk.txt"),
|
| 294 |
+
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
|
| 295 |
+
)
|
| 296 |
+
if not transforms:
|
| 297 |
+
raise FileNotFoundError("elastix produced no composite transform file.")
|
| 298 |
+
transform = sitk.ReadTransform(str(transforms[-1]))
|
| 299 |
+
|
| 300 |
+
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
|
| 301 |
+
dvf = sitk.TransformToDisplacementField(
|
| 302 |
+
transform,
|
| 303 |
+
sitk.sitkVectorFloat64,
|
| 304 |
+
fixed.GetSize(),
|
| 305 |
+
fixed.GetOrigin(),
|
| 306 |
+
fixed.GetSpacing(),
|
| 307 |
+
fixed.GetDirection(),
|
| 308 |
+
)
|
| 309 |
+
moved_np, _ = image_to_data(moved)
|
| 310 |
+
dvf_np, _ = image_to_data(dvf)
|
| 311 |
+
return moved_np, dvf_np
|
| 312 |
+
finally:
|
| 313 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ElastixRegistration(torch.nn.Module):
|
| 317 |
+
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
|
| 318 |
+
|
| 319 |
+
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
|
| 320 |
+
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
|
| 321 |
+
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
accepts_attributes = True
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
engine: str,
|
| 329 |
+
parameter_maps: list[str],
|
| 330 |
+
max_iterations: int = 0,
|
| 331 |
+
final_grid_spacing: float = 0.0,
|
| 332 |
+
subset_features: int = 0,
|
| 333 |
+
spatial_samples: int = 0,
|
| 334 |
+
parameter_overrides: list[str] = [],
|
| 335 |
+
resolutions: dict = {},
|
| 336 |
+
mode: str = "Static",
|
| 337 |
+
) -> None:
|
| 338 |
+
super().__init__()
|
| 339 |
+
if engine != "elastix":
|
| 340 |
+
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
|
| 341 |
+
self._engine = ElastixEngine(
|
| 342 |
+
parameter_maps,
|
| 343 |
+
max_iterations,
|
| 344 |
+
final_grid_spacing,
|
| 345 |
+
subset_features,
|
| 346 |
+
spatial_samples,
|
| 347 |
+
parameter_overrides,
|
| 348 |
+
resolutions,
|
| 349 |
+
mode,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def forward(
|
| 353 |
+
self,
|
| 354 |
+
fixed: torch.Tensor,
|
| 355 |
+
moving: torch.Tensor,
|
| 356 |
+
fixed_mask: torch.Tensor,
|
| 357 |
+
moving_mask: torch.Tensor,
|
| 358 |
+
attributes: list[list[Attribute]],
|
| 359 |
+
) -> torch.Tensor:
|
| 360 |
+
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
|
| 361 |
+
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
|
| 362 |
+
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
|
| 363 |
+
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
|
| 364 |
+
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
|
| 365 |
+
combined = []
|
| 366 |
+
for b in range(fixed.shape[0]):
|
| 367 |
+
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
|
| 368 |
+
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
|
| 369 |
+
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
|
| 370 |
+
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
|
| 371 |
+
moved_np, dvf_np = self._engine.register(
|
| 372 |
+
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
|
| 373 |
+
)
|
| 374 |
+
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
|
| 375 |
+
return torch.stack(combined, dim=0).to(fixed.device)
|