Valentin Boussot commited on
Commit
269b189
·
1 Parent(s): 6a3ecbf

refactor: split elastix engine and type model parameters across presets

Browse files

Split 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 CHANGED
@@ -14,115 +14,89 @@
14
  #
15
  # SPDX-License-Identifier: Apache-2.0
16
 
17
- """Registration as a KonfAI model, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0.0
11
  subset_features: 0
12
- spatial_samples: 0
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": 3,
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, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0.0
11
  subset_features: 0
12
- spatial_samples: 0
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": 3,
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, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0.0
11
  subset_features: 0
12
- spatial_samples: 0
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": 3,
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": 3,
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": 3,
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": 3,
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, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0
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": 3,
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, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0.0
12
- spatial_samples: 0
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": 3,
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, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0.0
11
  subset_features: 0
12
- spatial_samples: 0
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": 3,
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, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0.0
11
  subset_features: 0
12
- spatial_samples: 0
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": 3,
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, built the idiomatic way (an ``add_module`` graph).
18
 
19
- ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
- (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
- onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
- (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
- ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
- needs to register in physical space.
25
 
26
- Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
 
27
 
28
- Patch.patch_size: None # one patch per case (registration is global)
29
- batch_size: 1 # one fixed/moving pair at a time
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 shutil
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.dataset import Attribute, data_to_image, image_to_data
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
- # The forced per-model props (dimension/channels/FOV formula) live in a registry (models.json on
60
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (which models per resolution,
61
- # feature voxel size, iterations, per-model layer weights/mask/subset/pca/distance) and the global
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`` grows with depth but the segmenters' receptive field plateaus: layers 7-8 share layer 6's
69
- # FOV (the "ramp max"). A config that deep should really run in Static (whole image) anyway; in Jacobian
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: integers without a trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
76
  return "%g" % float(x)
77
 
78
 
 
79
  class ModelSpec:
80
- """One feature model at one resolution, with its OWN config (several models may share a resolution).
81
-
82
- ``ref`` selects the model; ``voxel_size`` / ``layers_weight`` / ``subset_features`` / ``pca`` /
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
- def __init__(
89
- self,
90
- ref: str,
91
- voxel_size: list[float] = [],
92
- layers_weight: list[float] = [1.0],
93
- subset_features: int = 0,
94
- pca: int = 0,
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 models compared there (each self-configured)."""
109
 
110
- def __init__(self, max_iterations: int, models: dict[str, ModelSpec]) -> None:
111
- self.max_iterations = max_iterations
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 (well-defined res/model 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 (forced params per model) from the model repo on Hugging Face.
122
 
123
- The registry is NOT bundled with the preset it lives on the models repo and is fetched from there.
124
- Resolution: the ``KONFAI_IMPACT_MODELS_REGISTRY`` env path wins (dev/offline); otherwise ``ref`` must be
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 models repo (strip a 'repo:' prefix)."""
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 read left-to-right.
148
 
149
- A model returns its feature layers shallow->deep (``[layer_0, layer_1, ...]``, see the model repo's
150
- build scripts); ``layers_mask`` has one char per returned layer, position ``i`` == ``layer_i``, ``'1'``
151
- = selected. In Jacobian the patch must cover the receptive field of the DEEPEST selected layer, so the
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
- Supported formulas (from the model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
165
- ``2*r*d+1`` MIND, from the handcrafted radius ``r`` / dilation ``d`` (e.g. R1D2 -> 5);
166
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = the deepest layer picked by ``layers_mask``,
167
- clamped to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
168
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
169
- ``Global`` Anatomix — whole-image only (Static); has no finite Jacobian patch -> error.
170
- An explicit ``value`` in the spec is honoured as a precomputed shortcut when the formula needs none.
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 model -> 2 tokens, 3D -> 3): Static ->
189
- whole image (all zeros); Jacobian -> the evaluated FOV repeated over the axes. A 2D model mixed with a
190
- 3D one at a resolution concatenates as e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
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 (from the config ``mode``), and the whole ImpactXxxK block; every other template line is
205
- kept verbatim (optimizer, transform, metric weights, components...). N (number of resolutions) is
206
- deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0`` (whole image); Jacobian -> the
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 (models.json on the model repo) ONLY the 3 truly model-fixed props:
222
- # Dimension, NumberOfChannels, PatchSize (the model FOV). Everything else is a per-model tuning knob
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
- # (the blank lines the reference maps put BETWEEN resolutions fall inside that span). Replace the whole
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
- fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
624
 
625
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
626
- ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
627
- both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
628
- ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
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
- models_registry: str = _IMPACT_MODELS_REGISTRY,
647
- mode: str = "Static",
648
  ) -> None:
649
- # The registration is fully described by the per-resolution model matrix ``resolutions`` (config =
650
- # source of truth): each resolution lists its models, each model self-configured (ref, voxel_size,
651
- # layers_mask, layers_weight, subset_features, pca, distance); intrinsic per-model props come from
652
- # ``models_registry``. The feature-model download list is DERIVED from the matrix (no flat ``models``).
653
- # Global knobs override the generated map: final_grid_spacing -> FinalGridSpacingInPhysicalUnits (mm),
654
- # spatial_samples -> NumberOfSpatialSamples, parameter_overrides ('Key=value') -> any other entry.
655
- # An empty ``resolutions`` = an intensity-only preset (no IMPACT models): the fixed maps are staged
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: 0.0
11
  subset_features: 0
12
- spatial_samples: 0
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": 3,
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)