File size: 19,041 Bytes
269b189 6afc1ec 269b189 6afc1ec 269b189 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 | # Copyright (c) 2025 Valentin Boussot
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
"""Elastix-IMPACT runtime for the registration bundle.
``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
"""
import os
import re
import shutil
import subprocess # nosec B404
import tempfile
from pathlib import Path
import numpy as np
import SimpleITK as sitk
import torch
import tqdm
from huggingface_hub import hf_hub_download
from install import get_elastix_bin, install_elastix_impact, try_elastix
from konfai.utils.dataset import Attribute, data_to_image, image_to_data
from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
# Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
# Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
def _is_partial_mask(mask: "sitk.Image | None") -> bool:
"""True only for a mask that actually restricts the metric region — some voxels in, some out. An
absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
the whole image) instead of paying for a mask that restricts nothing."""
if mask is None:
return False
arr = sitk.GetArrayViewFromImage(mask)
return bool((arr > 0).any()) and bool((arr == 0).any())
class ElastixEngine:
"""Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
"""
def __init__(
self,
parameter_maps: list[str],
max_iterations: int = 0,
final_grid_spacing: float = 0.0,
subset_features: int = 0,
spatial_samples: int = 0,
parameter_overrides: list[str] = [],
resolutions: dict = {},
mode: str = "Static",
) -> None:
self._bundle_dir = Path(__file__).resolve().parent
self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
self._max_iterations = max_iterations
self._final_grid_spacing = final_grid_spacing
self._subset_features = subset_features
self._spatial_samples = spatial_samples
self._parameter_overrides = list(parameter_overrides)
# ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
# samples random FOV-sized patches each iteration. One mode per preset.
self._mode = mode
# Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
# intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
self._resolutions = resolutions
self._registry = load_models_registry() if resolutions else {}
# Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
models: list[str] = []
for res in _sorted_specs(resolutions):
for model in _sorted_specs(res.models):
if model.ref not in models:
models.append(model.ref)
self._models = models
# ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
self._iterations = self._total_iterations()
self._elastix_bin = self._ensure_binary()
self._local_models = self._download_models()
def _total_iterations(self) -> int:
"""Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
if self._resolutions:
return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
total = 0
for src in self._parameter_maps:
match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
if match:
total += sum(int(token) for token in match.group(1).split())
return total
def _ensure_binary(self) -> Path:
# Optional override: point at an existing elastix-IMPACT install (skips the download).
override = os.environ.get("KONFAI_ELASTIX_DIR", "")
if override:
try_elastix(Path(override))
return get_elastix_bin(Path(override)).resolve()
ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
try:
try_elastix(ELASTIX_CACHE)
except Exception:
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
try_elastix(ELASTIX_CACHE)
return get_elastix_bin(ELASTIX_CACHE).resolve()
def _download_models(self) -> list[tuple[str, Path]]:
"""Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
models = []
for ref in self._models:
repo, filename = ref.split(":", 1)
local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
models.append((filename, local))
return models
def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
"""The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
**each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
"""
per_token: dict[str, str] = {}
if not global_only and self._max_iterations > 0:
per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
if self._final_grid_spacing > 0:
per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
if not global_only and self._subset_features > 0:
per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
if self._spatial_samples > 0:
per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
exact: list[tuple[str, str]] = []
for entry in self._parameter_overrides:
key, sep, value = entry.partition("=")
if not sep or not key.strip():
raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
exact.append((key.strip(), value.strip()))
return per_token, exact
@staticmethod
def _apply_map_overrides(
text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
) -> str:
"""Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
"""
entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
requested = set(per_token) | {key for key, _ in exact}
seen: set[str] = set()
lines = []
for line in text.splitlines():
match = entry_pattern.match(line)
if match:
indent, key, values = match.group(1), match.group(2), match.group(3)
if key == "ImpactGPU":
line = f"{indent}(ImpactGPU {device_index})"
else:
exact_value = next((value for k, value in exact if k == key), None)
if exact_value is not None:
seen.add(key)
line = f"{indent}({key} {exact_value})"
else:
token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
if token_key in per_token:
seen.add(token_key)
replaced = " ".join(per_token[token_key] for _ in values.split())
line = f"{indent}({key} {replaced})"
lines.append(line)
# Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
# surface it (e.g. final_grid_spacing on a rigid-only preset).
for key in sorted(requested - seen):
print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
return "\n".join(lines)
def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
"""Stage the parameter maps into ``work``.
Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
applies every per-token / exact override. Both set the ImpactGPU device.
"""
staged = []
for src in self._parameter_maps:
if self._resolutions:
text = generate_impact_parameter_map(
src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
)
per_token, exact = self._parameter_map_overrides(global_only=True)
else:
text = src.read_text(encoding="utf-8")
per_token, exact = self._parameter_map_overrides()
text = self._apply_map_overrides(text, per_token, exact, device_index)
dst = work / src.name
dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
staged.append(dst)
return staged
def register(
self,
fixed: sitk.Image,
moving: sitk.Image,
device_index: int,
fixed_mask: sitk.Image | None = None,
moving_mask: sitk.Image | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
"""
work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
try:
fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
sitk.WriteImage(fixed, str(fixed_path))
sitk.WriteImage(moving, str(moving_path))
# Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
# "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
for rel_name, model_path in self._local_models:
dst = work / rel_name
dst.parent.mkdir(parents=True, exist_ok=True)
if not dst.exists():
dst.symlink_to(model_path)
args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
if _is_partial_mask(mask):
mask_path = work / name
sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
args += [flag, str(mask_path)]
args += ["-out", str(work)]
for pmap in self._stage_parameter_maps(work, device_index):
args += ["-p", str(pmap)]
# Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
# libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
env = os.environ.copy()
extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
proc = subprocess.Popen( # nosec B603
args,
cwd=str(work),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=env,
)
# Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
# progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
# open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
captured: list[str] = []
iteration_line = re.compile(r"^\d+\s")
budget = None if self._max_iterations > 0 else (self._iterations or None)
progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
assert proc.stdout is not None
resolution = 0
for line in proc.stdout:
captured.append(line)
stripped = line.strip()
if stripped.startswith("Resolution:"):
try:
resolution = int(stripped.split(":", 1)[1])
except ValueError:
pass
elif iteration_line.match(line):
progress.update(1)
columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
if len(columns) > 1:
try:
progress.set_description(
f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
)
except ValueError:
pass
progress.close()
returncode = proc.wait()
if returncode != 0:
raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
transforms = sorted(
work.glob("TransformParameters.*-Composite.itk.txt"),
key=lambda p: int(p.name.split(".")[1].split("-")[0]),
)
if not transforms:
raise FileNotFoundError("elastix produced no composite transform file.")
transform = sitk.ReadTransform(str(transforms[-1]))
moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
dvf = sitk.TransformToDisplacementField(
transform,
sitk.sitkVectorFloat64,
fixed.GetSize(),
fixed.GetOrigin(),
fixed.GetSpacing(),
fixed.GetDirection(),
)
moved_np, _ = image_to_data(moved)
dvf_np, _ = image_to_data(dvf)
return moved_np, dvf_np
finally:
shutil.rmtree(work, ignore_errors=True)
class ElastixRegistration(torch.nn.Module):
"""Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
"""
accepts_attributes = True
def __init__(
self,
engine: str,
parameter_maps: list[str],
max_iterations: int = 0,
final_grid_spacing: float = 0.0,
subset_features: int = 0,
spatial_samples: int = 0,
parameter_overrides: list[str] = [],
resolutions: dict = {},
mode: str = "Static",
) -> None:
super().__init__()
if engine != "elastix":
raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
self._engine = ElastixEngine(
parameter_maps,
max_iterations,
final_grid_spacing,
subset_features,
spatial_samples,
parameter_overrides,
resolutions,
mode,
)
def forward(
self,
fixed: torch.Tensor,
moving: torch.Tensor,
fixed_mask: torch.Tensor,
moving_mask: torch.Tensor,
attributes: list[list[Attribute]],
) -> torch.Tensor:
# attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
# batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
# the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
device_index = fixed.device.index if fixed.device.type == "cuda" else -1
combined = []
for b in range(fixed.shape[0]):
fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
moved_np, dvf_np = self._engine.register(
fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
)
combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
return torch.stack(combined, dim=0).to(fixed.device)
|