VBoussot commited on
Commit
442bdf8
Β·
verified Β·
1 Parent(s): fd85e78

Update reg presets: mode knob + FOV evaluator + dimension-aware patch; models.json fetched from HF (unbundled)

Browse files
CBCT_CT_HeadNeck/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
CBCT_CT_HeadNeck/ParameterMap_CBCT_HN.txt CHANGED
@@ -1,4 +1,4 @@
1
- (MaximumNumberOfIterations 300 300 200 200 150)
2
  (NumberOfSpatialSamples 2000)
3
  (Transform "RecursiveBSplineTransform")
4
  (NumberOfResolutions 5)
@@ -25,7 +25,7 @@
25
  (ImpactModelsPath1 "TS/M731.pt")
26
  (ImpactDimension1 3)
27
  (ImpactNumberOfChannels1 1)
28
- (ImpactPatchSize1 0 0 0)
29
  (ImpactVoxelSize1 3 3 3)
30
  (ImpactLayersMask1 "01")
31
  (ImpactSubsetFeatures1 64)
@@ -149,4 +149,4 @@
149
  (ResultImageFormat "mha")
150
 
151
  (ITKTransformOutputFileNameExtension "itk.txt")
152
- (WriteITKCompositeTransform "true")
 
1
+ (MaximumNumberOfIterations 300 300 200 200 150)
2
  (NumberOfSpatialSamples 2000)
3
  (Transform "RecursiveBSplineTransform")
4
  (NumberOfResolutions 5)
 
25
  (ImpactModelsPath1 "TS/M731.pt")
26
  (ImpactDimension1 3)
27
  (ImpactNumberOfChannels1 1)
28
+ (ImpactPatchSize1 0 0 0)
29
  (ImpactVoxelSize1 3 3 3)
30
  (ImpactLayersMask1 "01")
31
  (ImpactSubsetFeatures1 64)
 
149
  (ResultImageFormat "mha")
150
 
151
  (ITKTransformOutputFileNameExtension "itk.txt")
152
+ (WriteITKCompositeTransform "true")
CBCT_CT_HeadNeck/Prediction.yml CHANGED
@@ -5,12 +5,90 @@ Predictor:
5
  engine: elastix
6
  parameter_maps:
7
  - ParameterMap_CBCT_HN.txt
8
- models:
9
- - VBoussot/impact-torchscript-models:TS/M732.pt
10
- - VBoussot/impact-torchscript-models:TS/M731.pt
11
- - VBoussot/impact-torchscript-models:TS/M730.pt
12
- iterations: 1150
13
  outputs_criterions: None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  Dataset:
15
  groups_src:
16
  Volume_0:
 
5
  engine: elastix
6
  parameter_maps:
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':
16
+ max_iterations: 300
17
+ models:
18
+ '0':
19
+ ref: VBoussot/impact-torchscript-models:TS/M732.pt
20
+ voxel_size:
21
+ - 6.0
22
+ - 6.0
23
+ - 6.0
24
+ layers_mask: '01'
25
+ layers_weight:
26
+ - 1.0
27
+ subset_features: 64
28
+ pca: 0
29
+ distance: L1
30
+ '1':
31
+ max_iterations: 300
32
+ models:
33
+ '0':
34
+ ref: VBoussot/impact-torchscript-models:TS/M731.pt
35
+ voxel_size:
36
+ - 3.0
37
+ - 3.0
38
+ - 3.0
39
+ layers_mask: '01'
40
+ layers_weight:
41
+ - 1.0
42
+ subset_features: 64
43
+ pca: 0
44
+ distance: L1
45
+ '2':
46
+ max_iterations: 200
47
+ models:
48
+ '0':
49
+ ref: VBoussot/impact-torchscript-models:TS/M731.pt
50
+ voxel_size:
51
+ - 3.0
52
+ - 3.0
53
+ - 3.0
54
+ layers_mask: '01'
55
+ layers_weight:
56
+ - 1.0
57
+ subset_features: 64
58
+ pca: 0
59
+ distance: L1
60
+ '3':
61
+ max_iterations: 200
62
+ models:
63
+ '0':
64
+ ref: VBoussot/impact-torchscript-models:TS/M730.pt
65
+ voxel_size:
66
+ - 2.0
67
+ - 2.0
68
+ - 3.0
69
+ layers_mask: '01'
70
+ layers_weight:
71
+ - 1.0
72
+ subset_features: 64
73
+ pca: 0
74
+ distance: L1
75
+ '4':
76
+ max_iterations: 150
77
+ models:
78
+ '0':
79
+ ref: VBoussot/impact-torchscript-models:TS/M730.pt
80
+ voxel_size:
81
+ - 2.0
82
+ - 2.0
83
+ - 3.0
84
+ layers_mask: '01'
85
+ layers_weight:
86
+ - 1.0
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:
94
  Volume_0:
CBCT_CT_MRSeg/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
CBCT_CT_MRSeg/ParameterMap_CBCT_generic_MRSeg.txt CHANGED
@@ -23,7 +23,7 @@
23
  (ImpactModelsPath1 "MIND/R1D2_3D.pt" "MRSeg/MRSeg.pt")
24
  (ImpactDimension1 3 3)
25
  (ImpactNumberOfChannels1 1 1)
26
- (ImpactPatchSize1 0 0 0 0 0 0)
27
  (ImpactVoxelSize1 3 3 3 3 3 3)
28
  (ImpactLayersMask1 "1" "1")
29
  (ImpactSubsetFeatures1 32 64)
@@ -36,7 +36,7 @@
36
  (ImpactNumberOfChannels2 1 1)
37
  (ImpactPatchSize2 0 0 0 0 0 0)
38
  (ImpactVoxelSize2 2 2 2 2 2 2)
39
- (ImpactLayersMask2 "1" "1")
40
  (ImpactSubsetFeatures2 32 64)
41
  (ImpactPCA2 0 0)
42
  (ImpactDistance2 "L1" "Dice")
@@ -47,7 +47,7 @@
47
  (ImpactNumberOfChannels3 1 1)
48
  (ImpactPatchSize3 0 0 0 0 0 0)
49
  (ImpactVoxelSize3 2 2 2 2 2 2)
50
- (ImpactLayersMask3 "1" "1")
51
  (ImpactSubsetFeatures3 32 64)
52
  (ImpactPCA3 0 0)
53
  (ImpactDistance3 "L1" "Dice")
@@ -134,4 +134,4 @@
134
  (ResultImageFormat "mha")
135
 
136
  (ITKTransformOutputFileNameExtension "itk.txt")
137
- (WriteITKCompositeTransform "true")
 
23
  (ImpactModelsPath1 "MIND/R1D2_3D.pt" "MRSeg/MRSeg.pt")
24
  (ImpactDimension1 3 3)
25
  (ImpactNumberOfChannels1 1 1)
26
+ (ImpactPatchSize1 0 0 0 0 0 0)
27
  (ImpactVoxelSize1 3 3 3 3 3 3)
28
  (ImpactLayersMask1 "1" "1")
29
  (ImpactSubsetFeatures1 32 64)
 
36
  (ImpactNumberOfChannels2 1 1)
37
  (ImpactPatchSize2 0 0 0 0 0 0)
38
  (ImpactVoxelSize2 2 2 2 2 2 2)
39
+ (ImpactLayersMask2 "1" "1")
40
  (ImpactSubsetFeatures2 32 64)
41
  (ImpactPCA2 0 0)
42
  (ImpactDistance2 "L1" "Dice")
 
47
  (ImpactNumberOfChannels3 1 1)
48
  (ImpactPatchSize3 0 0 0 0 0 0)
49
  (ImpactVoxelSize3 2 2 2 2 2 2)
50
+ (ImpactLayersMask3 "1" "1")
51
  (ImpactSubsetFeatures3 32 64)
52
  (ImpactPCA3 0 0)
53
  (ImpactDistance3 "L1" "Dice")
 
134
  (ResultImageFormat "mha")
135
 
136
  (ITKTransformOutputFileNameExtension "itk.txt")
137
+ (WriteITKCompositeTransform "true")
CBCT_CT_MRSeg/Prediction.yml CHANGED
@@ -5,11 +5,123 @@ Predictor:
5
  engine: elastix
6
  parameter_maps:
7
  - ParameterMap_CBCT_generic_MRSeg.txt
8
- models:
9
- - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
10
- - VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
11
- iterations: 1100
12
  outputs_criterions: None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  Dataset:
14
  groups_src:
15
  Volume_0:
 
5
  engine: elastix
6
  parameter_maps:
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':
16
+ max_iterations: 400
17
+ models:
18
+ '0':
19
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
20
+ voxel_size:
21
+ - 6.0
22
+ - 6.0
23
+ - 6.0
24
+ layers_mask: '1'
25
+ layers_weight:
26
+ - 0.2
27
+ subset_features: 32
28
+ pca: 0
29
+ distance: L1
30
+ '1':
31
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
32
+ voxel_size:
33
+ - 6.0
34
+ - 6.0
35
+ - 6.0
36
+ layers_mask: '1'
37
+ layers_weight:
38
+ - 0.8
39
+ subset_features: 64
40
+ pca: 0
41
+ distance: Dice
42
+ '1':
43
+ max_iterations: 300
44
+ models:
45
+ '0':
46
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
47
+ voxel_size:
48
+ - 3.0
49
+ - 3.0
50
+ - 3.0
51
+ layers_mask: '1'
52
+ layers_weight:
53
+ - 0.3
54
+ subset_features: 32
55
+ pca: 0
56
+ distance: L1
57
+ '1':
58
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
59
+ voxel_size:
60
+ - 3.0
61
+ - 3.0
62
+ - 3.0
63
+ layers_mask: '1'
64
+ layers_weight:
65
+ - 0.7
66
+ subset_features: 64
67
+ pca: 0
68
+ distance: Dice
69
+ '2':
70
+ max_iterations: 200
71
+ models:
72
+ '0':
73
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
74
+ voxel_size:
75
+ - 2.0
76
+ - 2.0
77
+ - 2.0
78
+ layers_mask: '1'
79
+ layers_weight:
80
+ - 0.6
81
+ subset_features: 32
82
+ pca: 0
83
+ distance: L1
84
+ '1':
85
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
86
+ voxel_size:
87
+ - 2.0
88
+ - 2.0
89
+ - 2.0
90
+ layers_mask: '1'
91
+ layers_weight:
92
+ - 0.4
93
+ subset_features: 64
94
+ pca: 0
95
+ distance: Dice
96
+ '3':
97
+ max_iterations: 200
98
+ models:
99
+ '0':
100
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
101
+ voxel_size:
102
+ - 2.0
103
+ - 2.0
104
+ - 2.0
105
+ layers_mask: '1'
106
+ layers_weight:
107
+ - 0.7
108
+ subset_features: 32
109
+ pca: 0
110
+ distance: L1
111
+ '1':
112
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
113
+ voxel_size:
114
+ - 2.0
115
+ - 2.0
116
+ - 2.0
117
+ layers_mask: '1'
118
+ layers_weight:
119
+ - 0.3
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:
127
  Volume_0:
CBCT_CT_TS/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
CBCT_CT_TS/Prediction.yml CHANGED
@@ -5,11 +5,12 @@ Predictor:
5
  engine: elastix
6
  parameter_maps:
7
  - ParameterMap_CBCT_generic_TS.txt
8
- models:
9
- - VBoussot/impact-torchscript-models:TS/M852.pt
10
- - VBoussot/impact-torchscript-models:TS/M850.pt
11
- iterations: 1050
12
  outputs_criterions: None
 
 
 
 
 
13
  Dataset:
14
  groups_src:
15
  Volume_0:
 
5
  engine: elastix
6
  parameter_maps:
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:
16
  Volume_0:
ConvexAdam_Coarse/Model.py CHANGED
@@ -43,6 +43,9 @@ from konfai.network import network
43
  from konfai.utils.dataset import Attribute, data_to_image, image_to_data
44
 
45
  DIM = 3
 
 
 
46
  _IMAGE_F = itk.Image[itk.F, DIM]
47
 
48
 
@@ -106,7 +109,6 @@ class ConvexAdamEngine:
106
  self,
107
  models: list[str],
108
  voxel_size: list[float],
109
- num_channels: int,
110
  overlap: int,
111
  layers_mask: list[bool],
112
  mixed_precision: bool,
@@ -131,7 +133,6 @@ class ConvexAdamEngine:
131
  # from disk in C++, so build the list once and reuse it across both stages and every case.
132
  self._configurations: "list[itk.ModelConfiguration] | None" = None
133
  self._voxel_size = voxel_size
134
- self._num_channels = num_channels
135
  self._overlap = overlap
136
  self._layers_mask = layers_mask
137
  self._mixed_precision = mixed_precision
@@ -171,7 +172,7 @@ class ConvexAdamEngine:
171
  itk.ModelConfiguration(
172
  path,
173
  DIM,
174
- self._num_channels,
175
  [0, 0, 0],
176
  [float(v) for v in self._voxel_size],
177
  self._overlap,
@@ -416,7 +417,6 @@ class RegistrationNet(network.Network):
416
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
417
  models: list[str] = [],
418
  voxel_size: list[float] = [3.0, 3.0, 3.0],
419
- num_channels: int = 1,
420
  overlap: int = 2,
421
  layers_mask: list[bool] = [True],
422
  mixed_precision: bool = False,
@@ -428,7 +428,7 @@ class RegistrationNet(network.Network):
428
  grid_shrink: int = 4,
429
  distance: list[str] = ["L1"],
430
  layers_weight: list[float] = [1.0],
431
- subset_features: list[int] = [32],
432
  pca: list[int] = [0],
433
  stages: list[str] = ["coarse", "fine"],
434
  linear: bool = True,
@@ -445,7 +445,6 @@ class RegistrationNet(network.Network):
445
  engine = ConvexAdamEngine(
446
  models,
447
  voxel_size,
448
- num_channels,
449
  overlap,
450
  layers_mask,
451
  mixed_precision,
 
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
 
 
109
  self,
110
  models: list[str],
111
  voxel_size: list[float],
 
112
  overlap: int,
113
  layers_mask: list[bool],
114
  mixed_precision: bool,
 
133
  # from disk in C++, so build the list once and reuse it across both stages and every case.
134
  self._configurations: "list[itk.ModelConfiguration] | None" = None
135
  self._voxel_size = voxel_size
 
136
  self._overlap = overlap
137
  self._layers_mask = layers_mask
138
  self._mixed_precision = mixed_precision
 
172
  itk.ModelConfiguration(
173
  path,
174
  DIM,
175
+ NUM_CHANNELS,
176
  [0, 0, 0],
177
  [float(v) for v in self._voxel_size],
178
  self._overlap,
 
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,
 
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,
 
445
  engine = ConvexAdamEngine(
446
  models,
447
  voxel_size,
 
448
  overlap,
449
  layers_mask,
450
  mixed_precision,
ConvexAdam_Coarse/Prediction.yml CHANGED
@@ -8,7 +8,6 @@ Predictor:
8
  - 3.0
9
  - 3.0
10
  - 3.0
11
- num_channels: 1
12
  overlap: 2
13
  layers_mask:
14
  - true
@@ -23,8 +22,7 @@ Predictor:
23
  - L1
24
  layers_weight:
25
  - 1.0
26
- subset_features:
27
- - 32
28
  pca:
29
  - 0
30
  linear: true
 
8
  - 3.0
9
  - 3.0
10
  - 3.0
 
11
  overlap: 2
12
  layers_mask:
13
  - true
 
22
  - L1
23
  layers_weight:
24
  - 1.0
25
+ subset_features: []
 
26
  pca:
27
  - 0
28
  linear: true
ConvexAdam_Composite/Model.py CHANGED
@@ -43,6 +43,9 @@ from konfai.network import network
43
  from konfai.utils.dataset import Attribute, data_to_image, image_to_data
44
 
45
  DIM = 3
 
 
 
46
  _IMAGE_F = itk.Image[itk.F, DIM]
47
 
48
 
@@ -106,7 +109,6 @@ class ConvexAdamEngine:
106
  self,
107
  models: list[str],
108
  voxel_size: list[float],
109
- num_channels: int,
110
  overlap: int,
111
  layers_mask: list[bool],
112
  mixed_precision: bool,
@@ -131,7 +133,6 @@ class ConvexAdamEngine:
131
  # from disk in C++, so build the list once and reuse it across both stages and every case.
132
  self._configurations: "list[itk.ModelConfiguration] | None" = None
133
  self._voxel_size = voxel_size
134
- self._num_channels = num_channels
135
  self._overlap = overlap
136
  self._layers_mask = layers_mask
137
  self._mixed_precision = mixed_precision
@@ -171,7 +172,7 @@ class ConvexAdamEngine:
171
  itk.ModelConfiguration(
172
  path,
173
  DIM,
174
- self._num_channels,
175
  [0, 0, 0],
176
  [float(v) for v in self._voxel_size],
177
  self._overlap,
@@ -416,7 +417,6 @@ class RegistrationNet(network.Network):
416
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
417
  models: list[str] = [],
418
  voxel_size: list[float] = [3.0, 3.0, 3.0],
419
- num_channels: int = 1,
420
  overlap: int = 2,
421
  layers_mask: list[bool] = [True],
422
  mixed_precision: bool = False,
@@ -428,7 +428,7 @@ class RegistrationNet(network.Network):
428
  grid_shrink: int = 4,
429
  distance: list[str] = ["L1"],
430
  layers_weight: list[float] = [1.0],
431
- subset_features: list[int] = [32],
432
  pca: list[int] = [0],
433
  stages: list[str] = ["coarse", "fine"],
434
  linear: bool = True,
@@ -445,7 +445,6 @@ class RegistrationNet(network.Network):
445
  engine = ConvexAdamEngine(
446
  models,
447
  voxel_size,
448
- num_channels,
449
  overlap,
450
  layers_mask,
451
  mixed_precision,
 
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
 
 
109
  self,
110
  models: list[str],
111
  voxel_size: list[float],
 
112
  overlap: int,
113
  layers_mask: list[bool],
114
  mixed_precision: bool,
 
133
  # from disk in C++, so build the list once and reuse it across both stages and every case.
134
  self._configurations: "list[itk.ModelConfiguration] | None" = None
135
  self._voxel_size = voxel_size
 
136
  self._overlap = overlap
137
  self._layers_mask = layers_mask
138
  self._mixed_precision = mixed_precision
 
172
  itk.ModelConfiguration(
173
  path,
174
  DIM,
175
+ NUM_CHANNELS,
176
  [0, 0, 0],
177
  [float(v) for v in self._voxel_size],
178
  self._overlap,
 
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,
 
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,
 
445
  engine = ConvexAdamEngine(
446
  models,
447
  voxel_size,
 
448
  overlap,
449
  layers_mask,
450
  mixed_precision,
ConvexAdam_Composite/Prediction.yml CHANGED
@@ -8,7 +8,6 @@ Predictor:
8
  - 3.0
9
  - 3.0
10
  - 3.0
11
- num_channels: 1
12
  overlap: 2
13
  layers_mask:
14
  - true
@@ -23,8 +22,7 @@ Predictor:
23
  - L1
24
  layers_weight:
25
  - 1.0
26
- subset_features:
27
- - 32
28
  pca:
29
  - 0
30
  linear: true
 
8
  - 3.0
9
  - 3.0
10
  - 3.0
 
11
  overlap: 2
12
  layers_mask:
13
  - true
 
22
  - L1
23
  layers_weight:
24
  - 1.0
25
+ subset_features: []
 
26
  pca:
27
  - 0
28
  linear: true
ConvexAdam_Fine/Model.py CHANGED
@@ -43,6 +43,9 @@ from konfai.network import network
43
  from konfai.utils.dataset import Attribute, data_to_image, image_to_data
44
 
45
  DIM = 3
 
 
 
46
  _IMAGE_F = itk.Image[itk.F, DIM]
47
 
48
 
@@ -106,7 +109,6 @@ class ConvexAdamEngine:
106
  self,
107
  models: list[str],
108
  voxel_size: list[float],
109
- num_channels: int,
110
  overlap: int,
111
  layers_mask: list[bool],
112
  mixed_precision: bool,
@@ -131,7 +133,6 @@ class ConvexAdamEngine:
131
  # from disk in C++, so build the list once and reuse it across both stages and every case.
132
  self._configurations: "list[itk.ModelConfiguration] | None" = None
133
  self._voxel_size = voxel_size
134
- self._num_channels = num_channels
135
  self._overlap = overlap
136
  self._layers_mask = layers_mask
137
  self._mixed_precision = mixed_precision
@@ -171,7 +172,7 @@ class ConvexAdamEngine:
171
  itk.ModelConfiguration(
172
  path,
173
  DIM,
174
- self._num_channels,
175
  [0, 0, 0],
176
  [float(v) for v in self._voxel_size],
177
  self._overlap,
@@ -416,7 +417,6 @@ class RegistrationNet(network.Network):
416
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
417
  models: list[str] = [],
418
  voxel_size: list[float] = [3.0, 3.0, 3.0],
419
- num_channels: int = 1,
420
  overlap: int = 2,
421
  layers_mask: list[bool] = [True],
422
  mixed_precision: bool = False,
@@ -428,7 +428,7 @@ class RegistrationNet(network.Network):
428
  grid_shrink: int = 4,
429
  distance: list[str] = ["L1"],
430
  layers_weight: list[float] = [1.0],
431
- subset_features: list[int] = [32],
432
  pca: list[int] = [0],
433
  stages: list[str] = ["coarse", "fine"],
434
  linear: bool = True,
@@ -445,7 +445,6 @@ class RegistrationNet(network.Network):
445
  engine = ConvexAdamEngine(
446
  models,
447
  voxel_size,
448
- num_channels,
449
  overlap,
450
  layers_mask,
451
  mixed_precision,
 
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
 
 
109
  self,
110
  models: list[str],
111
  voxel_size: list[float],
 
112
  overlap: int,
113
  layers_mask: list[bool],
114
  mixed_precision: bool,
 
133
  # from disk in C++, so build the list once and reuse it across both stages and every case.
134
  self._configurations: "list[itk.ModelConfiguration] | None" = None
135
  self._voxel_size = voxel_size
 
136
  self._overlap = overlap
137
  self._layers_mask = layers_mask
138
  self._mixed_precision = mixed_precision
 
172
  itk.ModelConfiguration(
173
  path,
174
  DIM,
175
+ NUM_CHANNELS,
176
  [0, 0, 0],
177
  [float(v) for v in self._voxel_size],
178
  self._overlap,
 
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,
 
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,
 
445
  engine = ConvexAdamEngine(
446
  models,
447
  voxel_size,
 
448
  overlap,
449
  layers_mask,
450
  mixed_precision,
ConvexAdam_Fine/Prediction.yml CHANGED
@@ -8,7 +8,6 @@ Predictor:
8
  - 3.0
9
  - 3.0
10
  - 3.0
11
- num_channels: 1
12
  overlap: 2
13
  layers_mask:
14
  - true
@@ -23,8 +22,7 @@ Predictor:
23
  - L1
24
  layers_weight:
25
  - 1.0
26
- subset_features:
27
- - 32
28
  pca:
29
  - 0
30
  linear: false
 
8
  - 3.0
9
  - 3.0
10
  - 3.0
 
11
  overlap: 2
12
  layers_mask:
13
  - true
 
22
  - L1
23
  layers_weight:
24
  - 1.0
25
+ subset_features: []
 
26
  pca:
27
  - 0
28
  linear: false
Generic_Rigid/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
Generic_Rigid/Prediction.yml CHANGED
@@ -5,9 +5,10 @@ Predictor:
5
  engine: elastix
6
  parameter_maps:
7
  - Parameters_Rigid.txt
8
- models: []
9
- iterations: 250
10
  outputs_criterions: None
 
 
 
11
  Dataset:
12
  groups_src:
13
  Volume_0:
 
5
  engine: elastix
6
  parameter_maps:
7
  - Parameters_Rigid.txt
 
 
8
  outputs_criterions: None
9
+ max_iterations: 0
10
+ spatial_samples: 0
11
+ parameter_overrides: []
12
  Dataset:
13
  groups_src:
14
  Volume_0:
Generic_Rigid_BSpline/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
Generic_Rigid_BSpline/Prediction.yml CHANGED
@@ -6,9 +6,11 @@ Predictor:
6
  parameter_maps:
7
  - Parameters_Rigid.txt
8
  - Parameters_BSpline.txt
9
- models: []
10
- iterations: 3000
11
  outputs_criterions: None
 
 
 
 
12
  Dataset:
13
  groups_src:
14
  Volume_0:
 
6
  parameter_maps:
7
  - Parameters_Rigid.txt
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:
16
  Volume_0:
MR_CT_HeadNeck/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
MR_CT_HeadNeck/ParameterMap_MRI_HN.txt CHANGED
@@ -11,7 +11,7 @@
11
 
12
 
13
  (ImpactModelsPath0 "MIND/R1D2_3D.pt")
14
- (ImpactDimension0 3 )
15
  (ImpactNumberOfChannels0 1)
16
  (ImpactPatchSize0 0 0 0)
17
  (ImpactVoxelSize0 6 6 6)
@@ -24,7 +24,7 @@
24
  (ImpactModelsPath1 "MIND/R1D2_3D.pt")
25
  (ImpactDimension1 3)
26
  (ImpactNumberOfChannels1 1)
27
- (ImpactPatchSize1 0 0 0)
28
  (ImpactVoxelSize1 3 3 3)
29
  (ImpactLayersMask1 "1")
30
  (ImpactSubsetFeatures1 32)
@@ -136,4 +136,4 @@
136
  (ResultImageFormat "mha")
137
 
138
  (ITKTransformOutputFileNameExtension "itk.txt")
139
- (WriteITKCompositeTransform "true")
 
11
 
12
 
13
  (ImpactModelsPath0 "MIND/R1D2_3D.pt")
14
+ (ImpactDimension0 3)
15
  (ImpactNumberOfChannels0 1)
16
  (ImpactPatchSize0 0 0 0)
17
  (ImpactVoxelSize0 6 6 6)
 
24
  (ImpactModelsPath1 "MIND/R1D2_3D.pt")
25
  (ImpactDimension1 3)
26
  (ImpactNumberOfChannels1 1)
27
+ (ImpactPatchSize1 0 0 0)
28
  (ImpactVoxelSize1 3 3 3)
29
  (ImpactLayersMask1 "1")
30
  (ImpactSubsetFeatures1 32)
 
136
  (ResultImageFormat "mha")
137
 
138
  (ITKTransformOutputFileNameExtension "itk.txt")
139
+ (WriteITKCompositeTransform "true")
MR_CT_HeadNeck/Prediction.yml CHANGED
@@ -5,10 +5,75 @@ Predictor:
5
  engine: elastix
6
  parameter_maps:
7
  - ParameterMap_MRI_HN.txt
8
- models:
9
- - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
10
- iterations: 1050
11
  outputs_criterions: None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  Dataset:
13
  groups_src:
14
  Volume_0:
 
5
  engine: elastix
6
  parameter_maps:
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':
16
+ max_iterations: 300
17
+ models:
18
+ '0':
19
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
20
+ voxel_size:
21
+ - 6.0
22
+ - 6.0
23
+ - 6.0
24
+ layers_mask: '1'
25
+ layers_weight:
26
+ - 1.0
27
+ subset_features: 32
28
+ pca: 0
29
+ distance: L1
30
+ '1':
31
+ max_iterations: 300
32
+ models:
33
+ '0':
34
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
35
+ voxel_size:
36
+ - 3.0
37
+ - 3.0
38
+ - 3.0
39
+ layers_mask: '1'
40
+ layers_weight:
41
+ - 1.0
42
+ subset_features: 32
43
+ pca: 0
44
+ distance: L1
45
+ '2':
46
+ max_iterations: 250
47
+ models:
48
+ '0':
49
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
50
+ voxel_size:
51
+ - 2.0
52
+ - 2.0
53
+ - 2.0
54
+ layers_mask: '1'
55
+ layers_weight:
56
+ - 1.0
57
+ subset_features: 32
58
+ pca: 0
59
+ distance: L1
60
+ '3':
61
+ max_iterations: 200
62
+ models:
63
+ '0':
64
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
65
+ voxel_size:
66
+ - 2.0
67
+ - 2.0
68
+ - 2.0
69
+ layers_mask: '1'
70
+ layers_weight:
71
+ - 1.0
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:
79
  Volume_0:
MR_CT_MRSeg/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
MR_CT_MRSeg/ParameterMap_MRI_MRSeg.txt CHANGED
@@ -24,7 +24,7 @@
24
  (ImpactModelsPath1 "MIND/R1D2_3D.pt" "MRSeg/MRSeg.pt")
25
  (ImpactDimension1 3 3)
26
  (ImpactNumberOfChannels1 1 1)
27
- (ImpactPatchSize1 0 0 0 0 0 0)
28
  (ImpactVoxelSize1 3 3 3 3 3 3)
29
  (ImpactLayersMask1 "1" "1")
30
  (ImpactSubsetFeatures1 32 64)
@@ -37,7 +37,7 @@
37
  (ImpactNumberOfChannels2 1 1)
38
  (ImpactPatchSize2 0 0 0 0 0 0)
39
  (ImpactVoxelSize2 2 2 2 2 2 2)
40
- (ImpactLayersMask2 "1" "1")
41
  (ImpactSubsetFeatures2 32 64)
42
  (ImpactPCA2 0 0)
43
  (ImpactDistance2 "L1" "Dice")
@@ -48,7 +48,7 @@
48
  (ImpactNumberOfChannels3 1 1)
49
  (ImpactPatchSize3 0 0 0 0 0 0)
50
  (ImpactVoxelSize3 2 2 2 2 2 2)
51
- (ImpactLayersMask3 "1" "1")
52
  (ImpactSubsetFeatures3 32 64)
53
  (ImpactPCA3 0 0)
54
  (ImpactDistance3 "L1" "Dice")
 
24
  (ImpactModelsPath1 "MIND/R1D2_3D.pt" "MRSeg/MRSeg.pt")
25
  (ImpactDimension1 3 3)
26
  (ImpactNumberOfChannels1 1 1)
27
+ (ImpactPatchSize1 0 0 0 0 0 0)
28
  (ImpactVoxelSize1 3 3 3 3 3 3)
29
  (ImpactLayersMask1 "1" "1")
30
  (ImpactSubsetFeatures1 32 64)
 
37
  (ImpactNumberOfChannels2 1 1)
38
  (ImpactPatchSize2 0 0 0 0 0 0)
39
  (ImpactVoxelSize2 2 2 2 2 2 2)
40
+ (ImpactLayersMask2 "1" "1")
41
  (ImpactSubsetFeatures2 32 64)
42
  (ImpactPCA2 0 0)
43
  (ImpactDistance2 "L1" "Dice")
 
48
  (ImpactNumberOfChannels3 1 1)
49
  (ImpactPatchSize3 0 0 0 0 0 0)
50
  (ImpactVoxelSize3 2 2 2 2 2 2)
51
+ (ImpactLayersMask3 "1" "1")
52
  (ImpactSubsetFeatures3 32 64)
53
  (ImpactPCA3 0 0)
54
  (ImpactDistance3 "L1" "Dice")
MR_CT_MRSeg/Prediction.yml CHANGED
@@ -5,11 +5,123 @@ Predictor:
5
  engine: elastix
6
  parameter_maps:
7
  - ParameterMap_MRI_MRSeg.txt
8
- models:
9
- - VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
10
- - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
11
- iterations: 1100
12
  outputs_criterions: None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  Dataset:
14
  groups_src:
15
  Volume_0:
 
5
  engine: elastix
6
  parameter_maps:
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':
16
+ max_iterations: 400
17
+ models:
18
+ '0':
19
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
20
+ voxel_size:
21
+ - 6.0
22
+ - 6.0
23
+ - 6.0
24
+ layers_mask: '1'
25
+ layers_weight:
26
+ - 0.2
27
+ subset_features: 32
28
+ pca: 0
29
+ distance: L1
30
+ '1':
31
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
32
+ voxel_size:
33
+ - 6.0
34
+ - 6.0
35
+ - 6.0
36
+ layers_mask: '1'
37
+ layers_weight:
38
+ - 0.8
39
+ subset_features: 64
40
+ pca: 0
41
+ distance: Dice
42
+ '1':
43
+ max_iterations: 300
44
+ models:
45
+ '0':
46
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
47
+ voxel_size:
48
+ - 3.0
49
+ - 3.0
50
+ - 3.0
51
+ layers_mask: '1'
52
+ layers_weight:
53
+ - 0.3
54
+ subset_features: 32
55
+ pca: 0
56
+ distance: L1
57
+ '1':
58
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
59
+ voxel_size:
60
+ - 3.0
61
+ - 3.0
62
+ - 3.0
63
+ layers_mask: '1'
64
+ layers_weight:
65
+ - 0.7
66
+ subset_features: 64
67
+ pca: 0
68
+ distance: Dice
69
+ '2':
70
+ max_iterations: 200
71
+ models:
72
+ '0':
73
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
74
+ voxel_size:
75
+ - 2.0
76
+ - 2.0
77
+ - 2.0
78
+ layers_mask: '1'
79
+ layers_weight:
80
+ - 0.6
81
+ subset_features: 32
82
+ pca: 0
83
+ distance: L1
84
+ '1':
85
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
86
+ voxel_size:
87
+ - 2.0
88
+ - 2.0
89
+ - 2.0
90
+ layers_mask: '1'
91
+ layers_weight:
92
+ - 0.4
93
+ subset_features: 64
94
+ pca: 0
95
+ distance: Dice
96
+ '3':
97
+ max_iterations: 200
98
+ models:
99
+ '0':
100
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
101
+ voxel_size:
102
+ - 2.0
103
+ - 2.0
104
+ - 2.0
105
+ layers_mask: '1'
106
+ layers_weight:
107
+ - 0.7
108
+ subset_features: 32
109
+ pca: 0
110
+ distance: L1
111
+ '1':
112
+ ref: VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
113
+ voxel_size:
114
+ - 2.0
115
+ - 2.0
116
+ - 2.0
117
+ layers_mask: '1'
118
+ layers_weight:
119
+ - 0.3
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:
127
  Volume_0:
MR_CT_TS/Model.py CHANGED
@@ -32,6 +32,7 @@ NOTE: do NOT add ``from __future__ import annotations`` here β€” KonfAI's config
32
  runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
  """
34
 
 
35
  import os
36
  import re
37
  import shutil
@@ -52,6 +53,212 @@ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
  # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
  ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  class ElastixEngine:
57
  """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
@@ -60,14 +267,57 @@ class ElastixEngine:
60
  does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
  """
62
 
63
- def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
64
  self._bundle_dir = Path(__file__).resolve().parent
65
  self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  self._models = models
67
- self._iterations = iterations
 
 
68
  self._elastix_bin = self._ensure_binary()
69
  self._local_models = self._download_models()
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _ensure_binary(self) -> Path:
72
  # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
  override = os.environ.get("KONFAI_ELASTIX_DIR", "")
@@ -91,17 +341,90 @@ class ElastixEngine:
91
  models.append((filename, local))
92
  return models
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
- """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
 
 
 
 
 
 
96
  staged = []
97
  for src in self._parameter_maps:
 
 
 
 
 
 
 
 
 
98
  dst = work / src.name
99
- lines = []
100
- for line in src.read_text(encoding="utf-8").splitlines():
101
- if line.strip().startswith("(ImpactGPU"):
102
- line = f"(ImpactGPU {device_index})"
103
- lines.append(line)
104
- dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
  staged.append(dst)
106
  return staged
107
 
@@ -161,8 +484,10 @@ class ElastixEngine:
161
  captured: list[str] = []
162
  iteration_line = re.compile(r"^\d+\s")
163
  # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
- # chained parameter maps), so the bar spans the whole chain of registration stages.
165
- progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
 
 
166
  assert proc.stdout is not None
167
  resolution = 0
168
  for line in proc.stdout:
@@ -226,11 +551,33 @@ class ElastixRegistration(torch.nn.Module):
226
 
227
  accepts_attributes = True
228
 
229
- def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
230
  super().__init__()
231
  if engine != "elastix":
232
  raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
233
- self._engine = ElastixEngine(parameter_maps, models, iterations)
 
 
 
 
 
 
 
 
 
 
234
 
235
  def forward(
236
  self,
@@ -290,9 +637,23 @@ class RegistrationNet(network.Network):
290
  outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
291
  engine: str = "elastix",
292
  parameter_maps: list[str] = [],
293
- models: list[str] = [],
294
- iterations: int = 1000,
 
 
 
 
 
 
295
  ) -> None:
 
 
 
 
 
 
 
 
296
  super().__init__(
297
  in_channels=1,
298
  optimizer=optimizer,
@@ -302,7 +663,18 @@ class RegistrationNet(network.Network):
302
  )
303
  self.add_module(
304
  "Registration",
305
- ElastixRegistration(engine, parameter_maps, models, iterations),
 
 
 
 
 
 
 
 
 
 
 
306
  in_branch=[0, 1, 2, 3],
307
  out_branch=["registration"],
308
  )
 
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
 
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:
129
+ path = Path(local)
130
+ elif ":" in ref:
131
+ repo, filename = ref.split(":", 1)
132
+ path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
+ else:
134
+ raise ValueError(
135
+ f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
136
+ f"from HF, not bundled) β€” or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
137
+ )
138
+ return json.loads(path.read_text(encoding="utf-8"))
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"]
156
+ if not active:
157
+ raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
158
+ return max(active)
159
+
160
+
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()
174
+ if key.isdigit():
175
+ return int(key)
176
+ if key == "2*r*d+1":
177
+ return 2 * int(fov["r"]) * int(fov["d"]) + 1
178
+ if key == "2^l+3":
179
+ return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
180
+ if "global" in key:
181
+ raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
182
+ if fov.get("value") is not None:
183
+ return int(fov["value"])
184
+ raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
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)
194
+ fov = _fov_value(entry.get("fov", {}), layers_mask)
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)
211
+ mode_clean = mode.strip().strip('"') or "Static"
212
+
213
+ impact: list[str] = []
214
+ for k, r in enumerate(res):
215
+ models = _sorted_specs(r.models)
216
+ entries = [registry[_model_key(m.ref)] for m in models]
217
+
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])
227
+ row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
228
+ row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
229
+ row("LayersMask", [f'"{m.layers_mask}"' for m in models])
230
+ row("SubsetFeatures", [str(m.subset_features) for m in models])
231
+ row("PCA", [str(m.pca) for m in models])
232
+ row("Distance", [f'"{m.distance}"' for m in models])
233
+ row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
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))]
242
+ block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
243
+
244
+ out: list[str] = []
245
+ for i, (m, line) in enumerate(indexed):
246
+ key = m.group(1) if m else None
247
+ if block_lo <= i <= block_hi:
248
+ if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
249
+ out.extend(impact[:-1])
250
+ elif key == "MaximumNumberOfIterations":
251
+ out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
252
+ elif key == "NumberOfResolutions":
253
+ out.append(f"(NumberOfResolutions {n})")
254
+ elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
255
+ out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
256
+ elif key == "ImpactMode":
257
+ out.append(f'(ImpactMode "{mode_clean}")')
258
+ else:
259
+ out.append(line)
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.
 
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", "")
 
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
 
 
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:
 
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,
 
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,
 
663
  )
664
  self.add_module(
665
  "Registration",
666
+ ElastixRegistration(
667
+ engine,
668
+ parameter_maps,
669
+ max_iterations,
670
+ final_grid_spacing,
671
+ subset_features,
672
+ spatial_samples,
673
+ parameter_overrides,
674
+ resolutions,
675
+ models_registry,
676
+ mode,
677
+ ),
678
  in_branch=[0, 1, 2, 3],
679
  out_branch=["registration"],
680
  )
MR_CT_TS/ParameterMap_MRI_TS.txt CHANGED
@@ -24,7 +24,7 @@
24
  (ImpactModelsPath1 "MIND/R1D2_3D.pt" "TS/M852.pt")
25
  (ImpactDimension1 3 3)
26
  (ImpactNumberOfChannels1 1 1)
27
- (ImpactPatchSize1 0 0 0 0 0 0)
28
  (ImpactVoxelSize1 3 3 3 3 3 3)
29
  (ImpactLayersMask1 "1" "0000001")
30
  (ImpactSubsetFeatures1 32 64)
@@ -37,7 +37,7 @@
37
  (ImpactNumberOfChannels2 1 1)
38
  (ImpactPatchSize2 0 0 0 0 0 0)
39
  (ImpactVoxelSize2 2 2 2 2 2 2)
40
- (ImpactLayersMask2 "1" "00000001")
41
  (ImpactSubsetFeatures2 32 64)
42
  (ImpactPCA2 0 0)
43
  (ImpactDistance2 "L1" "Dice")
@@ -48,7 +48,7 @@
48
  (ImpactNumberOfChannels3 1 1)
49
  (ImpactPatchSize3 0 0 0 0 0 0)
50
  (ImpactVoxelSize3 2 2 2 2 2 2)
51
- (ImpactLayersMask3 "1" "00000001")
52
  (ImpactSubsetFeatures3 32 64)
53
  (ImpactPCA3 0 0)
54
  (ImpactDistance3 "L1" "Dice")
@@ -135,4 +135,4 @@
135
  (ResultImageFormat "mha")
136
 
137
  (ITKTransformOutputFileNameExtension "itk.txt")
138
- (WriteITKCompositeTransform "true")
 
24
  (ImpactModelsPath1 "MIND/R1D2_3D.pt" "TS/M852.pt")
25
  (ImpactDimension1 3 3)
26
  (ImpactNumberOfChannels1 1 1)
27
+ (ImpactPatchSize1 0 0 0 0 0 0)
28
  (ImpactVoxelSize1 3 3 3 3 3 3)
29
  (ImpactLayersMask1 "1" "0000001")
30
  (ImpactSubsetFeatures1 32 64)
 
37
  (ImpactNumberOfChannels2 1 1)
38
  (ImpactPatchSize2 0 0 0 0 0 0)
39
  (ImpactVoxelSize2 2 2 2 2 2 2)
40
+ (ImpactLayersMask2 "1" "00000001")
41
  (ImpactSubsetFeatures2 32 64)
42
  (ImpactPCA2 0 0)
43
  (ImpactDistance2 "L1" "Dice")
 
48
  (ImpactNumberOfChannels3 1 1)
49
  (ImpactPatchSize3 0 0 0 0 0 0)
50
  (ImpactVoxelSize3 2 2 2 2 2 2)
51
+ (ImpactLayersMask3 "1" "00000001")
52
  (ImpactSubsetFeatures3 32 64)
53
  (ImpactPCA3 0 0)
54
  (ImpactDistance3 "L1" "Dice")
 
135
  (ResultImageFormat "mha")
136
 
137
  (ITKTransformOutputFileNameExtension "itk.txt")
138
+ (WriteITKCompositeTransform "true")
MR_CT_TS/Prediction.yml CHANGED
@@ -5,12 +5,123 @@ Predictor:
5
  engine: elastix
6
  parameter_maps:
7
  - ParameterMap_MRI_TS.txt
8
- models:
9
- - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
10
- - VBoussot/impact-torchscript-models:TS/M852.pt
11
- - VBoussot/impact-torchscript-models:TS/M850.pt
12
- iterations: 1100
13
  outputs_criterions: None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  Dataset:
15
  groups_src:
16
  Volume_0:
 
5
  engine: elastix
6
  parameter_maps:
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':
16
+ max_iterations: 400
17
+ models:
18
+ '0':
19
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
20
+ voxel_size:
21
+ - 6.0
22
+ - 6.0
23
+ - 6.0
24
+ layers_mask: '1'
25
+ layers_weight:
26
+ - 0.2
27
+ subset_features: 32
28
+ pca: 0
29
+ distance: L1
30
+ '1':
31
+ ref: VBoussot/impact-torchscript-models:TS/M852.pt
32
+ voxel_size:
33
+ - 6.0
34
+ - 6.0
35
+ - 6.0
36
+ layers_mask: '0000001'
37
+ layers_weight:
38
+ - 0.8
39
+ subset_features: 64
40
+ pca: 0
41
+ distance: Dice
42
+ '1':
43
+ max_iterations: 300
44
+ models:
45
+ '0':
46
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
47
+ voxel_size:
48
+ - 3.0
49
+ - 3.0
50
+ - 3.0
51
+ layers_mask: '1'
52
+ layers_weight:
53
+ - 0.3
54
+ subset_features: 32
55
+ pca: 0
56
+ distance: L1
57
+ '1':
58
+ ref: VBoussot/impact-torchscript-models:TS/M852.pt
59
+ voxel_size:
60
+ - 3.0
61
+ - 3.0
62
+ - 3.0
63
+ layers_mask: '0000001'
64
+ layers_weight:
65
+ - 0.7
66
+ subset_features: 64
67
+ pca: 0
68
+ distance: Dice
69
+ '2':
70
+ max_iterations: 200
71
+ models:
72
+ '0':
73
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
74
+ voxel_size:
75
+ - 2.0
76
+ - 2.0
77
+ - 2.0
78
+ layers_mask: '1'
79
+ layers_weight:
80
+ - 0.6
81
+ subset_features: 32
82
+ pca: 0
83
+ distance: L1
84
+ '1':
85
+ ref: VBoussot/impact-torchscript-models:TS/M850.pt
86
+ voxel_size:
87
+ - 2.0
88
+ - 2.0
89
+ - 2.0
90
+ layers_mask: '00000001'
91
+ layers_weight:
92
+ - 0.4
93
+ subset_features: 64
94
+ pca: 0
95
+ distance: Dice
96
+ '3':
97
+ max_iterations: 200
98
+ models:
99
+ '0':
100
+ ref: VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
101
+ voxel_size:
102
+ - 2.0
103
+ - 2.0
104
+ - 2.0
105
+ layers_mask: '1'
106
+ layers_weight:
107
+ - 0.7
108
+ subset_features: 32
109
+ pca: 0
110
+ distance: L1
111
+ '1':
112
+ ref: VBoussot/impact-torchscript-models:TS/M850.pt
113
+ voxel_size:
114
+ - 2.0
115
+ - 2.0
116
+ - 2.0
117
+ layers_mask: '00000001'
118
+ layers_weight:
119
+ - 0.3
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:
127
  Volume_0:
README.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: konfai
4
+ pipeline_tag: image-to-image
5
+ tags:
6
+ - medical-imaging
7
+ - registration
8
+ - deformable
9
+ - multimodal
10
+ - ct
11
+ - mri
12
+ - cbct
13
+ - konfai
14
+ ---
15
+
16
+ # IMPACT-Reg β€” Multimodal Medical Image Registration
17
+
18
+ Robust **multimodal** (**MR / CT / CBCT**) deformable **registration** presets, built with
19
+ [**KonfAI**](https://github.com/vboussot/KonfAI). Alignment is driven by the **IMPACT** semantic
20
+ similarity metric β€” deep features from pretrained segmentation / foundation models (**MIND**,
21
+ **TotalSegmentator**, **MRSegmentator**) β€” so cross-modality pairs align while the deformation
22
+ stays smooth and physically plausible.
23
+
24
+ Each preset is a **self-contained KonfAI app**: on the **fixed** grid it produces the moving image
25
+ resampled onto the fixed image (**`MovedImage`**) and the **`DisplacementField`**. Presets can be
26
+ **ensembled** (their displacement fields are averaged into one transform).
27
+
28
+ ## 🧩 Presets
29
+
30
+ | Preset | Pair | Engine | Description |
31
+ |:--|:--|:--|:--|
32
+ | `Generic_Rigid` | any | elastix | Rigid alignment (mutual information, multi-resolution) |
33
+ | `Generic_Rigid_BSpline` | any | elastix | Rigid, then B-spline deformable refinement |
34
+ | `MR_CT_HeadNeck` | MR/CT | elastix + IMPACT | MR/CT head & neck preset |
35
+ | `MR_CT_TS` | MR/CT | elastix + IMPACT | MR/CT with MIND + TotalSegmentator features |
36
+ | `MR_CT_MRSeg` | MR/CT | elastix + IMPACT | MR/CT with MIND + MRSegmentator features |
37
+ | `CBCT_CT_HeadNeck` | CBCT/CT | elastix + IMPACT | CBCT/CT head & neck preset |
38
+ | `CBCT_CT_TS` | CBCT/CT | elastix + IMPACT | CBCT/CT with TotalSegmentator features |
39
+ | `CBCT_CT_MRSeg` | CBCT/CT | elastix + IMPACT | CBCT/CT with MRSegmentator features |
40
+ | `ConvexAdam_Coarse` | any | itk-impact (native) | Global coarse coupled-convex init (IMPACT/MIND) |
41
+ | `ConvexAdam_Fine` | any | itk-impact (native) | Adam instance-optimisation (tileable; expects a pre-aligned start) |
42
+ | `ConvexAdam_Composite` | any | itk-impact (native) | Coarse + fine ConvexAdam in one app (IMPACT/MIND) |
43
+
44
+ Inputs: **Fixed**, **Moving**, and optional **FixedMask** / **MovingMask** (restrict the metric region).
45
+
46
+ ## πŸš€ Usage
47
+
48
+ ```bash
49
+ pip install impact-reg-konfai
50
+ # Register a moving image onto a fixed image (ensemble several presets by listing them):
51
+ impact-reg-konfai register ConvexAdam_Composite -f fixed.nii.gz -m moving.nii.gz -o ./Output --gpu 0
52
+ ```
53
+
54
+ - **Generic runner (single preset):** `konfai-apps infer VBoussot/ImpactReg:ConvexAdam_Composite -i fixed.nii.gz -i moving.nii.gz -o output/`
55
+ - **Interactive:** [**SlicerImpactReg**](https://github.com/vboussot/SlicerImpactReg) β€” a 3D Slicer extension driving these presets.
56
+
57
+ > The `ConvexAdam_*` presets depend on [`itk-impact`](https://pypi.org/project/itk-impact/); resolving the app installs it automatically (it reuses your existing PyTorch, CPU or GPU).
58
+
59
+ ## ⚑ Performance & VRAM
60
+
61
+ `ConvexAdam` presets (native, GPU) benchmarked on an **NVIDIA RTX PRO 5000 (24 GB)** with a real
62
+ abdomen **MR→CT** pair, **222 × 226 × 124 @ 2 mm** (single pass, no TTA):
63
+
64
+ | Preset | Stages | Time / case | Peak VRAM |
65
+ |:--|:--|:--:|:--:|
66
+ | `ConvexAdam_Fine` | fine (150 Adam iters) | **β‰ˆ 0.5 s** | ~2.1 GB |
67
+ | `ConvexAdam_Coarse` | linear + coarse | **β‰ˆ 4.6 s** | ~2.1 GB |
68
+ | `ConvexAdam_Composite` | linear + coarse + fine | **β‰ˆ 5.1 s** | ~2.1 GB |
69
+
70
+ Per-stage breakdown: linear pre-align **β‰ˆ 4.2 s** (ITK affine, MI) Β· coarse **β‰ˆ 0.4 s** Β· fine **β‰ˆ 0.5 s**.
71
+ One-time TorchScript feature-model load **β‰ˆ 7 s** (amortised across a batch). Times scale with case size;
72
+ `--tta k` multiplies runtime. The `elastix + IMPACT` presets run through elastix and scale differently.
73
+
74
+ ## πŸ”— Links & Citation
75
+
76
+ - 🧠 **KonfAI:** [github.com/vboussot/KonfAI](https://github.com/vboussot/KonfAI)
77
+ - πŸ“¦ **PyPI:** [impact_reg_konfai](https://pypi.org/project/impact_reg_konfai/)
78
+ - 🩻 **Slicer:** [SlicerImpactReg](https://github.com/vboussot/SlicerImpactReg)
79
+ - πŸ“„ **Paper:** KonfAI β€” [arXiv:2508.09823](https://arxiv.org/abs/2508.09823)
80
+ </content>
81
+ </invoke>