VBoussot commited on
Commit
2f7c1ac
·
1 Parent(s): 68e49c6

Move engine code to impact_reg_konfai.models; keep only config + weights

Browse files

Each preset's Prediction.yml now points its classpath at
impact_reg_konfai.models.<engine>:RegistrationNet instead of a bundled Model.py,
and declares impact_reg_konfai>=1.6.1. The duplicated engine code (Model.py, plus
elastix_engine.py/install.py) is removed; the elastix parameter-map .txt stay, as
they are per-preset config. Requires impact_reg_konfai>=1.6.1 (ships the models
subpackage).

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. CBCT_CT_HeadNeck/Model.py +0 -301
  2. CBCT_CT_HeadNeck/Prediction.yml +1 -1
  3. CBCT_CT_HeadNeck/elastix_engine.py +0 -386
  4. CBCT_CT_HeadNeck/install.py +0 -325
  5. CBCT_CT_HeadNeck/requirements.txt +1 -0
  6. CBCT_CT_MRSeg/Model.py +0 -301
  7. CBCT_CT_MRSeg/Prediction.yml +1 -1
  8. CBCT_CT_MRSeg/elastix_engine.py +0 -386
  9. CBCT_CT_MRSeg/install.py +0 -325
  10. CBCT_CT_MRSeg/requirements.txt +1 -0
  11. ConvexAdam_Coarse/Model.py +0 -549
  12. ConvexAdam_Coarse/Prediction.yml +1 -1
  13. ConvexAdam_Coarse/requirements.txt +1 -0
  14. ConvexAdam_Composite/Model.py +0 -549
  15. ConvexAdam_Composite/Prediction.yml +1 -1
  16. ConvexAdam_Composite/requirements.txt +1 -0
  17. ConvexAdam_Fine/Model.py +0 -549
  18. ConvexAdam_Fine/Prediction.yml +1 -1
  19. ConvexAdam_Fine/requirements.txt +1 -0
  20. FireANTs_Affine/Model.py +0 -669
  21. FireANTs_Affine/Prediction.yml +1 -1
  22. FireANTs_Affine/requirements.txt +1 -0
  23. FireANTs_IMPACT/Model.py +0 -669
  24. FireANTs_IMPACT/Prediction.yml +1 -1
  25. FireANTs_IMPACT/requirements.txt +1 -0
  26. FireANTs_SyN/Model.py +0 -669
  27. FireANTs_SyN/Prediction.yml +1 -1
  28. FireANTs_SyN/requirements.txt +1 -0
  29. Generic_Rigid/Model.py +0 -301
  30. Generic_Rigid/Prediction.yml +1 -1
  31. Generic_Rigid/elastix_engine.py +0 -386
  32. Generic_Rigid/install.py +0 -325
  33. Generic_Rigid/requirements.txt +1 -0
  34. Generic_Rigid_BSpline/Model.py +0 -301
  35. Generic_Rigid_BSpline/Prediction.yml +1 -1
  36. Generic_Rigid_BSpline/elastix_engine.py +0 -386
  37. Generic_Rigid_BSpline/install.py +0 -325
  38. Generic_Rigid_BSpline/requirements.txt +1 -0
  39. MR_CT_HeadNeck/Model.py +0 -301
  40. MR_CT_HeadNeck/Prediction.yml +1 -1
  41. MR_CT_HeadNeck/elastix_engine.py +0 -386
  42. MR_CT_HeadNeck/install.py +0 -325
  43. MR_CT_HeadNeck/requirements.txt +1 -0
  44. MR_CT_MRSeg/Model.py +0 -301
  45. MR_CT_MRSeg/Prediction.yml +1 -1
  46. MR_CT_MRSeg/elastix_engine.py +0 -386
  47. MR_CT_MRSeg/install.py +0 -325
  48. MR_CT_MRSeg/requirements.txt +1 -0
  49. MR_CT_TS/Model.py +0 -301
  50. MR_CT_TS/Prediction.yml +1 -1
CBCT_CT_HeadNeck/Model.py DELETED
@@ -1,301 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
18
-
19
- ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
20
- 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
21
- the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
22
- the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
23
- subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
24
-
25
- A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
26
- ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
27
-
28
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
29
- (``get_origin``); PEP 563 stringized annotations break arg resolution.
30
- """
31
-
32
- import json
33
- import os
34
- import re
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Annotated, Literal
38
-
39
- import torch
40
- from huggingface_hub import hf_hub_download
41
- from konfai.network import network
42
- from konfai.utils.config import Choices, Range
43
-
44
- # IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
45
- # A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
46
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
47
- # iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
48
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
49
-
50
- # ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
51
- # Static anyway; in Jacobian we clamp ``l`` to this plateau.
52
- _FOV_RAMP_MAX_LAYER = 6
53
-
54
-
55
- def registry_choices() -> list[str]:
56
- """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
57
- (offline-first). A user may still point ``ref`` at a local model."""
58
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
59
- return [f"{repo}:{key}" for key in load_models_registry()]
60
-
61
-
62
- def _num(x: object) -> str:
63
- """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
64
- return "%g" % float(x)
65
-
66
-
67
- @dataclass
68
- class ModelSpec:
69
- """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
70
- rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
71
- (``models.json``) keyed by ``ref`` — never tuned."""
72
-
73
- ref: Annotated[str, Choices(registry_choices)]
74
- voxel_size: list[float] = field(default_factory=list)
75
- layers_weight: list[float] = field(default_factory=lambda: [1.0])
76
- subset_features: Annotated[int, Range(0, 1000)] = 0
77
- pca: Annotated[int, Range(0, 100)] = 0
78
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
79
- layers_mask: str = ""
80
-
81
-
82
- @dataclass
83
- class ResolutionSpec:
84
- """One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
85
-
86
- max_iterations: Annotated[int, Range(1, 100000)]
87
- models: dict[str, ModelSpec]
88
-
89
-
90
- def _sorted_specs(mapping: dict) -> list:
91
- """dict keyed by string indices ('0','1',...) -> values in numeric order."""
92
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
93
-
94
-
95
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
96
- """Load models.json (the fixed params per model) from the model repo on Hugging Face.
97
-
98
- The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
99
- dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
100
- """
101
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
102
- if local:
103
- path = Path(local)
104
- elif ":" in ref:
105
- repo, filename = ref.split(":", 1)
106
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
107
- else:
108
- raise ValueError(
109
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
110
- f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
111
- )
112
- return json.loads(path.read_text(encoding="utf-8"))
113
-
114
-
115
- def _model_key(ref: str) -> str:
116
- """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
117
- return ref.split(":", 1)[1] if ":" in ref else ref
118
-
119
-
120
- def _deepest_active_layer(layers_mask: str) -> int:
121
- """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
122
-
123
- A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
124
- == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
125
- receptive field, so the FOV is governed by the rightmost ``'1'``.
126
- """
127
- mask = layers_mask.strip().strip('"')
128
- active = [i for i, char in enumerate(mask) if char == "1"]
129
- if not active:
130
- raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
131
- return max(active)
132
-
133
-
134
- def _fov_value(fov: dict, layers_mask: str) -> int:
135
- """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
136
-
137
- Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
138
- ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
139
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
140
- to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
141
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
142
- ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
143
- An explicit ``value`` in the spec is honoured as a precomputed shortcut.
144
- """
145
- formula = str(fov.get("formula", "")).strip()
146
- key = re.sub(r"\s+", "", formula).lower()
147
- if key.isdigit():
148
- return int(key)
149
- if key == "2*r*d+1":
150
- return 2 * int(fov["r"]) * int(fov["d"]) + 1
151
- if key == "2^l+3":
152
- return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
153
- if "global" in key:
154
- raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
155
- if fov.get("value") is not None:
156
- return int(fov["value"])
157
- raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
158
-
159
-
160
- def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
161
- """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
162
- image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
163
- e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
164
- dim = int(entry.get("dimension", 3))
165
- if mode.strip().strip('"').lower() != "jacobian":
166
- return " ".join(["0"] * dim)
167
- fov = _fov_value(entry.get("fov", {}), layers_mask)
168
- return " ".join([str(fov)] * dim)
169
-
170
-
171
- def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
172
- """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
173
-
174
- Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
175
- ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
176
- is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
177
- from the registry formula and the cell's ``layers_mask``.
178
- """
179
- res = _sorted_specs(resolutions)
180
- n = len(res)
181
- mode_clean = mode.strip().strip('"') or "Static"
182
-
183
- impact: list[str] = []
184
- for k, r in enumerate(res):
185
- models = _sorted_specs(r.models)
186
- entries = [registry[_model_key(m.ref)] for m in models]
187
-
188
- def row(stem: str, values: list[str]) -> None:
189
- impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
190
-
191
- # From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
192
- # model FOV); everything else is a per-model knob taken straight from the cell.
193
- row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
194
- row("Dimension", [e["dimension"] for e in entries])
195
- row("NumberOfChannels", [e["numberofchannels"] for e in entries])
196
- row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
197
- row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
198
- row("LayersMask", [f'"{m.layers_mask}"' for m in models])
199
- row("SubsetFeatures", [str(m.subset_features) for m in models])
200
- row("PCA", [str(m.pca) for m in models])
201
- row("Distance", [f'"{m.distance}"' for m in models])
202
- row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
203
- impact.append("") # blank line between resolutions, mirroring the reference maps
204
-
205
- # The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
206
- # (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
207
- lines = template_text.splitlines()
208
- indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
209
- block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
210
- block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
211
-
212
- out: list[str] = []
213
- for i, (m, line) in enumerate(indexed):
214
- key = m.group(1) if m else None
215
- if block_lo <= i <= block_hi:
216
- if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
217
- out.extend(impact[:-1])
218
- elif key == "MaximumNumberOfIterations":
219
- out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
220
- elif key == "NumberOfResolutions":
221
- out.append(f"(NumberOfResolutions {n})")
222
- elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
223
- out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
224
- elif key == "ImpactMode":
225
- out.append(f'(ImpactMode "{mode_clean}")')
226
- else:
227
- out.append(line)
228
- return "\n".join(out)
229
-
230
-
231
- class ChannelSelect(torch.nn.Module):
232
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
233
-
234
- def __init__(self, start: int, stop: int) -> None:
235
- super().__init__()
236
- self._start = start
237
- self._stop = stop
238
-
239
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
240
- return tensor[:, self._start : self._stop]
241
-
242
-
243
- class RegistrationNet(network.Network):
244
- """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
245
- moving mask = 3; masks restrict the metric, whole-image = no restriction).
246
-
247
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
248
- (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
249
- ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
250
- ``same_as_group: Volume_0:Fixed``.
251
- """
252
-
253
- def __init__(
254
- self,
255
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
256
- schedulers: dict[str, network.LRSchedulersLoader] = {
257
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
258
- },
259
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
260
- engine: str = "elastix",
261
- parameter_maps: list[str] = [],
262
- max_iterations: Annotated[int, Range(0, 100000)] = 0,
263
- final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
264
- subset_features: Annotated[int, Range(0, 1000)] = 0,
265
- spatial_samples: Annotated[int, Range(0, 100000)] = 0,
266
- parameter_overrides: list[str] = [],
267
- resolutions: dict[str, ResolutionSpec] = {},
268
- mode: Literal["Static", "Jacobian"] = "Static",
269
- ) -> None:
270
- # The registration is fully described by ``resolutions`` (config = source of truth): each resolution
271
- # lists its self-configured models; the download list is derived from the cells. Global knobs override
272
- # the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
273
- # NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
274
- # preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
275
- from elastix_engine import ElastixRegistration
276
-
277
- super().__init__(
278
- in_channels=1,
279
- optimizer=optimizer,
280
- schedulers=schedulers,
281
- outputs_criterions=outputs_criterions,
282
- dim=3,
283
- )
284
- self.add_module(
285
- "Registration",
286
- ElastixRegistration(
287
- engine,
288
- parameter_maps,
289
- max_iterations,
290
- final_grid_spacing,
291
- subset_features,
292
- spatial_samples,
293
- parameter_overrides,
294
- resolutions,
295
- mode,
296
- ),
297
- in_branch=[0, 1, 2, 3],
298
- out_branch=["registration"],
299
- )
300
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
301
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CBCT_CT_HeadNeck/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.elastix:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
CBCT_CT_HeadNeck/elastix_engine.py DELETED
@@ -1,386 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Elastix-IMPACT runtime for the registration bundle.
18
-
19
- ``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
20
- parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
21
- ``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
22
- images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
23
- """
24
-
25
- import os
26
- import re
27
- import shutil
28
- import subprocess # nosec B404
29
- import tempfile
30
- from pathlib import Path
31
-
32
- import numpy as np
33
- import SimpleITK as sitk
34
- import torch
35
- import tqdm
36
- from huggingface_hub import hf_hub_download
37
- from install import get_elastix_bin, install_elastix_impact, try_elastix
38
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
39
-
40
- from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
41
-
42
- # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
43
- # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
44
- ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
45
-
46
-
47
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
48
- """True only for a mask that actually restricts the metric region — some voxels in, some out. An
49
- absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
50
- is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
51
- the whole image) instead of paying for a mask that restricts nothing."""
52
- if mask is None:
53
- return False
54
- arr = sitk.GetArrayViewFromImage(mask)
55
- return bool((arr > 0).any()) and bool((arr == 0).any())
56
-
57
-
58
- class ElastixEngine:
59
- """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
60
-
61
- NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
62
- NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
63
- """
64
-
65
- def __init__(
66
- self,
67
- parameter_maps: list[str],
68
- max_iterations: int = 0,
69
- final_grid_spacing: float = 0.0,
70
- subset_features: int = 0,
71
- spatial_samples: int = 0,
72
- parameter_overrides: list[str] = [],
73
- resolutions: dict = {},
74
- mode: str = "Static",
75
- ) -> None:
76
- self._bundle_dir = Path(__file__).resolve().parent
77
- self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
78
- self._max_iterations = max_iterations
79
- self._final_grid_spacing = final_grid_spacing
80
- self._subset_features = subset_features
81
- self._spatial_samples = spatial_samples
82
- self._parameter_overrides = list(parameter_overrides)
83
- # ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
84
- # samples random FOV-sized patches each iteration. One mode per preset.
85
- self._mode = mode
86
- # Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
87
- # intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
88
- self._resolutions = resolutions
89
- self._registry = load_models_registry() if resolutions else {}
90
- # Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
91
- models: list[str] = []
92
- for res in _sorted_specs(resolutions):
93
- for model in _sorted_specs(res.models):
94
- if model.ref not in models:
95
- models.append(model.ref)
96
- self._models = models
97
- # ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
98
- self._iterations = self._total_iterations()
99
- self._elastix_bin = self._ensure_binary()
100
- self._local_models = self._download_models()
101
-
102
- def _total_iterations(self) -> int:
103
- """Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
104
- if self._resolutions:
105
- return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
106
- total = 0
107
- for src in self._parameter_maps:
108
- match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
109
- if match:
110
- total += sum(int(token) for token in match.group(1).split())
111
- return total
112
-
113
- def _ensure_binary(self) -> Path:
114
- # Optional override: point at an existing elastix-IMPACT install (skips the download).
115
- override = os.environ.get("KONFAI_ELASTIX_DIR", "")
116
- if override:
117
- try_elastix(Path(override))
118
- return get_elastix_bin(Path(override)).resolve()
119
- ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
120
- try:
121
- try_elastix(ELASTIX_CACHE)
122
- except Exception:
123
- install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
124
- try_elastix(ELASTIX_CACHE)
125
- return get_elastix_bin(ELASTIX_CACHE).resolve()
126
-
127
- def _download_models(self) -> list[tuple[str, Path]]:
128
- """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
129
- models = []
130
- for ref in self._models:
131
- repo, filename = ref.split(":", 1)
132
- local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
- models.append((filename, local))
134
- return models
135
-
136
- def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
137
- """The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
138
-
139
- ``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
140
- **each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
141
- ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
142
- knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
143
- ``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
144
- """
145
- per_token: dict[str, str] = {}
146
- if not global_only and self._max_iterations > 0:
147
- per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
148
- if self._final_grid_spacing > 0:
149
- per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
150
- if not global_only and self._subset_features > 0:
151
- per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
152
- if self._spatial_samples > 0:
153
- per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
154
- exact: list[tuple[str, str]] = []
155
- for entry in self._parameter_overrides:
156
- key, sep, value = entry.partition("=")
157
- if not sep or not key.strip():
158
- raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
159
- exact.append((key.strip(), value.strip()))
160
- return per_token, exact
161
-
162
- @staticmethod
163
- def _apply_map_overrides(
164
- text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
165
- ) -> str:
166
- """Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
167
- of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
168
- """
169
- entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
170
- requested = set(per_token) | {key for key, _ in exact}
171
- seen: set[str] = set()
172
- lines = []
173
- for line in text.splitlines():
174
- match = entry_pattern.match(line)
175
- if match:
176
- indent, key, values = match.group(1), match.group(2), match.group(3)
177
- if key == "ImpactGPU":
178
- line = f"{indent}(ImpactGPU {device_index})"
179
- else:
180
- exact_value = next((value for k, value in exact if k == key), None)
181
- if exact_value is not None:
182
- seen.add(key)
183
- line = f"{indent}({key} {exact_value})"
184
- else:
185
- token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
186
- if token_key in per_token:
187
- seen.add(token_key)
188
- replaced = " ".join(per_token[token_key] for _ in values.split())
189
- line = f"{indent}({key} {replaced})"
190
- lines.append(line)
191
- # Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
192
- # surface it (e.g. final_grid_spacing on a rigid-only preset).
193
- for key in sorted(requested - seen):
194
- print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
195
- return "\n".join(lines)
196
-
197
- def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
198
- """Stage the parameter maps into ``work``.
199
-
200
- Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
201
- knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
202
- applies every per-token / exact override. Both set the ImpactGPU device.
203
- """
204
- staged = []
205
- for src in self._parameter_maps:
206
- if self._resolutions:
207
- text = generate_impact_parameter_map(
208
- src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
209
- )
210
- per_token, exact = self._parameter_map_overrides(global_only=True)
211
- else:
212
- text = src.read_text(encoding="utf-8")
213
- per_token, exact = self._parameter_map_overrides()
214
- text = self._apply_map_overrides(text, per_token, exact, device_index)
215
- dst = work / src.name
216
- dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
217
- staged.append(dst)
218
- return staged
219
-
220
- def register(
221
- self,
222
- fixed: sitk.Image,
223
- moving: sitk.Image,
224
- device_index: int,
225
- fixed_mask: sitk.Image | None = None,
226
- moving_mask: sitk.Image | None = None,
227
- ) -> tuple[np.ndarray, np.ndarray]:
228
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
229
-
230
- Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
231
- ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
232
- """
233
- work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
234
- try:
235
- fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
236
- sitk.WriteImage(fixed, str(fixed_path))
237
- sitk.WriteImage(moving, str(moving_path))
238
-
239
- # Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
240
- # "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
241
- for rel_name, model_path in self._local_models:
242
- dst = work / rel_name
243
- dst.parent.mkdir(parents=True, exist_ok=True)
244
- if not dst.exists():
245
- dst.symlink_to(model_path)
246
-
247
- args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
248
- for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
249
- if _is_partial_mask(mask):
250
- mask_path = work / name
251
- sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
252
- args += [flag, str(mask_path)]
253
- args += ["-out", str(work)]
254
- for pmap in self._stage_parameter_maps(work, device_index):
255
- args += ["-p", str(pmap)]
256
-
257
- # Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
258
- # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
259
- env = os.environ.copy()
260
- extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
261
- env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
262
- proc = subprocess.Popen( # nosec B603
263
- args,
264
- cwd=str(work),
265
- stdout=subprocess.PIPE,
266
- stderr=subprocess.STDOUT,
267
- text=True,
268
- bufsize=1,
269
- env=env,
270
- )
271
- # Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
272
- # progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
273
- # open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
274
- captured: list[str] = []
275
- iteration_line = re.compile(r"^\d+\s")
276
- budget = None if self._max_iterations > 0 else (self._iterations or None)
277
- progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
278
- assert proc.stdout is not None
279
- resolution = 0
280
- for line in proc.stdout:
281
- captured.append(line)
282
- stripped = line.strip()
283
- if stripped.startswith("Resolution:"):
284
- try:
285
- resolution = int(stripped.split(":", 1)[1])
286
- except ValueError:
287
- pass
288
- elif iteration_line.match(line):
289
- progress.update(1)
290
- columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
291
- if len(columns) > 1:
292
- try:
293
- progress.set_description(
294
- f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
295
- )
296
- except ValueError:
297
- pass
298
- progress.close()
299
- returncode = proc.wait()
300
- if returncode != 0:
301
- raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
302
-
303
- transforms = sorted(
304
- work.glob("TransformParameters.*-Composite.itk.txt"),
305
- key=lambda p: int(p.name.split(".")[1].split("-")[0]),
306
- )
307
- if not transforms:
308
- raise FileNotFoundError("elastix produced no composite transform file.")
309
- transform = sitk.ReadTransform(str(transforms[-1]))
310
-
311
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
312
- dvf = sitk.TransformToDisplacementField(
313
- transform,
314
- sitk.sitkVectorFloat64,
315
- fixed.GetSize(),
316
- fixed.GetOrigin(),
317
- fixed.GetSpacing(),
318
- fixed.GetDirection(),
319
- )
320
- moved_np, _ = image_to_data(moved)
321
- dvf_np, _ = image_to_data(dvf)
322
- return moved_np, dvf_np
323
- finally:
324
- shutil.rmtree(work, ignore_errors=True)
325
-
326
-
327
- class ElastixRegistration(torch.nn.Module):
328
- """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
329
-
330
- ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
331
- ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
332
- the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
333
- """
334
-
335
- accepts_attributes = True
336
-
337
- def __init__(
338
- self,
339
- engine: str,
340
- parameter_maps: list[str],
341
- max_iterations: int = 0,
342
- final_grid_spacing: float = 0.0,
343
- subset_features: int = 0,
344
- spatial_samples: int = 0,
345
- parameter_overrides: list[str] = [],
346
- resolutions: dict = {},
347
- mode: str = "Static",
348
- ) -> None:
349
- super().__init__()
350
- if engine != "elastix":
351
- raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
352
- self._engine = ElastixEngine(
353
- parameter_maps,
354
- max_iterations,
355
- final_grid_spacing,
356
- subset_features,
357
- spatial_samples,
358
- parameter_overrides,
359
- resolutions,
360
- mode,
361
- )
362
-
363
- def forward(
364
- self,
365
- fixed: torch.Tensor,
366
- moving: torch.Tensor,
367
- fixed_mask: torch.Tensor,
368
- moving_mask: torch.Tensor,
369
- attributes: list[list[Attribute]],
370
- ) -> torch.Tensor:
371
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
372
- # batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
373
- # the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
374
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
375
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
376
- combined = []
377
- for b in range(fixed.shape[0]):
378
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
379
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
380
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
381
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
382
- moved_np, dvf_np = self._engine.register(
383
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
384
- )
385
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
- return torch.stack(combined, dim=0).to(fixed.device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CBCT_CT_HeadNeck/install.py DELETED
@@ -1,325 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- import argparse
18
- import platform
19
- import re
20
- import shutil
21
- import stat
22
- import subprocess # nosec B404
23
- import zipfile
24
- from pathlib import Path
25
-
26
- import requests
27
- from tqdm import tqdm
28
-
29
- # -----------------------------------------------------------------------------
30
- # Elastix + IMPACT binary assets hosted on GitHub Releases.
31
- #
32
- # Key format: (OS, ARCH, FLAVOR)
33
- # - OS : platform.system() -> "Linux", "Windows", "Darwin"
34
- # - ARCH : normalized architecture -> "x86_64"
35
- # - FLAVOR : "cpu" or "cu128"
36
- #
37
- # CPU assets are standalone (LibTorch CPU bundled).
38
- # CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
39
- # -----------------------------------------------------------------------------
40
- ELX_ASSET_TEMPLATE = {
41
- ("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
42
- ("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
43
- ("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
44
- ("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
45
- ("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
46
- }
47
-
48
- # -----------------------------------------------------------------------------
49
- # Official LibTorch downloads (PyTorch).
50
- #
51
- # IMPORTANT:
52
- # - Version MUST match the one used at build time (ABI compatibility).
53
- # - Using "shared-with-deps" ensures CUDA runtime libraries are included
54
- # (except the NVIDIA driver, which must be installed system-wide).
55
- # -----------------------------------------------------------------------------
56
-
57
- LIBTORCH_URL = {
58
- (
59
- "Linux",
60
- "x86_64",
61
- "cpu",
62
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
63
- (
64
- "Windows",
65
- "x86_64",
66
- "cpu",
67
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
68
- (
69
- "Darwin",
70
- "arm64",
71
- "cpu",
72
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
73
- (
74
- "Linux",
75
- "x86_64",
76
- "cu128",
77
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
78
- (
79
- "Windows",
80
- "x86_64",
81
- "cu128",
82
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
83
- }
84
-
85
- # -----------------------------------------------------------------------------
86
- # Minimum NVIDIA driver versions required for CUDA 12.8.
87
- #
88
- # The CUDA Toolkit itself is NOT required.
89
- # Only a sufficiently recent NVIDIA driver must be installed.
90
- # -----------------------------------------------------------------------------
91
- CUDA128_MIN_DRIVER_LINUX = (570, 26)
92
- CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
93
-
94
- GITHUB_OWNER = "vboussot"
95
- GITHUB_REPO = "ImpactElastix"
96
- GITHUB_TAG = "1.0.0"
97
-
98
-
99
- DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
100
-
101
-
102
- def run_cmd(cmd: list[str]) -> str:
103
- return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
104
-
105
-
106
- def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
107
- """
108
- Detect presence of an NVIDIA GPU and extract the driver version.
109
- Returns:
110
- (True, (major, minor)) if detected
111
- (False, None) if nvidia-smi is not available
112
- """
113
- try:
114
- nvidia_smi = shutil.which("nvidia-smi")
115
- if nvidia_smi is None:
116
- raise RuntimeError("nvidia-smi not found in PATH")
117
-
118
- out = (
119
- subprocess.check_output(
120
- [nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
121
- ) # nosec B603
122
- .decode("utf-8")
123
- .strip()
124
- )
125
- except Exception:
126
- return (False, None)
127
-
128
- # Exemple: "575.64"
129
- m = re.match(r"([0-9]+)\.([0-9]+)", out)
130
- if not m:
131
- return (True, None)
132
-
133
- return (True, (int(m.group(1)), int(m.group(2))))
134
-
135
-
136
- def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
137
- """
138
- Check whether the detected NVIDIA driver satisfies the minimum
139
- requirement for CUDA 12.8 on the given operating system.
140
- """
141
- if drv is None:
142
- return False
143
- if os_name == "Linux":
144
- return drv >= CUDA128_MIN_DRIVER_LINUX
145
- if os_name == "Windows":
146
- return drv >= CUDA128_MIN_DRIVER_WINDOWS
147
- return False
148
-
149
-
150
- def normalize_arch(machine: str) -> str:
151
- """
152
- Normalize platform.machine() output across operating systems.
153
-
154
- Examples:
155
- AMD64 -> x86_64
156
- aarch64 -> arm64
157
- """
158
- m = machine.lower()
159
- if m in ("x86_64", "amd64"):
160
- return "x86_64"
161
- if m in ("aarch64", "arm64"):
162
- return "arm64"
163
- return machine
164
-
165
-
166
- def download_file(url: str, dst: Path) -> None:
167
- """
168
- Download a file from the given URL with a progress indicator.
169
- """
170
- dst.parent.mkdir(parents=True, exist_ok=True)
171
- print(f"Downloading: {url}", flush=True)
172
- print(f" to: {dst}", flush=True)
173
-
174
- try:
175
- with requests.get(url, stream=True, timeout=60) as r:
176
- r.raise_for_status()
177
- total = int(r.headers.get("content-length", 0))
178
- with open(dst, "wb") as f:
179
- with tqdm(
180
- total=total,
181
- unit="B",
182
- unit_scale=True,
183
- desc=f"Downloading {dst.name}",
184
- ) as pbar:
185
- for chunk in r.iter_content(chunk_size=8192):
186
- f.write(chunk)
187
- pbar.update(len(chunk))
188
- except Exception as e:
189
- raise e
190
-
191
-
192
- def extract_archive(archive: Path, dst_dir: Path) -> None:
193
- """
194
- Extract a ZIP archive to the destination directory.
195
- """
196
- dst_dir.mkdir(parents=True, exist_ok=True)
197
- print(f"Extracting: {archive} -> {dst_dir}", flush=True)
198
- with zipfile.ZipFile(archive, "r") as z:
199
- z.extractall(dst_dir)
200
- archive.unlink()
201
-
202
-
203
- def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
204
- os_name = platform.system()
205
- arch = normalize_arch(platform.machine())
206
- has_nvidia, drv = detect_nvidia_driver()
207
-
208
- if os_name not in ("Linux", "Windows", "Darwin"):
209
- raise NameError(f"Unsupported OS: {os_name}")
210
-
211
- if arch not in ("x86_64", "arm64"):
212
- raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
213
-
214
- flavor = "cpu"
215
- if force_cuda:
216
- if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
217
- raise NameError(
218
- "CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
219
- )
220
-
221
- flavor = "cu128"
222
- elif not force_cpu:
223
- if has_nvidia and driver_ok_for_cuda(os_name, drv):
224
- flavor = "cu128"
225
- else:
226
- flavor = "cpu"
227
-
228
- print(f"System: {os_name} {arch}", flush=True)
229
- print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
230
- print(f"Selected flavor: {flavor}", flush=True)
231
-
232
- key = (os_name, arch, flavor)
233
- if key not in ELX_ASSET_TEMPLATE:
234
- raise NameError(f"No elastix asset configured for {key}")
235
- if flavor != "cpu" and key not in LIBTORCH_URL:
236
- raise NameError(f"No libtorch url configured for {key}")
237
-
238
- install_path = install_path.resolve()
239
- install_path.mkdir(parents=True, exist_ok=True)
240
-
241
- elx_asset = ELX_ASSET_TEMPLATE[key]
242
- elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
243
- elx_archive = install_path / elx_asset
244
- download_file(elx_url, elx_archive)
245
- extract_archive(elx_archive, install_path)
246
-
247
- # -------------------------------------------------------------------------
248
- # ZIP archives may drop executable permissions.
249
- # Ensure elastix and transformix are executable on Unix platforms.
250
- # -------------------------------------------------------------------------
251
- if os_name in ("Linux", "Darwin"):
252
- for exe in ("elastix", "transformix"):
253
- p = install_path / "bin" / exe
254
- if p.exists():
255
- p.chmod(p.stat().st_mode | stat.S_IEXEC)
256
-
257
- # ---------------------------------------------------------------------
258
- # - Download matching LibTorch 12.8 with or without CUDA 12.8
259
- # - Extract it
260
- # - Move runtime libraries to a location visible to the dynamic loader
261
- #
262
- # Linux / macOS : prefix/lib
263
- # Windows : prefix (next to elastix.exe)
264
- # ---------------------------------------------------------------------
265
- lt_url = LIBTORCH_URL[key]
266
- lt_archive = install_path / Path(lt_url).name
267
- download_file(lt_url, lt_archive)
268
- extract_archive(lt_archive, install_path)
269
- for p in (install_path / "libtorch" / "lib").iterdir():
270
- if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
271
- shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
272
-
273
- shutil.rmtree(install_path / "libtorch")
274
- if os_name == "Linux" and flavor == "cu128":
275
- shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
276
-
277
-
278
- def get_elastix_bin(install_path: Path) -> Path:
279
- return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
280
-
281
-
282
- def try_elastix(install_path: Path) -> None:
283
- try:
284
- subprocess.run(
285
- [str(get_elastix_bin(install_path)), "-h"],
286
- capture_output=True,
287
- text=True,
288
- check=True,
289
- ) # nosec B603
290
- except subprocess.CalledProcessError as e:
291
- msg = "Elastix execution failed.\n\n"
292
-
293
- msg += f"Command:\n{' '.join(e.cmd)}\n"
294
- msg += f"Return code: {e.returncode}\n\n"
295
-
296
- if e.stderr:
297
- msg += "Error output:\n"
298
- msg += e.stderr.strip()
299
- raise NameError(msg)
300
-
301
- except OSError as e:
302
- msg = (
303
- "Elastix could not be started.\n\n"
304
- "This is usually caused by missing shared libraries "
305
- "(e.g. LibTorch or CUDA runtime).\n\n"
306
- f"System error:\n{str(e)}"
307
- )
308
-
309
- raise NameError(msg)
310
-
311
-
312
- def main() -> None:
313
- ap = argparse.ArgumentParser()
314
- ap.add_argument(
315
- "--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
316
- )
317
- group = ap.add_mutually_exclusive_group()
318
- group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
319
- group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
320
- args = vars(ap.parse_args())
321
- install_elastix_impact(**args)
322
-
323
-
324
- if __name__ == "__main__":
325
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CBCT_CT_HeadNeck/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ impact_reg_konfai>=1.6.1
CBCT_CT_MRSeg/Model.py DELETED
@@ -1,301 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
18
-
19
- ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
20
- 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
21
- the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
22
- the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
23
- subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
24
-
25
- A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
26
- ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
27
-
28
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
29
- (``get_origin``); PEP 563 stringized annotations break arg resolution.
30
- """
31
-
32
- import json
33
- import os
34
- import re
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Annotated, Literal
38
-
39
- import torch
40
- from huggingface_hub import hf_hub_download
41
- from konfai.network import network
42
- from konfai.utils.config import Choices, Range
43
-
44
- # IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
45
- # A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
46
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
47
- # iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
48
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
49
-
50
- # ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
51
- # Static anyway; in Jacobian we clamp ``l`` to this plateau.
52
- _FOV_RAMP_MAX_LAYER = 6
53
-
54
-
55
- def registry_choices() -> list[str]:
56
- """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
57
- (offline-first). A user may still point ``ref`` at a local model."""
58
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
59
- return [f"{repo}:{key}" for key in load_models_registry()]
60
-
61
-
62
- def _num(x: object) -> str:
63
- """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
64
- return "%g" % float(x)
65
-
66
-
67
- @dataclass
68
- class ModelSpec:
69
- """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
70
- rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
71
- (``models.json``) keyed by ``ref`` — never tuned."""
72
-
73
- ref: Annotated[str, Choices(registry_choices)]
74
- voxel_size: list[float] = field(default_factory=list)
75
- layers_weight: list[float] = field(default_factory=lambda: [1.0])
76
- subset_features: Annotated[int, Range(0, 1000)] = 0
77
- pca: Annotated[int, Range(0, 100)] = 0
78
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
79
- layers_mask: str = ""
80
-
81
-
82
- @dataclass
83
- class ResolutionSpec:
84
- """One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
85
-
86
- max_iterations: Annotated[int, Range(1, 100000)]
87
- models: dict[str, ModelSpec]
88
-
89
-
90
- def _sorted_specs(mapping: dict) -> list:
91
- """dict keyed by string indices ('0','1',...) -> values in numeric order."""
92
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
93
-
94
-
95
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
96
- """Load models.json (the fixed params per model) from the model repo on Hugging Face.
97
-
98
- The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
99
- dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
100
- """
101
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
102
- if local:
103
- path = Path(local)
104
- elif ":" in ref:
105
- repo, filename = ref.split(":", 1)
106
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
107
- else:
108
- raise ValueError(
109
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
110
- f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
111
- )
112
- return json.loads(path.read_text(encoding="utf-8"))
113
-
114
-
115
- def _model_key(ref: str) -> str:
116
- """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
117
- return ref.split(":", 1)[1] if ":" in ref else ref
118
-
119
-
120
- def _deepest_active_layer(layers_mask: str) -> int:
121
- """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
122
-
123
- A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
124
- == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
125
- receptive field, so the FOV is governed by the rightmost ``'1'``.
126
- """
127
- mask = layers_mask.strip().strip('"')
128
- active = [i for i, char in enumerate(mask) if char == "1"]
129
- if not active:
130
- raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
131
- return max(active)
132
-
133
-
134
- def _fov_value(fov: dict, layers_mask: str) -> int:
135
- """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
136
-
137
- Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
138
- ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
139
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
140
- to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
141
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
142
- ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
143
- An explicit ``value`` in the spec is honoured as a precomputed shortcut.
144
- """
145
- formula = str(fov.get("formula", "")).strip()
146
- key = re.sub(r"\s+", "", formula).lower()
147
- if key.isdigit():
148
- return int(key)
149
- if key == "2*r*d+1":
150
- return 2 * int(fov["r"]) * int(fov["d"]) + 1
151
- if key == "2^l+3":
152
- return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
153
- if "global" in key:
154
- raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
155
- if fov.get("value") is not None:
156
- return int(fov["value"])
157
- raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
158
-
159
-
160
- def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
161
- """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
162
- image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
163
- e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
164
- dim = int(entry.get("dimension", 3))
165
- if mode.strip().strip('"').lower() != "jacobian":
166
- return " ".join(["0"] * dim)
167
- fov = _fov_value(entry.get("fov", {}), layers_mask)
168
- return " ".join([str(fov)] * dim)
169
-
170
-
171
- def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
172
- """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
173
-
174
- Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
175
- ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
176
- is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
177
- from the registry formula and the cell's ``layers_mask``.
178
- """
179
- res = _sorted_specs(resolutions)
180
- n = len(res)
181
- mode_clean = mode.strip().strip('"') or "Static"
182
-
183
- impact: list[str] = []
184
- for k, r in enumerate(res):
185
- models = _sorted_specs(r.models)
186
- entries = [registry[_model_key(m.ref)] for m in models]
187
-
188
- def row(stem: str, values: list[str]) -> None:
189
- impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
190
-
191
- # From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
192
- # model FOV); everything else is a per-model knob taken straight from the cell.
193
- row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
194
- row("Dimension", [e["dimension"] for e in entries])
195
- row("NumberOfChannels", [e["numberofchannels"] for e in entries])
196
- row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
197
- row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
198
- row("LayersMask", [f'"{m.layers_mask}"' for m in models])
199
- row("SubsetFeatures", [str(m.subset_features) for m in models])
200
- row("PCA", [str(m.pca) for m in models])
201
- row("Distance", [f'"{m.distance}"' for m in models])
202
- row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
203
- impact.append("") # blank line between resolutions, mirroring the reference maps
204
-
205
- # The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
206
- # (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
207
- lines = template_text.splitlines()
208
- indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
209
- block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
210
- block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
211
-
212
- out: list[str] = []
213
- for i, (m, line) in enumerate(indexed):
214
- key = m.group(1) if m else None
215
- if block_lo <= i <= block_hi:
216
- if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
217
- out.extend(impact[:-1])
218
- elif key == "MaximumNumberOfIterations":
219
- out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
220
- elif key == "NumberOfResolutions":
221
- out.append(f"(NumberOfResolutions {n})")
222
- elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
223
- out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
224
- elif key == "ImpactMode":
225
- out.append(f'(ImpactMode "{mode_clean}")')
226
- else:
227
- out.append(line)
228
- return "\n".join(out)
229
-
230
-
231
- class ChannelSelect(torch.nn.Module):
232
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
233
-
234
- def __init__(self, start: int, stop: int) -> None:
235
- super().__init__()
236
- self._start = start
237
- self._stop = stop
238
-
239
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
240
- return tensor[:, self._start : self._stop]
241
-
242
-
243
- class RegistrationNet(network.Network):
244
- """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
245
- moving mask = 3; masks restrict the metric, whole-image = no restriction).
246
-
247
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
248
- (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
249
- ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
250
- ``same_as_group: Volume_0:Fixed``.
251
- """
252
-
253
- def __init__(
254
- self,
255
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
256
- schedulers: dict[str, network.LRSchedulersLoader] = {
257
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
258
- },
259
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
260
- engine: str = "elastix",
261
- parameter_maps: list[str] = [],
262
- max_iterations: Annotated[int, Range(0, 100000)] = 0,
263
- final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
264
- subset_features: Annotated[int, Range(0, 1000)] = 0,
265
- spatial_samples: Annotated[int, Range(0, 100000)] = 0,
266
- parameter_overrides: list[str] = [],
267
- resolutions: dict[str, ResolutionSpec] = {},
268
- mode: Literal["Static", "Jacobian"] = "Static",
269
- ) -> None:
270
- # The registration is fully described by ``resolutions`` (config = source of truth): each resolution
271
- # lists its self-configured models; the download list is derived from the cells. Global knobs override
272
- # the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
273
- # NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
274
- # preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
275
- from elastix_engine import ElastixRegistration
276
-
277
- super().__init__(
278
- in_channels=1,
279
- optimizer=optimizer,
280
- schedulers=schedulers,
281
- outputs_criterions=outputs_criterions,
282
- dim=3,
283
- )
284
- self.add_module(
285
- "Registration",
286
- ElastixRegistration(
287
- engine,
288
- parameter_maps,
289
- max_iterations,
290
- final_grid_spacing,
291
- subset_features,
292
- spatial_samples,
293
- parameter_overrides,
294
- resolutions,
295
- mode,
296
- ),
297
- in_branch=[0, 1, 2, 3],
298
- out_branch=["registration"],
299
- )
300
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
301
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CBCT_CT_MRSeg/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.elastix:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
CBCT_CT_MRSeg/elastix_engine.py DELETED
@@ -1,386 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Elastix-IMPACT runtime for the registration bundle.
18
-
19
- ``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
20
- parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
21
- ``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
22
- images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
23
- """
24
-
25
- import os
26
- import re
27
- import shutil
28
- import subprocess # nosec B404
29
- import tempfile
30
- from pathlib import Path
31
-
32
- import numpy as np
33
- import SimpleITK as sitk
34
- import torch
35
- import tqdm
36
- from huggingface_hub import hf_hub_download
37
- from install import get_elastix_bin, install_elastix_impact, try_elastix
38
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
39
-
40
- from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
41
-
42
- # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
43
- # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
44
- ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
45
-
46
-
47
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
48
- """True only for a mask that actually restricts the metric region — some voxels in, some out. An
49
- absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
50
- is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
51
- the whole image) instead of paying for a mask that restricts nothing."""
52
- if mask is None:
53
- return False
54
- arr = sitk.GetArrayViewFromImage(mask)
55
- return bool((arr > 0).any()) and bool((arr == 0).any())
56
-
57
-
58
- class ElastixEngine:
59
- """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
60
-
61
- NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
62
- NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
63
- """
64
-
65
- def __init__(
66
- self,
67
- parameter_maps: list[str],
68
- max_iterations: int = 0,
69
- final_grid_spacing: float = 0.0,
70
- subset_features: int = 0,
71
- spatial_samples: int = 0,
72
- parameter_overrides: list[str] = [],
73
- resolutions: dict = {},
74
- mode: str = "Static",
75
- ) -> None:
76
- self._bundle_dir = Path(__file__).resolve().parent
77
- self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
78
- self._max_iterations = max_iterations
79
- self._final_grid_spacing = final_grid_spacing
80
- self._subset_features = subset_features
81
- self._spatial_samples = spatial_samples
82
- self._parameter_overrides = list(parameter_overrides)
83
- # ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
84
- # samples random FOV-sized patches each iteration. One mode per preset.
85
- self._mode = mode
86
- # Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
87
- # intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
88
- self._resolutions = resolutions
89
- self._registry = load_models_registry() if resolutions else {}
90
- # Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
91
- models: list[str] = []
92
- for res in _sorted_specs(resolutions):
93
- for model in _sorted_specs(res.models):
94
- if model.ref not in models:
95
- models.append(model.ref)
96
- self._models = models
97
- # ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
98
- self._iterations = self._total_iterations()
99
- self._elastix_bin = self._ensure_binary()
100
- self._local_models = self._download_models()
101
-
102
- def _total_iterations(self) -> int:
103
- """Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
104
- if self._resolutions:
105
- return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
106
- total = 0
107
- for src in self._parameter_maps:
108
- match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
109
- if match:
110
- total += sum(int(token) for token in match.group(1).split())
111
- return total
112
-
113
- def _ensure_binary(self) -> Path:
114
- # Optional override: point at an existing elastix-IMPACT install (skips the download).
115
- override = os.environ.get("KONFAI_ELASTIX_DIR", "")
116
- if override:
117
- try_elastix(Path(override))
118
- return get_elastix_bin(Path(override)).resolve()
119
- ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
120
- try:
121
- try_elastix(ELASTIX_CACHE)
122
- except Exception:
123
- install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
124
- try_elastix(ELASTIX_CACHE)
125
- return get_elastix_bin(ELASTIX_CACHE).resolve()
126
-
127
- def _download_models(self) -> list[tuple[str, Path]]:
128
- """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
129
- models = []
130
- for ref in self._models:
131
- repo, filename = ref.split(":", 1)
132
- local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
- models.append((filename, local))
134
- return models
135
-
136
- def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
137
- """The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
138
-
139
- ``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
140
- **each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
141
- ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
142
- knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
143
- ``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
144
- """
145
- per_token: dict[str, str] = {}
146
- if not global_only and self._max_iterations > 0:
147
- per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
148
- if self._final_grid_spacing > 0:
149
- per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
150
- if not global_only and self._subset_features > 0:
151
- per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
152
- if self._spatial_samples > 0:
153
- per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
154
- exact: list[tuple[str, str]] = []
155
- for entry in self._parameter_overrides:
156
- key, sep, value = entry.partition("=")
157
- if not sep or not key.strip():
158
- raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
159
- exact.append((key.strip(), value.strip()))
160
- return per_token, exact
161
-
162
- @staticmethod
163
- def _apply_map_overrides(
164
- text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
165
- ) -> str:
166
- """Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
167
- of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
168
- """
169
- entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
170
- requested = set(per_token) | {key for key, _ in exact}
171
- seen: set[str] = set()
172
- lines = []
173
- for line in text.splitlines():
174
- match = entry_pattern.match(line)
175
- if match:
176
- indent, key, values = match.group(1), match.group(2), match.group(3)
177
- if key == "ImpactGPU":
178
- line = f"{indent}(ImpactGPU {device_index})"
179
- else:
180
- exact_value = next((value for k, value in exact if k == key), None)
181
- if exact_value is not None:
182
- seen.add(key)
183
- line = f"{indent}({key} {exact_value})"
184
- else:
185
- token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
186
- if token_key in per_token:
187
- seen.add(token_key)
188
- replaced = " ".join(per_token[token_key] for _ in values.split())
189
- line = f"{indent}({key} {replaced})"
190
- lines.append(line)
191
- # Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
192
- # surface it (e.g. final_grid_spacing on a rigid-only preset).
193
- for key in sorted(requested - seen):
194
- print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
195
- return "\n".join(lines)
196
-
197
- def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
198
- """Stage the parameter maps into ``work``.
199
-
200
- Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
201
- knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
202
- applies every per-token / exact override. Both set the ImpactGPU device.
203
- """
204
- staged = []
205
- for src in self._parameter_maps:
206
- if self._resolutions:
207
- text = generate_impact_parameter_map(
208
- src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
209
- )
210
- per_token, exact = self._parameter_map_overrides(global_only=True)
211
- else:
212
- text = src.read_text(encoding="utf-8")
213
- per_token, exact = self._parameter_map_overrides()
214
- text = self._apply_map_overrides(text, per_token, exact, device_index)
215
- dst = work / src.name
216
- dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
217
- staged.append(dst)
218
- return staged
219
-
220
- def register(
221
- self,
222
- fixed: sitk.Image,
223
- moving: sitk.Image,
224
- device_index: int,
225
- fixed_mask: sitk.Image | None = None,
226
- moving_mask: sitk.Image | None = None,
227
- ) -> tuple[np.ndarray, np.ndarray]:
228
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
229
-
230
- Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
231
- ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
232
- """
233
- work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
234
- try:
235
- fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
236
- sitk.WriteImage(fixed, str(fixed_path))
237
- sitk.WriteImage(moving, str(moving_path))
238
-
239
- # Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
240
- # "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
241
- for rel_name, model_path in self._local_models:
242
- dst = work / rel_name
243
- dst.parent.mkdir(parents=True, exist_ok=True)
244
- if not dst.exists():
245
- dst.symlink_to(model_path)
246
-
247
- args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
248
- for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
249
- if _is_partial_mask(mask):
250
- mask_path = work / name
251
- sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
252
- args += [flag, str(mask_path)]
253
- args += ["-out", str(work)]
254
- for pmap in self._stage_parameter_maps(work, device_index):
255
- args += ["-p", str(pmap)]
256
-
257
- # Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
258
- # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
259
- env = os.environ.copy()
260
- extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
261
- env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
262
- proc = subprocess.Popen( # nosec B603
263
- args,
264
- cwd=str(work),
265
- stdout=subprocess.PIPE,
266
- stderr=subprocess.STDOUT,
267
- text=True,
268
- bufsize=1,
269
- env=env,
270
- )
271
- # Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
272
- # progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
273
- # open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
274
- captured: list[str] = []
275
- iteration_line = re.compile(r"^\d+\s")
276
- budget = None if self._max_iterations > 0 else (self._iterations or None)
277
- progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
278
- assert proc.stdout is not None
279
- resolution = 0
280
- for line in proc.stdout:
281
- captured.append(line)
282
- stripped = line.strip()
283
- if stripped.startswith("Resolution:"):
284
- try:
285
- resolution = int(stripped.split(":", 1)[1])
286
- except ValueError:
287
- pass
288
- elif iteration_line.match(line):
289
- progress.update(1)
290
- columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
291
- if len(columns) > 1:
292
- try:
293
- progress.set_description(
294
- f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
295
- )
296
- except ValueError:
297
- pass
298
- progress.close()
299
- returncode = proc.wait()
300
- if returncode != 0:
301
- raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
302
-
303
- transforms = sorted(
304
- work.glob("TransformParameters.*-Composite.itk.txt"),
305
- key=lambda p: int(p.name.split(".")[1].split("-")[0]),
306
- )
307
- if not transforms:
308
- raise FileNotFoundError("elastix produced no composite transform file.")
309
- transform = sitk.ReadTransform(str(transforms[-1]))
310
-
311
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
312
- dvf = sitk.TransformToDisplacementField(
313
- transform,
314
- sitk.sitkVectorFloat64,
315
- fixed.GetSize(),
316
- fixed.GetOrigin(),
317
- fixed.GetSpacing(),
318
- fixed.GetDirection(),
319
- )
320
- moved_np, _ = image_to_data(moved)
321
- dvf_np, _ = image_to_data(dvf)
322
- return moved_np, dvf_np
323
- finally:
324
- shutil.rmtree(work, ignore_errors=True)
325
-
326
-
327
- class ElastixRegistration(torch.nn.Module):
328
- """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
329
-
330
- ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
331
- ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
332
- the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
333
- """
334
-
335
- accepts_attributes = True
336
-
337
- def __init__(
338
- self,
339
- engine: str,
340
- parameter_maps: list[str],
341
- max_iterations: int = 0,
342
- final_grid_spacing: float = 0.0,
343
- subset_features: int = 0,
344
- spatial_samples: int = 0,
345
- parameter_overrides: list[str] = [],
346
- resolutions: dict = {},
347
- mode: str = "Static",
348
- ) -> None:
349
- super().__init__()
350
- if engine != "elastix":
351
- raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
352
- self._engine = ElastixEngine(
353
- parameter_maps,
354
- max_iterations,
355
- final_grid_spacing,
356
- subset_features,
357
- spatial_samples,
358
- parameter_overrides,
359
- resolutions,
360
- mode,
361
- )
362
-
363
- def forward(
364
- self,
365
- fixed: torch.Tensor,
366
- moving: torch.Tensor,
367
- fixed_mask: torch.Tensor,
368
- moving_mask: torch.Tensor,
369
- attributes: list[list[Attribute]],
370
- ) -> torch.Tensor:
371
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
372
- # batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
373
- # the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
374
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
375
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
376
- combined = []
377
- for b in range(fixed.shape[0]):
378
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
379
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
380
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
381
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
382
- moved_np, dvf_np = self._engine.register(
383
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
384
- )
385
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
- return torch.stack(combined, dim=0).to(fixed.device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CBCT_CT_MRSeg/install.py DELETED
@@ -1,325 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- import argparse
18
- import platform
19
- import re
20
- import shutil
21
- import stat
22
- import subprocess # nosec B404
23
- import zipfile
24
- from pathlib import Path
25
-
26
- import requests
27
- from tqdm import tqdm
28
-
29
- # -----------------------------------------------------------------------------
30
- # Elastix + IMPACT binary assets hosted on GitHub Releases.
31
- #
32
- # Key format: (OS, ARCH, FLAVOR)
33
- # - OS : platform.system() -> "Linux", "Windows", "Darwin"
34
- # - ARCH : normalized architecture -> "x86_64"
35
- # - FLAVOR : "cpu" or "cu128"
36
- #
37
- # CPU assets are standalone (LibTorch CPU bundled).
38
- # CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
39
- # -----------------------------------------------------------------------------
40
- ELX_ASSET_TEMPLATE = {
41
- ("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
42
- ("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
43
- ("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
44
- ("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
45
- ("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
46
- }
47
-
48
- # -----------------------------------------------------------------------------
49
- # Official LibTorch downloads (PyTorch).
50
- #
51
- # IMPORTANT:
52
- # - Version MUST match the one used at build time (ABI compatibility).
53
- # - Using "shared-with-deps" ensures CUDA runtime libraries are included
54
- # (except the NVIDIA driver, which must be installed system-wide).
55
- # -----------------------------------------------------------------------------
56
-
57
- LIBTORCH_URL = {
58
- (
59
- "Linux",
60
- "x86_64",
61
- "cpu",
62
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
63
- (
64
- "Windows",
65
- "x86_64",
66
- "cpu",
67
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
68
- (
69
- "Darwin",
70
- "arm64",
71
- "cpu",
72
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
73
- (
74
- "Linux",
75
- "x86_64",
76
- "cu128",
77
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
78
- (
79
- "Windows",
80
- "x86_64",
81
- "cu128",
82
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
83
- }
84
-
85
- # -----------------------------------------------------------------------------
86
- # Minimum NVIDIA driver versions required for CUDA 12.8.
87
- #
88
- # The CUDA Toolkit itself is NOT required.
89
- # Only a sufficiently recent NVIDIA driver must be installed.
90
- # -----------------------------------------------------------------------------
91
- CUDA128_MIN_DRIVER_LINUX = (570, 26)
92
- CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
93
-
94
- GITHUB_OWNER = "vboussot"
95
- GITHUB_REPO = "ImpactElastix"
96
- GITHUB_TAG = "1.0.0"
97
-
98
-
99
- DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
100
-
101
-
102
- def run_cmd(cmd: list[str]) -> str:
103
- return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
104
-
105
-
106
- def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
107
- """
108
- Detect presence of an NVIDIA GPU and extract the driver version.
109
- Returns:
110
- (True, (major, minor)) if detected
111
- (False, None) if nvidia-smi is not available
112
- """
113
- try:
114
- nvidia_smi = shutil.which("nvidia-smi")
115
- if nvidia_smi is None:
116
- raise RuntimeError("nvidia-smi not found in PATH")
117
-
118
- out = (
119
- subprocess.check_output(
120
- [nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
121
- ) # nosec B603
122
- .decode("utf-8")
123
- .strip()
124
- )
125
- except Exception:
126
- return (False, None)
127
-
128
- # Exemple: "575.64"
129
- m = re.match(r"([0-9]+)\.([0-9]+)", out)
130
- if not m:
131
- return (True, None)
132
-
133
- return (True, (int(m.group(1)), int(m.group(2))))
134
-
135
-
136
- def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
137
- """
138
- Check whether the detected NVIDIA driver satisfies the minimum
139
- requirement for CUDA 12.8 on the given operating system.
140
- """
141
- if drv is None:
142
- return False
143
- if os_name == "Linux":
144
- return drv >= CUDA128_MIN_DRIVER_LINUX
145
- if os_name == "Windows":
146
- return drv >= CUDA128_MIN_DRIVER_WINDOWS
147
- return False
148
-
149
-
150
- def normalize_arch(machine: str) -> str:
151
- """
152
- Normalize platform.machine() output across operating systems.
153
-
154
- Examples:
155
- AMD64 -> x86_64
156
- aarch64 -> arm64
157
- """
158
- m = machine.lower()
159
- if m in ("x86_64", "amd64"):
160
- return "x86_64"
161
- if m in ("aarch64", "arm64"):
162
- return "arm64"
163
- return machine
164
-
165
-
166
- def download_file(url: str, dst: Path) -> None:
167
- """
168
- Download a file from the given URL with a progress indicator.
169
- """
170
- dst.parent.mkdir(parents=True, exist_ok=True)
171
- print(f"Downloading: {url}", flush=True)
172
- print(f" to: {dst}", flush=True)
173
-
174
- try:
175
- with requests.get(url, stream=True, timeout=60) as r:
176
- r.raise_for_status()
177
- total = int(r.headers.get("content-length", 0))
178
- with open(dst, "wb") as f:
179
- with tqdm(
180
- total=total,
181
- unit="B",
182
- unit_scale=True,
183
- desc=f"Downloading {dst.name}",
184
- ) as pbar:
185
- for chunk in r.iter_content(chunk_size=8192):
186
- f.write(chunk)
187
- pbar.update(len(chunk))
188
- except Exception as e:
189
- raise e
190
-
191
-
192
- def extract_archive(archive: Path, dst_dir: Path) -> None:
193
- """
194
- Extract a ZIP archive to the destination directory.
195
- """
196
- dst_dir.mkdir(parents=True, exist_ok=True)
197
- print(f"Extracting: {archive} -> {dst_dir}", flush=True)
198
- with zipfile.ZipFile(archive, "r") as z:
199
- z.extractall(dst_dir)
200
- archive.unlink()
201
-
202
-
203
- def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
204
- os_name = platform.system()
205
- arch = normalize_arch(platform.machine())
206
- has_nvidia, drv = detect_nvidia_driver()
207
-
208
- if os_name not in ("Linux", "Windows", "Darwin"):
209
- raise NameError(f"Unsupported OS: {os_name}")
210
-
211
- if arch not in ("x86_64", "arm64"):
212
- raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
213
-
214
- flavor = "cpu"
215
- if force_cuda:
216
- if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
217
- raise NameError(
218
- "CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
219
- )
220
-
221
- flavor = "cu128"
222
- elif not force_cpu:
223
- if has_nvidia and driver_ok_for_cuda(os_name, drv):
224
- flavor = "cu128"
225
- else:
226
- flavor = "cpu"
227
-
228
- print(f"System: {os_name} {arch}", flush=True)
229
- print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
230
- print(f"Selected flavor: {flavor}", flush=True)
231
-
232
- key = (os_name, arch, flavor)
233
- if key not in ELX_ASSET_TEMPLATE:
234
- raise NameError(f"No elastix asset configured for {key}")
235
- if flavor != "cpu" and key not in LIBTORCH_URL:
236
- raise NameError(f"No libtorch url configured for {key}")
237
-
238
- install_path = install_path.resolve()
239
- install_path.mkdir(parents=True, exist_ok=True)
240
-
241
- elx_asset = ELX_ASSET_TEMPLATE[key]
242
- elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
243
- elx_archive = install_path / elx_asset
244
- download_file(elx_url, elx_archive)
245
- extract_archive(elx_archive, install_path)
246
-
247
- # -------------------------------------------------------------------------
248
- # ZIP archives may drop executable permissions.
249
- # Ensure elastix and transformix are executable on Unix platforms.
250
- # -------------------------------------------------------------------------
251
- if os_name in ("Linux", "Darwin"):
252
- for exe in ("elastix", "transformix"):
253
- p = install_path / "bin" / exe
254
- if p.exists():
255
- p.chmod(p.stat().st_mode | stat.S_IEXEC)
256
-
257
- # ---------------------------------------------------------------------
258
- # - Download matching LibTorch 12.8 with or without CUDA 12.8
259
- # - Extract it
260
- # - Move runtime libraries to a location visible to the dynamic loader
261
- #
262
- # Linux / macOS : prefix/lib
263
- # Windows : prefix (next to elastix.exe)
264
- # ---------------------------------------------------------------------
265
- lt_url = LIBTORCH_URL[key]
266
- lt_archive = install_path / Path(lt_url).name
267
- download_file(lt_url, lt_archive)
268
- extract_archive(lt_archive, install_path)
269
- for p in (install_path / "libtorch" / "lib").iterdir():
270
- if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
271
- shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
272
-
273
- shutil.rmtree(install_path / "libtorch")
274
- if os_name == "Linux" and flavor == "cu128":
275
- shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
276
-
277
-
278
- def get_elastix_bin(install_path: Path) -> Path:
279
- return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
280
-
281
-
282
- def try_elastix(install_path: Path) -> None:
283
- try:
284
- subprocess.run(
285
- [str(get_elastix_bin(install_path)), "-h"],
286
- capture_output=True,
287
- text=True,
288
- check=True,
289
- ) # nosec B603
290
- except subprocess.CalledProcessError as e:
291
- msg = "Elastix execution failed.\n\n"
292
-
293
- msg += f"Command:\n{' '.join(e.cmd)}\n"
294
- msg += f"Return code: {e.returncode}\n\n"
295
-
296
- if e.stderr:
297
- msg += "Error output:\n"
298
- msg += e.stderr.strip()
299
- raise NameError(msg)
300
-
301
- except OSError as e:
302
- msg = (
303
- "Elastix could not be started.\n\n"
304
- "This is usually caused by missing shared libraries "
305
- "(e.g. LibTorch or CUDA runtime).\n\n"
306
- f"System error:\n{str(e)}"
307
- )
308
-
309
- raise NameError(msg)
310
-
311
-
312
- def main() -> None:
313
- ap = argparse.ArgumentParser()
314
- ap.add_argument(
315
- "--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
316
- )
317
- group = ap.add_mutually_exclusive_group()
318
- group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
319
- group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
320
- args = vars(ap.parse_args())
321
- install_elastix_impact(**args)
322
-
323
-
324
- if __name__ == "__main__":
325
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CBCT_CT_MRSeg/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ impact_reg_konfai>=1.6.1
ConvexAdam_Coarse/Model.py DELETED
@@ -1,549 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
18
-
19
- Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
20
- (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
21
- so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
22
- the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
23
- the elastix binary:
24
-
25
- (optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
26
- -> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
27
- -> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
28
-
29
- The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
30
- and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
31
-
32
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
33
- runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
34
- """
35
-
36
- import contextlib
37
- import json
38
- import os
39
- from dataclasses import dataclass, field
40
- from pathlib import Path
41
- from typing import Annotated, Literal
42
-
43
- import itk
44
- import numpy as np
45
- import SimpleITK as sitk
46
- import torch
47
- import tqdm
48
- from huggingface_hub import hf_hub_download
49
- from konfai.network import network
50
- from konfai.utils.config import Choices, Range
51
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
-
53
- DIM = 3
54
- # The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
55
- # medical images), not a tunable — so it's fixed here, never a config/signature parameter.
56
- NUM_CHANNELS = 1
57
-
58
- # A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__`` and ``ModelSpec``:
59
- # ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` / ``Annotated[str, Choices]`` a dropdown.
60
- # ``models`` is a dict-of-objects (one ``ModelSpec`` per feature model) — the same shape as the elastix presets,
61
- # so SlicerKonfAI renders each model as a repeatable block with a ``ref`` / ``distance`` combo box.
62
- _IMAGE_F = itk.Image[itk.F, DIM]
63
-
64
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
65
-
66
-
67
- def registry_choices() -> list[str]:
68
- """The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry the
69
- engine already fetches (offline-first). A user may still point ``ref`` at a local model path."""
70
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
71
- return [f"{repo}:{key}" for key in load_models_registry()]
72
-
73
-
74
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
75
- """Load ``models.json`` (the available feature models) from the model repo on Hugging Face. The registry is
76
- NOT bundled: ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for dev/offline; otherwise ``ref`` must be
77
- a ``repo:file`` Hugging Face reference."""
78
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
79
- if local:
80
- path = Path(local)
81
- elif ":" in ref:
82
- repo, filename = ref.split(":", 1)
83
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
84
- else:
85
- raise ValueError(
86
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (fetched from HF, not "
87
- "bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
88
- )
89
- return json.loads(path.read_text(encoding="utf-8"))
90
-
91
-
92
- def _sorted_specs(mapping: dict) -> list:
93
- """A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
94
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
95
-
96
-
97
- @dataclass
98
- class ModelSpec:
99
- """One feature model in the ConvexAdam multi-feature fusion. ``ref`` picks the model; the rest are its
100
- per-model knobs (all models are compared jointly by the IMPACT metric). Same idea as the elastix ``ModelSpec``."""
101
-
102
- ref: Annotated[str, Choices(registry_choices)]
103
- voxel_size: list[float] = field(default_factory=lambda: [3.0, 3.0, 3.0])
104
- layers_mask: str = "1" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
105
- layers_weight: float = 1.0
106
- pca: Annotated[int, Range(0, 100)] = 0
107
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
108
-
109
-
110
- @contextlib.contextmanager
111
- def _no_texpr_fuser():
112
- """Disable ONLY torch's TensorExpr (NNC) fuser for the block. The TS feature-model graphs have a direction/
113
- orientation branch whose shape ops (aten::dim / aten::size) crash the fuser's alias analysis under itk-impact
114
- ("INTERNAL ASSERT ... We don't have an op for aten::size" in FuseTensorExprs). It cannot be fixed model-side:
115
- itk-impact passes an *undefined* direction, so guarding it needs a shape op — the very fuser trigger. The
116
- modern profiling executor stays on (NOT the legacy executor); measured cost ~1% of a registration (the fuser
117
- only touches the few feature forwards, not the C++ optimisation loop). The caller's setting is restored."""
118
- prev = torch._C._jit_texpr_fuser_enabled()
119
- torch._C._jit_set_texpr_fuser_enabled(False)
120
- try:
121
- yield
122
- finally:
123
- torch._C._jit_set_texpr_fuser_enabled(prev)
124
-
125
-
126
- def _coarse_registration_type():
127
- """The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
128
- cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
129
- if cls is None:
130
- raise RuntimeError(
131
- "itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
132
- "install a build with the ConvexAdam registration filters."
133
- )
134
- return cls[_IMAGE_F, _IMAGE_F]
135
-
136
-
137
- def _fine_registration_type():
138
- """The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
139
- cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
140
- if cls is None:
141
- raise RuntimeError(
142
- "itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
143
- "install a build with the ConvexAdam registration filters."
144
- )
145
- return cls[_IMAGE_F, _IMAGE_F]
146
-
147
-
148
- def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
149
- """Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
150
- itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
151
- itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
152
- itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
153
- itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
154
- return itk_image
155
-
156
-
157
- def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
158
- """Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
159
- array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
160
- sitk_field = sitk.GetImageFromArray(array, isVector=True)
161
- sitk_field.CopyInformation(reference)
162
- return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
163
-
164
-
165
- def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
166
- """Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
167
- sitk_affine = sitk.AffineTransform(DIM)
168
- sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
169
- sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
170
- sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
171
- return sitk_affine
172
-
173
-
174
- class ConvexAdamEngine:
175
- """Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
176
-
177
- The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
178
- Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
179
- filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
180
- """
181
-
182
- def __init__(
183
- self,
184
- models: list[str],
185
- voxel_sizes: list[list[float]],
186
- overlap: int,
187
- layers_masks: list[list[bool]],
188
- mixed_precision: bool,
189
- grid_spacing: int,
190
- displacement_half_width: int,
191
- iterations: int,
192
- learning_rate: float,
193
- regularization_weight: float,
194
- grid_shrink: int,
195
- distance: list[str],
196
- layers_weight: list[float],
197
- subset_features: list[int],
198
- pca: list[int],
199
- stages: list[str],
200
- linear: bool,
201
- linear_iterations: int,
202
- seed: int,
203
- ) -> None:
204
- self._stages = stages
205
- self._model_paths = self._download_models(models)
206
- # Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
207
- # from disk in C++, so build the list once and reuse it across both stages and every case.
208
- self._configurations: "list[itk.ModelConfiguration] | None" = None
209
- self._voxel_sizes = voxel_sizes
210
- self._overlap = overlap
211
- self._layers_masks = layers_masks
212
- self._mixed_precision = mixed_precision
213
- self._grid_spacing = grid_spacing
214
- self._displacement_half_width = displacement_half_width
215
- self._iterations = iterations
216
- self._learning_rate = learning_rate
217
- self._regularization_weight = regularization_weight
218
- self._grid_shrink = grid_shrink
219
- self._distance = distance
220
- self._layers_weight = layers_weight
221
- self._subset_features = subset_features
222
- self._pca = pca
223
- self._linear = linear
224
- self._linear_iterations = linear_iterations
225
- self._seed = seed
226
-
227
- @staticmethod
228
- def _download_models(models: list[str]) -> list[str]:
229
- """Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
230
- paths = []
231
- for ref in models:
232
- repo, filename = ref.split(":", 1)
233
- paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
234
- return paths
235
-
236
- def _model_configurations(self) -> list["itk.ModelConfiguration"]:
237
- """Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
238
-
239
- Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
240
- it is built lazily and cached. The coarse/fine filters copy each configuration by value in
241
- ``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
242
- ``shared_ptr`` — so a single build is reused everywhere without any reload.
243
- """
244
- if self._configurations is None:
245
- self._configurations = [
246
- itk.ModelConfiguration(
247
- path,
248
- DIM,
249
- NUM_CHANNELS,
250
- [0, 0, 0],
251
- [float(v) for v in voxel_size],
252
- self._overlap,
253
- list(layers_mask),
254
- self._mixed_precision,
255
- )
256
- for path, voxel_size, layers_mask in zip(self._model_paths, self._voxel_sizes, self._layers_masks)
257
- ]
258
- return self._configurations
259
-
260
- def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
261
- """Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
262
- rigid = itk.VersorRigid3DTransform[itk.D].New()
263
- initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
264
- Transform=rigid, FixedImage=fixed, MovingImage=moving
265
- )
266
- initializer.MomentsOn()
267
- initializer.InitializeTransform()
268
-
269
- affine = itk.AffineTransform[itk.D, DIM].New()
270
- affine.SetCenter(rigid.GetCenter())
271
- affine.SetMatrix(rigid.GetMatrix())
272
- affine.SetOffset(rigid.GetOffset())
273
- levels = 3
274
- metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
275
- metric = metric_type.New()
276
- metric.SetNumberOfHistogramBins(32)
277
- optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
278
- optimizer.SetNumberOfIterations(self._linear_iterations)
279
- optimizer.SetLearningRate(1.0)
280
- optimizer.SetMinimumStepLength(1e-5)
281
- optimizer.SetRelaxationFactor(0.6)
282
- scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
283
- scales.SetMetric(metric)
284
- optimizer.SetScalesEstimator(scales)
285
- registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
286
- FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
287
- )
288
- registration.SetNumberOfLevels(levels)
289
- registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
290
- registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
291
- registration.InPlaceOn()
292
- registration.Update()
293
- return affine
294
-
295
- def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
296
- """ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
297
- coarse = _coarse_registration_type().New()
298
- coarse.SetFixedImage(fixed)
299
- coarse.SetMovingImage(moving)
300
- for configuration in self._model_configurations():
301
- coarse.AddModelConfiguration(configuration)
302
- coarse.SetGridSpacing(self._grid_spacing)
303
- coarse.SetDisplacementHalfWidth(self._displacement_half_width)
304
- coarse.SetDevice(device)
305
- coarse.SetSeed(self._seed)
306
- coarse.Update()
307
- field = coarse.GetOutput()
308
- field.DisconnectPipeline()
309
- return field
310
-
311
- def _fine(
312
- self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
313
- ) -> "itk.Image":
314
- """Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
315
- fine = _fine_registration_type().New()
316
- fine.SetFixedImage(fixed)
317
- fine.SetMovingImage(moving)
318
- fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
319
- for configuration in self._model_configurations():
320
- fine.AddModelConfiguration(configuration)
321
- fine.SetDistance(list(self._distance))
322
- fine.SetLayersWeight([float(v) for v in self._layers_weight])
323
- fine.SetSubsetFeatures([int(v) for v in self._subset_features])
324
- fine.SetPCA([int(v) for v in self._pca])
325
- fine.SetNumberOfIterations(self._iterations)
326
- fine.SetLearningRate(self._learning_rate)
327
- fine.SetRegularizationWeight(self._regularization_weight)
328
- fine.SetGridShrinkFactor(self._grid_shrink)
329
- fine.SetDevice(device)
330
- fine.SetSeed(self._seed)
331
-
332
- # Optional terminal progress over the Adam iterations, driven from the metric trace. ``disable=None``
333
- # auto-hides it when stderr is not a TTY (e.g. under KonfAI/Slicer, where the outer "Prediction" bar
334
- # already reports progress), so captured logs stay clean; ``leave=False`` avoids stacking one bar per
335
- # patch. The observer is best-effort — if the filter emits no IterationEvent the bar just fills at the end.
336
- progress = tqdm.tqdm(
337
- total=self._iterations or None, desc="Registration", ncols=0, leave=False, disable=None
338
- )
339
-
340
- def _update(*_: object) -> None:
341
- values = list(fine.GetMetricValuesPerIteration())
342
- progress.n = min(len(values), self._iterations)
343
- if values:
344
- progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
345
- progress.refresh()
346
-
347
- try:
348
- fine.AddObserver(itk.IterationEvent(), _update)
349
- except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
350
- pass
351
- fine.Update()
352
- progress.n = progress.total or self._iterations # show completion even if no IterationEvent fired
353
- progress.refresh()
354
- progress.close()
355
- field = fine.GetDisplacementField()
356
- field.DisconnectPipeline()
357
- return field
358
-
359
- @staticmethod
360
- def _zero_field(reference: "itk.Image") -> "itk.Image":
361
- """An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
362
- field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
363
- field.CopyInformation(reference)
364
- field.SetRegions(reference.GetLargestPossibleRegion())
365
- field.Allocate()
366
- zero = itk.Vector[itk.F, DIM]()
367
- zero.Fill(0) # itk::Vector default ctor does not zero-initialise
368
- field.FillBuffer(zero)
369
- return field
370
-
371
- def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
372
- """Run the configured coarse/fine chain; each fine warm-starts from the running field.
373
-
374
- ``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
375
- coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
376
- (the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
377
- """
378
- field: "itk.Image | None" = None
379
- for stage in self._stages:
380
- if stage == "coarse":
381
- field = self._coarse(fixed, moving, device)
382
- elif stage == "fine":
383
- field = self._fine(fixed, moving, field, device)
384
- else:
385
- raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
386
- return field
387
-
388
- def register(
389
- self,
390
- fixed: sitk.Image,
391
- moving: sitk.Image,
392
- device_index: int,
393
- fixed_mask: sitk.Image | None = None,
394
- moving_mask: sitk.Image | None = None,
395
- ) -> tuple[np.ndarray, np.ndarray]:
396
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
397
- device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
398
- fixed_itk = _sitk_to_itk(fixed)
399
- moving_itk = _sitk_to_itk(moving)
400
-
401
- # Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
402
- affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
403
- resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
404
- Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
405
- )
406
- resampler.UseReferenceImageOn()
407
- resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
408
- resampler.Update()
409
- moving_linear = resampler.GetOutput()
410
-
411
- field = self._run_stages(fixed_itk, moving_linear, device)
412
-
413
- # One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
414
- # ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
415
- # moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
416
- chain = [_itk_affine_to_sitk(affine)]
417
- if field is not None:
418
- chain.append(_itk_field_to_sitk_transform(field, fixed))
419
- composite = sitk.CompositeTransform(chain)
420
- moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
421
- dvf = sitk.TransformToDisplacementField(
422
- composite,
423
- sitk.sitkVectorFloat64,
424
- fixed.GetSize(),
425
- fixed.GetOrigin(),
426
- fixed.GetSpacing(),
427
- fixed.GetDirection(),
428
- )
429
- moved_np, _ = image_to_data(moved)
430
- dvf_np, _ = image_to_data(dvf)
431
- return moved_np, dvf_np
432
-
433
-
434
- class ConvexAdamRegistration(torch.nn.Module):
435
- """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
436
-
437
- ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
438
- the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
439
- """
440
-
441
- accepts_attributes = True
442
-
443
- def __init__(self, engine: ConvexAdamEngine) -> None:
444
- super().__init__()
445
- self._engine = engine
446
-
447
- def forward(
448
- self,
449
- fixed: torch.Tensor,
450
- moving: torch.Tensor,
451
- fixed_mask: torch.Tensor,
452
- moving_mask: torch.Tensor,
453
- attributes: list[list[Attribute]],
454
- ) -> torch.Tensor:
455
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
456
- # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
457
- # channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
458
- fixed_attrs, moving_attrs, _, _ = attributes
459
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
460
- combined = []
461
- # ConvexAdam runs a gradient-based instance optimisation (Adam over the field) inside itk-impact's
462
- # .Update(); the predictor calls forward under torch.inference_mode(), which forbids autograd. The
463
- # image tensors have already crossed to numpy/ITK here, so re-enable grad for the optimisation.
464
- with torch.inference_mode(False), torch.enable_grad(), _no_texpr_fuser():
465
- for b in range(fixed.shape[0]):
466
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
467
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
468
- moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
469
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
470
- return torch.stack(combined, dim=0).to(fixed.device)
471
-
472
-
473
- class ChannelSelect(torch.nn.Module):
474
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
475
-
476
- def __init__(self, start: int, stop: int) -> None:
477
- super().__init__()
478
- self._start = start
479
- self._stop = stop
480
-
481
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
482
- return tensor[:, self._start : self._stop]
483
-
484
-
485
- class RegistrationNet(network.Network):
486
- """Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
487
- the mask branches 2/3 are accepted but unused by this engine).
488
-
489
- Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
490
- DIM-component displacement field, in mm). Geometry is attached by the predictor via
491
- ``same_as_group: Volume_0:Fixed``.
492
- """
493
-
494
- def __init__(
495
- self,
496
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
497
- schedulers: dict[str, network.LRSchedulersLoader] = {
498
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
499
- },
500
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
501
- models: dict[str, ModelSpec] = {},
502
- overlap: Annotated[int, Range(1, 128)] = 2,
503
- mixed_precision: bool = False,
504
- grid_spacing: Annotated[int, Range(1, 512)] = 4,
505
- displacement_half_width: Annotated[int, Range(1, 512)] = 6,
506
- iterations: Annotated[int, Range(0, 100000)] = 150,
507
- learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
508
- regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
509
- grid_shrink: Annotated[int, Range(1, 128)] = 4,
510
- subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
511
- stages: list[str] = ["coarse", "fine"],
512
- linear: bool = True,
513
- linear_iterations: Annotated[int, Range(0, 100000)] = 200,
514
- seed: int = 42,
515
- ) -> None:
516
- super().__init__(
517
- in_channels=1,
518
- optimizer=optimizer,
519
- schedulers=schedulers,
520
- outputs_criterions=outputs_criterions,
521
- dim=3,
522
- )
523
- specs = _sorted_specs(models)
524
- engine = ConvexAdamEngine(
525
- [spec.ref for spec in specs],
526
- [list(spec.voxel_size) for spec in specs],
527
- overlap,
528
- [[c == "1" for c in spec.layers_mask] for spec in specs],
529
- mixed_precision,
530
- grid_spacing,
531
- displacement_half_width,
532
- iterations,
533
- learning_rate,
534
- regularization_weight,
535
- grid_shrink,
536
- [spec.distance for spec in specs],
537
- [float(spec.layers_weight) for spec in specs],
538
- subset_features,
539
- [int(spec.pca) for spec in specs],
540
- stages,
541
- linear,
542
- linear_iterations,
543
- seed,
544
- )
545
- self.add_module(
546
- "Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
547
- )
548
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
549
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ConvexAdam_Coarse/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  models:
6
  '0':
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.convexadam:RegistrationNet
4
  RegistrationNet:
5
  models:
6
  '0':
ConvexAdam_Coarse/requirements.txt CHANGED
@@ -1 +1,2 @@
1
  itk-impact
 
 
1
  itk-impact
2
+ impact_reg_konfai>=1.6.1
ConvexAdam_Composite/Model.py DELETED
@@ -1,549 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
18
-
19
- Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
20
- (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
21
- so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
22
- the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
23
- the elastix binary:
24
-
25
- (optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
26
- -> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
27
- -> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
28
-
29
- The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
30
- and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
31
-
32
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
33
- runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
34
- """
35
-
36
- import contextlib
37
- import json
38
- import os
39
- from dataclasses import dataclass, field
40
- from pathlib import Path
41
- from typing import Annotated, Literal
42
-
43
- import itk
44
- import numpy as np
45
- import SimpleITK as sitk
46
- import torch
47
- import tqdm
48
- from huggingface_hub import hf_hub_download
49
- from konfai.network import network
50
- from konfai.utils.config import Choices, Range
51
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
-
53
- DIM = 3
54
- # The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
55
- # medical images), not a tunable — so it's fixed here, never a config/signature parameter.
56
- NUM_CHANNELS = 1
57
-
58
- # A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__`` and ``ModelSpec``:
59
- # ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` / ``Annotated[str, Choices]`` a dropdown.
60
- # ``models`` is a dict-of-objects (one ``ModelSpec`` per feature model) — the same shape as the elastix presets,
61
- # so SlicerKonfAI renders each model as a repeatable block with a ``ref`` / ``distance`` combo box.
62
- _IMAGE_F = itk.Image[itk.F, DIM]
63
-
64
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
65
-
66
-
67
- def registry_choices() -> list[str]:
68
- """The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry the
69
- engine already fetches (offline-first). A user may still point ``ref`` at a local model path."""
70
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
71
- return [f"{repo}:{key}" for key in load_models_registry()]
72
-
73
-
74
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
75
- """Load ``models.json`` (the available feature models) from the model repo on Hugging Face. The registry is
76
- NOT bundled: ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for dev/offline; otherwise ``ref`` must be
77
- a ``repo:file`` Hugging Face reference."""
78
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
79
- if local:
80
- path = Path(local)
81
- elif ":" in ref:
82
- repo, filename = ref.split(":", 1)
83
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
84
- else:
85
- raise ValueError(
86
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (fetched from HF, not "
87
- "bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
88
- )
89
- return json.loads(path.read_text(encoding="utf-8"))
90
-
91
-
92
- def _sorted_specs(mapping: dict) -> list:
93
- """A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
94
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
95
-
96
-
97
- @dataclass
98
- class ModelSpec:
99
- """One feature model in the ConvexAdam multi-feature fusion. ``ref`` picks the model; the rest are its
100
- per-model knobs (all models are compared jointly by the IMPACT metric). Same idea as the elastix ``ModelSpec``."""
101
-
102
- ref: Annotated[str, Choices(registry_choices)]
103
- voxel_size: list[float] = field(default_factory=lambda: [3.0, 3.0, 3.0])
104
- layers_mask: str = "1" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
105
- layers_weight: float = 1.0
106
- pca: Annotated[int, Range(0, 100)] = 0
107
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
108
-
109
-
110
- @contextlib.contextmanager
111
- def _no_texpr_fuser():
112
- """Disable ONLY torch's TensorExpr (NNC) fuser for the block. The TS feature-model graphs have a direction/
113
- orientation branch whose shape ops (aten::dim / aten::size) crash the fuser's alias analysis under itk-impact
114
- ("INTERNAL ASSERT ... We don't have an op for aten::size" in FuseTensorExprs). It cannot be fixed model-side:
115
- itk-impact passes an *undefined* direction, so guarding it needs a shape op — the very fuser trigger. The
116
- modern profiling executor stays on (NOT the legacy executor); measured cost ~1% of a registration (the fuser
117
- only touches the few feature forwards, not the C++ optimisation loop). The caller's setting is restored."""
118
- prev = torch._C._jit_texpr_fuser_enabled()
119
- torch._C._jit_set_texpr_fuser_enabled(False)
120
- try:
121
- yield
122
- finally:
123
- torch._C._jit_set_texpr_fuser_enabled(prev)
124
-
125
-
126
- def _coarse_registration_type():
127
- """The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
128
- cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
129
- if cls is None:
130
- raise RuntimeError(
131
- "itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
132
- "install a build with the ConvexAdam registration filters."
133
- )
134
- return cls[_IMAGE_F, _IMAGE_F]
135
-
136
-
137
- def _fine_registration_type():
138
- """The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
139
- cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
140
- if cls is None:
141
- raise RuntimeError(
142
- "itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
143
- "install a build with the ConvexAdam registration filters."
144
- )
145
- return cls[_IMAGE_F, _IMAGE_F]
146
-
147
-
148
- def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
149
- """Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
150
- itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
151
- itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
152
- itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
153
- itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
154
- return itk_image
155
-
156
-
157
- def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
158
- """Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
159
- array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
160
- sitk_field = sitk.GetImageFromArray(array, isVector=True)
161
- sitk_field.CopyInformation(reference)
162
- return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
163
-
164
-
165
- def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
166
- """Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
167
- sitk_affine = sitk.AffineTransform(DIM)
168
- sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
169
- sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
170
- sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
171
- return sitk_affine
172
-
173
-
174
- class ConvexAdamEngine:
175
- """Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
176
-
177
- The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
178
- Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
179
- filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
180
- """
181
-
182
- def __init__(
183
- self,
184
- models: list[str],
185
- voxel_sizes: list[list[float]],
186
- overlap: int,
187
- layers_masks: list[list[bool]],
188
- mixed_precision: bool,
189
- grid_spacing: int,
190
- displacement_half_width: int,
191
- iterations: int,
192
- learning_rate: float,
193
- regularization_weight: float,
194
- grid_shrink: int,
195
- distance: list[str],
196
- layers_weight: list[float],
197
- subset_features: list[int],
198
- pca: list[int],
199
- stages: list[str],
200
- linear: bool,
201
- linear_iterations: int,
202
- seed: int,
203
- ) -> None:
204
- self._stages = stages
205
- self._model_paths = self._download_models(models)
206
- # Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
207
- # from disk in C++, so build the list once and reuse it across both stages and every case.
208
- self._configurations: "list[itk.ModelConfiguration] | None" = None
209
- self._voxel_sizes = voxel_sizes
210
- self._overlap = overlap
211
- self._layers_masks = layers_masks
212
- self._mixed_precision = mixed_precision
213
- self._grid_spacing = grid_spacing
214
- self._displacement_half_width = displacement_half_width
215
- self._iterations = iterations
216
- self._learning_rate = learning_rate
217
- self._regularization_weight = regularization_weight
218
- self._grid_shrink = grid_shrink
219
- self._distance = distance
220
- self._layers_weight = layers_weight
221
- self._subset_features = subset_features
222
- self._pca = pca
223
- self._linear = linear
224
- self._linear_iterations = linear_iterations
225
- self._seed = seed
226
-
227
- @staticmethod
228
- def _download_models(models: list[str]) -> list[str]:
229
- """Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
230
- paths = []
231
- for ref in models:
232
- repo, filename = ref.split(":", 1)
233
- paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
234
- return paths
235
-
236
- def _model_configurations(self) -> list["itk.ModelConfiguration"]:
237
- """Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
238
-
239
- Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
240
- it is built lazily and cached. The coarse/fine filters copy each configuration by value in
241
- ``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
242
- ``shared_ptr`` — so a single build is reused everywhere without any reload.
243
- """
244
- if self._configurations is None:
245
- self._configurations = [
246
- itk.ModelConfiguration(
247
- path,
248
- DIM,
249
- NUM_CHANNELS,
250
- [0, 0, 0],
251
- [float(v) for v in voxel_size],
252
- self._overlap,
253
- list(layers_mask),
254
- self._mixed_precision,
255
- )
256
- for path, voxel_size, layers_mask in zip(self._model_paths, self._voxel_sizes, self._layers_masks)
257
- ]
258
- return self._configurations
259
-
260
- def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
261
- """Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
262
- rigid = itk.VersorRigid3DTransform[itk.D].New()
263
- initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
264
- Transform=rigid, FixedImage=fixed, MovingImage=moving
265
- )
266
- initializer.MomentsOn()
267
- initializer.InitializeTransform()
268
-
269
- affine = itk.AffineTransform[itk.D, DIM].New()
270
- affine.SetCenter(rigid.GetCenter())
271
- affine.SetMatrix(rigid.GetMatrix())
272
- affine.SetOffset(rigid.GetOffset())
273
- levels = 3
274
- metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
275
- metric = metric_type.New()
276
- metric.SetNumberOfHistogramBins(32)
277
- optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
278
- optimizer.SetNumberOfIterations(self._linear_iterations)
279
- optimizer.SetLearningRate(1.0)
280
- optimizer.SetMinimumStepLength(1e-5)
281
- optimizer.SetRelaxationFactor(0.6)
282
- scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
283
- scales.SetMetric(metric)
284
- optimizer.SetScalesEstimator(scales)
285
- registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
286
- FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
287
- )
288
- registration.SetNumberOfLevels(levels)
289
- registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
290
- registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
291
- registration.InPlaceOn()
292
- registration.Update()
293
- return affine
294
-
295
- def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
296
- """ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
297
- coarse = _coarse_registration_type().New()
298
- coarse.SetFixedImage(fixed)
299
- coarse.SetMovingImage(moving)
300
- for configuration in self._model_configurations():
301
- coarse.AddModelConfiguration(configuration)
302
- coarse.SetGridSpacing(self._grid_spacing)
303
- coarse.SetDisplacementHalfWidth(self._displacement_half_width)
304
- coarse.SetDevice(device)
305
- coarse.SetSeed(self._seed)
306
- coarse.Update()
307
- field = coarse.GetOutput()
308
- field.DisconnectPipeline()
309
- return field
310
-
311
- def _fine(
312
- self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
313
- ) -> "itk.Image":
314
- """Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
315
- fine = _fine_registration_type().New()
316
- fine.SetFixedImage(fixed)
317
- fine.SetMovingImage(moving)
318
- fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
319
- for configuration in self._model_configurations():
320
- fine.AddModelConfiguration(configuration)
321
- fine.SetDistance(list(self._distance))
322
- fine.SetLayersWeight([float(v) for v in self._layers_weight])
323
- fine.SetSubsetFeatures([int(v) for v in self._subset_features])
324
- fine.SetPCA([int(v) for v in self._pca])
325
- fine.SetNumberOfIterations(self._iterations)
326
- fine.SetLearningRate(self._learning_rate)
327
- fine.SetRegularizationWeight(self._regularization_weight)
328
- fine.SetGridShrinkFactor(self._grid_shrink)
329
- fine.SetDevice(device)
330
- fine.SetSeed(self._seed)
331
-
332
- # Optional terminal progress over the Adam iterations, driven from the metric trace. ``disable=None``
333
- # auto-hides it when stderr is not a TTY (e.g. under KonfAI/Slicer, where the outer "Prediction" bar
334
- # already reports progress), so captured logs stay clean; ``leave=False`` avoids stacking one bar per
335
- # patch. The observer is best-effort — if the filter emits no IterationEvent the bar just fills at the end.
336
- progress = tqdm.tqdm(
337
- total=self._iterations or None, desc="Registration", ncols=0, leave=False, disable=None
338
- )
339
-
340
- def _update(*_: object) -> None:
341
- values = list(fine.GetMetricValuesPerIteration())
342
- progress.n = min(len(values), self._iterations)
343
- if values:
344
- progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
345
- progress.refresh()
346
-
347
- try:
348
- fine.AddObserver(itk.IterationEvent(), _update)
349
- except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
350
- pass
351
- fine.Update()
352
- progress.n = progress.total or self._iterations # show completion even if no IterationEvent fired
353
- progress.refresh()
354
- progress.close()
355
- field = fine.GetDisplacementField()
356
- field.DisconnectPipeline()
357
- return field
358
-
359
- @staticmethod
360
- def _zero_field(reference: "itk.Image") -> "itk.Image":
361
- """An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
362
- field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
363
- field.CopyInformation(reference)
364
- field.SetRegions(reference.GetLargestPossibleRegion())
365
- field.Allocate()
366
- zero = itk.Vector[itk.F, DIM]()
367
- zero.Fill(0) # itk::Vector default ctor does not zero-initialise
368
- field.FillBuffer(zero)
369
- return field
370
-
371
- def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
372
- """Run the configured coarse/fine chain; each fine warm-starts from the running field.
373
-
374
- ``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
375
- coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
376
- (the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
377
- """
378
- field: "itk.Image | None" = None
379
- for stage in self._stages:
380
- if stage == "coarse":
381
- field = self._coarse(fixed, moving, device)
382
- elif stage == "fine":
383
- field = self._fine(fixed, moving, field, device)
384
- else:
385
- raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
386
- return field
387
-
388
- def register(
389
- self,
390
- fixed: sitk.Image,
391
- moving: sitk.Image,
392
- device_index: int,
393
- fixed_mask: sitk.Image | None = None,
394
- moving_mask: sitk.Image | None = None,
395
- ) -> tuple[np.ndarray, np.ndarray]:
396
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
397
- device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
398
- fixed_itk = _sitk_to_itk(fixed)
399
- moving_itk = _sitk_to_itk(moving)
400
-
401
- # Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
402
- affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
403
- resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
404
- Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
405
- )
406
- resampler.UseReferenceImageOn()
407
- resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
408
- resampler.Update()
409
- moving_linear = resampler.GetOutput()
410
-
411
- field = self._run_stages(fixed_itk, moving_linear, device)
412
-
413
- # One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
414
- # ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
415
- # moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
416
- chain = [_itk_affine_to_sitk(affine)]
417
- if field is not None:
418
- chain.append(_itk_field_to_sitk_transform(field, fixed))
419
- composite = sitk.CompositeTransform(chain)
420
- moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
421
- dvf = sitk.TransformToDisplacementField(
422
- composite,
423
- sitk.sitkVectorFloat64,
424
- fixed.GetSize(),
425
- fixed.GetOrigin(),
426
- fixed.GetSpacing(),
427
- fixed.GetDirection(),
428
- )
429
- moved_np, _ = image_to_data(moved)
430
- dvf_np, _ = image_to_data(dvf)
431
- return moved_np, dvf_np
432
-
433
-
434
- class ConvexAdamRegistration(torch.nn.Module):
435
- """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
436
-
437
- ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
438
- the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
439
- """
440
-
441
- accepts_attributes = True
442
-
443
- def __init__(self, engine: ConvexAdamEngine) -> None:
444
- super().__init__()
445
- self._engine = engine
446
-
447
- def forward(
448
- self,
449
- fixed: torch.Tensor,
450
- moving: torch.Tensor,
451
- fixed_mask: torch.Tensor,
452
- moving_mask: torch.Tensor,
453
- attributes: list[list[Attribute]],
454
- ) -> torch.Tensor:
455
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
456
- # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
457
- # channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
458
- fixed_attrs, moving_attrs, _, _ = attributes
459
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
460
- combined = []
461
- # ConvexAdam runs a gradient-based instance optimisation (Adam over the field) inside itk-impact's
462
- # .Update(); the predictor calls forward under torch.inference_mode(), which forbids autograd. The
463
- # image tensors have already crossed to numpy/ITK here, so re-enable grad for the optimisation.
464
- with torch.inference_mode(False), torch.enable_grad(), _no_texpr_fuser():
465
- for b in range(fixed.shape[0]):
466
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
467
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
468
- moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
469
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
470
- return torch.stack(combined, dim=0).to(fixed.device)
471
-
472
-
473
- class ChannelSelect(torch.nn.Module):
474
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
475
-
476
- def __init__(self, start: int, stop: int) -> None:
477
- super().__init__()
478
- self._start = start
479
- self._stop = stop
480
-
481
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
482
- return tensor[:, self._start : self._stop]
483
-
484
-
485
- class RegistrationNet(network.Network):
486
- """Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
487
- the mask branches 2/3 are accepted but unused by this engine).
488
-
489
- Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
490
- DIM-component displacement field, in mm). Geometry is attached by the predictor via
491
- ``same_as_group: Volume_0:Fixed``.
492
- """
493
-
494
- def __init__(
495
- self,
496
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
497
- schedulers: dict[str, network.LRSchedulersLoader] = {
498
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
499
- },
500
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
501
- models: dict[str, ModelSpec] = {},
502
- overlap: Annotated[int, Range(1, 128)] = 2,
503
- mixed_precision: bool = False,
504
- grid_spacing: Annotated[int, Range(1, 512)] = 4,
505
- displacement_half_width: Annotated[int, Range(1, 512)] = 6,
506
- iterations: Annotated[int, Range(0, 100000)] = 150,
507
- learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
508
- regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
509
- grid_shrink: Annotated[int, Range(1, 128)] = 4,
510
- subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
511
- stages: list[str] = ["coarse", "fine"],
512
- linear: bool = True,
513
- linear_iterations: Annotated[int, Range(0, 100000)] = 200,
514
- seed: int = 42,
515
- ) -> None:
516
- super().__init__(
517
- in_channels=1,
518
- optimizer=optimizer,
519
- schedulers=schedulers,
520
- outputs_criterions=outputs_criterions,
521
- dim=3,
522
- )
523
- specs = _sorted_specs(models)
524
- engine = ConvexAdamEngine(
525
- [spec.ref for spec in specs],
526
- [list(spec.voxel_size) for spec in specs],
527
- overlap,
528
- [[c == "1" for c in spec.layers_mask] for spec in specs],
529
- mixed_precision,
530
- grid_spacing,
531
- displacement_half_width,
532
- iterations,
533
- learning_rate,
534
- regularization_weight,
535
- grid_shrink,
536
- [spec.distance for spec in specs],
537
- [float(spec.layers_weight) for spec in specs],
538
- subset_features,
539
- [int(spec.pca) for spec in specs],
540
- stages,
541
- linear,
542
- linear_iterations,
543
- seed,
544
- )
545
- self.add_module(
546
- "Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
547
- )
548
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
549
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ConvexAdam_Composite/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  models:
6
  '0':
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.convexadam:RegistrationNet
4
  RegistrationNet:
5
  models:
6
  '0':
ConvexAdam_Composite/requirements.txt CHANGED
@@ -1 +1,2 @@
1
  itk-impact
 
 
1
  itk-impact
2
+ impact_reg_konfai>=1.6.1
ConvexAdam_Fine/Model.py DELETED
@@ -1,549 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
18
-
19
- Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
20
- (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
21
- so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
22
- the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
23
- the elastix binary:
24
-
25
- (optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
26
- -> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
27
- -> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
28
-
29
- The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
30
- and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
31
-
32
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
33
- runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
34
- """
35
-
36
- import contextlib
37
- import json
38
- import os
39
- from dataclasses import dataclass, field
40
- from pathlib import Path
41
- from typing import Annotated, Literal
42
-
43
- import itk
44
- import numpy as np
45
- import SimpleITK as sitk
46
- import torch
47
- import tqdm
48
- from huggingface_hub import hf_hub_download
49
- from konfai.network import network
50
- from konfai.utils.config import Choices, Range
51
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
52
-
53
- DIM = 3
54
- # The feature model's input channel count is an intrinsic property of the pretrained model (grayscale
55
- # medical images), not a tunable — so it's fixed here, never a config/signature parameter.
56
- NUM_CHANNELS = 1
57
-
58
- # A UI reads the tuning knobs straight from the TYPES on ``RegistrationNet.__init__`` and ``ModelSpec``:
59
- # ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` / ``Annotated[str, Choices]`` a dropdown.
60
- # ``models`` is a dict-of-objects (one ``ModelSpec`` per feature model) — the same shape as the elastix presets,
61
- # so SlicerKonfAI renders each model as a repeatable block with a ``ref`` / ``distance`` combo box.
62
- _IMAGE_F = itk.Image[itk.F, DIM]
63
-
64
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
65
-
66
-
67
- def registry_choices() -> list[str]:
68
- """The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry the
69
- engine already fetches (offline-first). A user may still point ``ref`` at a local model path."""
70
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
71
- return [f"{repo}:{key}" for key in load_models_registry()]
72
-
73
-
74
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
75
- """Load ``models.json`` (the available feature models) from the model repo on Hugging Face. The registry is
76
- NOT bundled: ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for dev/offline; otherwise ``ref`` must be
77
- a ``repo:file`` Hugging Face reference."""
78
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
79
- if local:
80
- path = Path(local)
81
- elif ":" in ref:
82
- repo, filename = ref.split(":", 1)
83
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
84
- else:
85
- raise ValueError(
86
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (fetched from HF, not "
87
- "bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
88
- )
89
- return json.loads(path.read_text(encoding="utf-8"))
90
-
91
-
92
- def _sorted_specs(mapping: dict) -> list:
93
- """A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
94
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
95
-
96
-
97
- @dataclass
98
- class ModelSpec:
99
- """One feature model in the ConvexAdam multi-feature fusion. ``ref`` picks the model; the rest are its
100
- per-model knobs (all models are compared jointly by the IMPACT metric). Same idea as the elastix ``ModelSpec``."""
101
-
102
- ref: Annotated[str, Choices(registry_choices)]
103
- voxel_size: list[float] = field(default_factory=lambda: [3.0, 3.0, 3.0])
104
- layers_mask: str = "1" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
105
- layers_weight: float = 1.0
106
- pca: Annotated[int, Range(0, 100)] = 0
107
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
108
-
109
-
110
- @contextlib.contextmanager
111
- def _no_texpr_fuser():
112
- """Disable ONLY torch's TensorExpr (NNC) fuser for the block. The TS feature-model graphs have a direction/
113
- orientation branch whose shape ops (aten::dim / aten::size) crash the fuser's alias analysis under itk-impact
114
- ("INTERNAL ASSERT ... We don't have an op for aten::size" in FuseTensorExprs). It cannot be fixed model-side:
115
- itk-impact passes an *undefined* direction, so guarding it needs a shape op — the very fuser trigger. The
116
- modern profiling executor stays on (NOT the legacy executor); measured cost ~1% of a registration (the fuser
117
- only touches the few feature forwards, not the C++ optimisation loop). The caller's setting is restored."""
118
- prev = torch._C._jit_texpr_fuser_enabled()
119
- torch._C._jit_set_texpr_fuser_enabled(False)
120
- try:
121
- yield
122
- finally:
123
- torch._C._jit_set_texpr_fuser_enabled(prev)
124
-
125
-
126
- def _coarse_registration_type():
127
- """The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
128
- cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
129
- if cls is None:
130
- raise RuntimeError(
131
- "itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
132
- "install a build with the ConvexAdam registration filters."
133
- )
134
- return cls[_IMAGE_F, _IMAGE_F]
135
-
136
-
137
- def _fine_registration_type():
138
- """The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
139
- cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
140
- if cls is None:
141
- raise RuntimeError(
142
- "itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
143
- "install a build with the ConvexAdam registration filters."
144
- )
145
- return cls[_IMAGE_F, _IMAGE_F]
146
-
147
-
148
- def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
149
- """Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
150
- itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
151
- itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
152
- itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
153
- itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
154
- return itk_image
155
-
156
-
157
- def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
158
- """Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
159
- array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
160
- sitk_field = sitk.GetImageFromArray(array, isVector=True)
161
- sitk_field.CopyInformation(reference)
162
- return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
163
-
164
-
165
- def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
166
- """Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
167
- sitk_affine = sitk.AffineTransform(DIM)
168
- sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
169
- sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
170
- sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
171
- return sitk_affine
172
-
173
-
174
- class ConvexAdamEngine:
175
- """Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
176
-
177
- The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
178
- Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
179
- filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
180
- """
181
-
182
- def __init__(
183
- self,
184
- models: list[str],
185
- voxel_sizes: list[list[float]],
186
- overlap: int,
187
- layers_masks: list[list[bool]],
188
- mixed_precision: bool,
189
- grid_spacing: int,
190
- displacement_half_width: int,
191
- iterations: int,
192
- learning_rate: float,
193
- regularization_weight: float,
194
- grid_shrink: int,
195
- distance: list[str],
196
- layers_weight: list[float],
197
- subset_features: list[int],
198
- pca: list[int],
199
- stages: list[str],
200
- linear: bool,
201
- linear_iterations: int,
202
- seed: int,
203
- ) -> None:
204
- self._stages = stages
205
- self._model_paths = self._download_models(models)
206
- # Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
207
- # from disk in C++, so build the list once and reuse it across both stages and every case.
208
- self._configurations: "list[itk.ModelConfiguration] | None" = None
209
- self._voxel_sizes = voxel_sizes
210
- self._overlap = overlap
211
- self._layers_masks = layers_masks
212
- self._mixed_precision = mixed_precision
213
- self._grid_spacing = grid_spacing
214
- self._displacement_half_width = displacement_half_width
215
- self._iterations = iterations
216
- self._learning_rate = learning_rate
217
- self._regularization_weight = regularization_weight
218
- self._grid_shrink = grid_shrink
219
- self._distance = distance
220
- self._layers_weight = layers_weight
221
- self._subset_features = subset_features
222
- self._pca = pca
223
- self._linear = linear
224
- self._linear_iterations = linear_iterations
225
- self._seed = seed
226
-
227
- @staticmethod
228
- def _download_models(models: list[str]) -> list[str]:
229
- """Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
230
- paths = []
231
- for ref in models:
232
- repo, filename = ref.split(":", 1)
233
- paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
234
- return paths
235
-
236
- def _model_configurations(self) -> list["itk.ModelConfiguration"]:
237
- """Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
238
-
239
- Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
240
- it is built lazily and cached. The coarse/fine filters copy each configuration by value in
241
- ``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
242
- ``shared_ptr`` — so a single build is reused everywhere without any reload.
243
- """
244
- if self._configurations is None:
245
- self._configurations = [
246
- itk.ModelConfiguration(
247
- path,
248
- DIM,
249
- NUM_CHANNELS,
250
- [0, 0, 0],
251
- [float(v) for v in voxel_size],
252
- self._overlap,
253
- list(layers_mask),
254
- self._mixed_precision,
255
- )
256
- for path, voxel_size, layers_mask in zip(self._model_paths, self._voxel_sizes, self._layers_masks)
257
- ]
258
- return self._configurations
259
-
260
- def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
261
- """Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
262
- rigid = itk.VersorRigid3DTransform[itk.D].New()
263
- initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
264
- Transform=rigid, FixedImage=fixed, MovingImage=moving
265
- )
266
- initializer.MomentsOn()
267
- initializer.InitializeTransform()
268
-
269
- affine = itk.AffineTransform[itk.D, DIM].New()
270
- affine.SetCenter(rigid.GetCenter())
271
- affine.SetMatrix(rigid.GetMatrix())
272
- affine.SetOffset(rigid.GetOffset())
273
- levels = 3
274
- metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
275
- metric = metric_type.New()
276
- metric.SetNumberOfHistogramBins(32)
277
- optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
278
- optimizer.SetNumberOfIterations(self._linear_iterations)
279
- optimizer.SetLearningRate(1.0)
280
- optimizer.SetMinimumStepLength(1e-5)
281
- optimizer.SetRelaxationFactor(0.6)
282
- scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
283
- scales.SetMetric(metric)
284
- optimizer.SetScalesEstimator(scales)
285
- registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
286
- FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
287
- )
288
- registration.SetNumberOfLevels(levels)
289
- registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
290
- registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
291
- registration.InPlaceOn()
292
- registration.Update()
293
- return affine
294
-
295
- def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
296
- """ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
297
- coarse = _coarse_registration_type().New()
298
- coarse.SetFixedImage(fixed)
299
- coarse.SetMovingImage(moving)
300
- for configuration in self._model_configurations():
301
- coarse.AddModelConfiguration(configuration)
302
- coarse.SetGridSpacing(self._grid_spacing)
303
- coarse.SetDisplacementHalfWidth(self._displacement_half_width)
304
- coarse.SetDevice(device)
305
- coarse.SetSeed(self._seed)
306
- coarse.Update()
307
- field = coarse.GetOutput()
308
- field.DisconnectPipeline()
309
- return field
310
-
311
- def _fine(
312
- self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
313
- ) -> "itk.Image":
314
- """Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
315
- fine = _fine_registration_type().New()
316
- fine.SetFixedImage(fixed)
317
- fine.SetMovingImage(moving)
318
- fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
319
- for configuration in self._model_configurations():
320
- fine.AddModelConfiguration(configuration)
321
- fine.SetDistance(list(self._distance))
322
- fine.SetLayersWeight([float(v) for v in self._layers_weight])
323
- fine.SetSubsetFeatures([int(v) for v in self._subset_features])
324
- fine.SetPCA([int(v) for v in self._pca])
325
- fine.SetNumberOfIterations(self._iterations)
326
- fine.SetLearningRate(self._learning_rate)
327
- fine.SetRegularizationWeight(self._regularization_weight)
328
- fine.SetGridShrinkFactor(self._grid_shrink)
329
- fine.SetDevice(device)
330
- fine.SetSeed(self._seed)
331
-
332
- # Optional terminal progress over the Adam iterations, driven from the metric trace. ``disable=None``
333
- # auto-hides it when stderr is not a TTY (e.g. under KonfAI/Slicer, where the outer "Prediction" bar
334
- # already reports progress), so captured logs stay clean; ``leave=False`` avoids stacking one bar per
335
- # patch. The observer is best-effort — if the filter emits no IterationEvent the bar just fills at the end.
336
- progress = tqdm.tqdm(
337
- total=self._iterations or None, desc="Registration", ncols=0, leave=False, disable=None
338
- )
339
-
340
- def _update(*_: object) -> None:
341
- values = list(fine.GetMetricValuesPerIteration())
342
- progress.n = min(len(values), self._iterations)
343
- if values:
344
- progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
345
- progress.refresh()
346
-
347
- try:
348
- fine.AddObserver(itk.IterationEvent(), _update)
349
- except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
350
- pass
351
- fine.Update()
352
- progress.n = progress.total or self._iterations # show completion even if no IterationEvent fired
353
- progress.refresh()
354
- progress.close()
355
- field = fine.GetDisplacementField()
356
- field.DisconnectPipeline()
357
- return field
358
-
359
- @staticmethod
360
- def _zero_field(reference: "itk.Image") -> "itk.Image":
361
- """An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
362
- field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
363
- field.CopyInformation(reference)
364
- field.SetRegions(reference.GetLargestPossibleRegion())
365
- field.Allocate()
366
- zero = itk.Vector[itk.F, DIM]()
367
- zero.Fill(0) # itk::Vector default ctor does not zero-initialise
368
- field.FillBuffer(zero)
369
- return field
370
-
371
- def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
372
- """Run the configured coarse/fine chain; each fine warm-starts from the running field.
373
-
374
- ``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
375
- coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
376
- (the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
377
- """
378
- field: "itk.Image | None" = None
379
- for stage in self._stages:
380
- if stage == "coarse":
381
- field = self._coarse(fixed, moving, device)
382
- elif stage == "fine":
383
- field = self._fine(fixed, moving, field, device)
384
- else:
385
- raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
386
- return field
387
-
388
- def register(
389
- self,
390
- fixed: sitk.Image,
391
- moving: sitk.Image,
392
- device_index: int,
393
- fixed_mask: sitk.Image | None = None,
394
- moving_mask: sitk.Image | None = None,
395
- ) -> tuple[np.ndarray, np.ndarray]:
396
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
397
- device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
398
- fixed_itk = _sitk_to_itk(fixed)
399
- moving_itk = _sitk_to_itk(moving)
400
-
401
- # Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
402
- affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
403
- resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
404
- Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
405
- )
406
- resampler.UseReferenceImageOn()
407
- resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
408
- resampler.Update()
409
- moving_linear = resampler.GetOutput()
410
-
411
- field = self._run_stages(fixed_itk, moving_linear, device)
412
-
413
- # One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
414
- # ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
415
- # moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
416
- chain = [_itk_affine_to_sitk(affine)]
417
- if field is not None:
418
- chain.append(_itk_field_to_sitk_transform(field, fixed))
419
- composite = sitk.CompositeTransform(chain)
420
- moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
421
- dvf = sitk.TransformToDisplacementField(
422
- composite,
423
- sitk.sitkVectorFloat64,
424
- fixed.GetSize(),
425
- fixed.GetOrigin(),
426
- fixed.GetSpacing(),
427
- fixed.GetDirection(),
428
- )
429
- moved_np, _ = image_to_data(moved)
430
- dvf_np, _ = image_to_data(dvf)
431
- return moved_np, dvf_np
432
-
433
-
434
- class ConvexAdamRegistration(torch.nn.Module):
435
- """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
436
-
437
- ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
438
- the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
439
- """
440
-
441
- accepts_attributes = True
442
-
443
- def __init__(self, engine: ConvexAdamEngine) -> None:
444
- super().__init__()
445
- self._engine = engine
446
-
447
- def forward(
448
- self,
449
- fixed: torch.Tensor,
450
- moving: torch.Tensor,
451
- fixed_mask: torch.Tensor,
452
- moving_mask: torch.Tensor,
453
- attributes: list[list[Attribute]],
454
- ) -> torch.Tensor:
455
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
456
- # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
457
- # channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
458
- fixed_attrs, moving_attrs, _, _ = attributes
459
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
460
- combined = []
461
- # ConvexAdam runs a gradient-based instance optimisation (Adam over the field) inside itk-impact's
462
- # .Update(); the predictor calls forward under torch.inference_mode(), which forbids autograd. The
463
- # image tensors have already crossed to numpy/ITK here, so re-enable grad for the optimisation.
464
- with torch.inference_mode(False), torch.enable_grad(), _no_texpr_fuser():
465
- for b in range(fixed.shape[0]):
466
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
467
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
468
- moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
469
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
470
- return torch.stack(combined, dim=0).to(fixed.device)
471
-
472
-
473
- class ChannelSelect(torch.nn.Module):
474
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
475
-
476
- def __init__(self, start: int, stop: int) -> None:
477
- super().__init__()
478
- self._start = start
479
- self._stop = stop
480
-
481
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
482
- return tensor[:, self._start : self._stop]
483
-
484
-
485
- class RegistrationNet(network.Network):
486
- """Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
487
- the mask branches 2/3 are accepted but unused by this engine).
488
-
489
- Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
490
- DIM-component displacement field, in mm). Geometry is attached by the predictor via
491
- ``same_as_group: Volume_0:Fixed``.
492
- """
493
-
494
- def __init__(
495
- self,
496
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
497
- schedulers: dict[str, network.LRSchedulersLoader] = {
498
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
499
- },
500
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
501
- models: dict[str, ModelSpec] = {},
502
- overlap: Annotated[int, Range(1, 128)] = 2,
503
- mixed_precision: bool = False,
504
- grid_spacing: Annotated[int, Range(1, 512)] = 4,
505
- displacement_half_width: Annotated[int, Range(1, 512)] = 6,
506
- iterations: Annotated[int, Range(0, 100000)] = 150,
507
- learning_rate: Annotated[float, Range(0.0, 100.0)] = 0.2,
508
- regularization_weight: Annotated[float, Range(0.0, 1000.0)] = 1.0,
509
- grid_shrink: Annotated[int, Range(1, 128)] = 4,
510
- subset_features: list[int] = [], # feature-channel indices to keep (empty = all); NOT a count
511
- stages: list[str] = ["coarse", "fine"],
512
- linear: bool = True,
513
- linear_iterations: Annotated[int, Range(0, 100000)] = 200,
514
- seed: int = 42,
515
- ) -> None:
516
- super().__init__(
517
- in_channels=1,
518
- optimizer=optimizer,
519
- schedulers=schedulers,
520
- outputs_criterions=outputs_criterions,
521
- dim=3,
522
- )
523
- specs = _sorted_specs(models)
524
- engine = ConvexAdamEngine(
525
- [spec.ref for spec in specs],
526
- [list(spec.voxel_size) for spec in specs],
527
- overlap,
528
- [[c == "1" for c in spec.layers_mask] for spec in specs],
529
- mixed_precision,
530
- grid_spacing,
531
- displacement_half_width,
532
- iterations,
533
- learning_rate,
534
- regularization_weight,
535
- grid_shrink,
536
- [spec.distance for spec in specs],
537
- [float(spec.layers_weight) for spec in specs],
538
- subset_features,
539
- [int(spec.pca) for spec in specs],
540
- stages,
541
- linear,
542
- linear_iterations,
543
- seed,
544
- )
545
- self.add_module(
546
- "Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
547
- )
548
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
549
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ConvexAdam_Fine/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  models:
6
  '0':
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.convexadam:RegistrationNet
4
  RegistrationNet:
5
  models:
6
  '0':
ConvexAdam_Fine/requirements.txt CHANGED
@@ -1 +1,2 @@
1
  itk-impact
 
 
1
  itk-impact
2
+ impact_reg_konfai>=1.6.1
FireANTs_Affine/Model.py DELETED
@@ -1,669 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
- #
17
- # This wrapper does NOT copy any FireANTs source: it only calls the public FireANTs API of the
18
- # separately-installed ``fireants`` wheel (PyPI). FireANTs is distributed under the FireANTs License
19
- # v1.0 and must be cited — see the NOTICE file in this directory for the license, copyright and
20
- # bibliography that ship with this app.
21
-
22
- """FireANTs registration as a self-contained KonfAI model (shared by the FireANTs presets).
23
-
24
- Same idiomatic ``add_module`` graph and the same output contract as the ConvexAdam preset
25
- (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``), so the
26
- orchestrator / app.json / ensemble / uncertainty are unchanged. The engine chains FireANTs' own
27
- composable stages (GPU, Riemannian Adam), each seeding the next like ANTs' ``-t`` stages:
28
-
29
- Rigid (MI, centre-of-mass init) -> Affine (MI, seeded by the rigid) -> deformable
30
-
31
- The deformable stage is selected by ``deformable_method`` — the ONE knob that specialises this shared
32
- Model.py into the different presets (exactly as ConvexAdam's shared Model.py is specialised by
33
- ``stages``):
34
-
35
- "syn" symmetric diffeomorphic SyN (CC) — invertible, higher quality, averages cleanly for ensembling
36
- "greedy" greedy diffeomorphic (CC) — one-directional, faster / lower VRAM
37
- "none" linear only — Rigid+Affine, no deformable (the FireANTs_Affine preset)
38
-
39
- Masks: the optional Fixed/Moving masks restrict the metric to a region. FireANTs implements this by
40
- carrying the mask as the last image channel and prefixing the metric with ``masked_``; a mask is only
41
- honoured when it actually restricts (some voxels in, some out), so the common mask-free path is
42
- unchanged (an absent optional mask arrives as a whole-image default and is treated as no mask).
43
-
44
- The deformable stages produce the single TOTAL displacement field on the fixed grid (the linear
45
- pre-align is baked in via ``init_affine``, ANTs convention); ``none`` uses the affine matrix directly.
46
- ``MovedImage`` and the emitted ``DisplacementField`` are rebuilt from that transform with SimpleITK —
47
- the same output path as the ConvexAdam engine — so all presets/engines are interchangeable in an
48
- ensemble. FireANTs' output-transform writer only serialises to a file, so the deformable field is
49
- round-tripped through a temporary NIfTI (no FireANTs internals are reimplemented here).
50
-
51
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
52
- runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
53
- """
54
-
55
- import contextlib
56
- import json
57
- import os
58
- import tempfile
59
- from dataclasses import dataclass
60
- from pathlib import Path
61
- from typing import Annotated, Literal
62
-
63
- import numpy as np
64
- import SimpleITK as sitk
65
- import torch
66
- from konfai.metric.measure import IMPACTReg
67
- from konfai.network import network
68
- from konfai.utils.config import Choices, Range
69
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
70
-
71
- DIM = 3
72
-
73
- # Feature-model registry (models.json): the available IMPACT feature models, fetched from HF (NOT bundled).
74
- # Only consulted by the "impact" deformable metric; ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins
75
- # for dev/offline. Mirrors the ConvexAdam preset so the same 30-model catalogue and picker are shared.
76
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
77
-
78
- _DISTANCES: dict[str, type[torch.nn.Module]] = {"L1": torch.nn.L1Loss, "L2": torch.nn.MSELoss}
79
-
80
-
81
- def _fireants_git_ref() -> str:
82
- """Best-effort FireANTs git ref whose ``fused_ops`` matches the installed ``fireants``.
83
-
84
- Overridable with ``FIREANTS_FUSED_OPS_REF``; falls back to ``main`` if the version is unknown.
85
- """
86
- try:
87
- import importlib.metadata
88
-
89
- version = importlib.metadata.version("fireants").strip()
90
- if version:
91
- return version if version.startswith("v") else f"v{version}"
92
- except Exception:
93
- pass
94
- return "main"
95
-
96
-
97
- def ensure_fireants_runtime(build_kernels: bool = True) -> None:
98
- """Make the ``fireants`` runtime importable before a preset uses it — best-effort, never fatal.
99
-
100
- A plain ``pip install fireants`` fails inside a host like 3D Slicer for two reasons, both handled
101
- here with a clear one-line status at each step:
102
-
103
- 1. **fireants won't install.** It pins ``simpleitk==2.2.1`` (no wheel on modern Python), while the
104
- host already ships a newer SimpleITK. We install it with ``--no-deps`` so that pin is ignored
105
- and the host's SimpleITK/torch are reused; its light deps ship in ``requirements.txt``.
106
- 2. **The fused CUDA kernels** (``fireants_fused_ops``) that make registration fast and
107
- memory-light are OPTIONAL. Without them fireants runs in pure PyTorch (correct, only slower).
108
- We enable them only when a CUDA compiler (``nvcc``) is present, compiling from the upstream
109
- FireANTs source at install time — nothing is vendored into this app. No compiler, or a failed
110
- build, simply falls back to pure PyTorch.
111
- """
112
- import importlib
113
- import shutil
114
- import subprocess
115
- import sys
116
-
117
- def _log(message: str) -> None:
118
- print(f"[FireANTs] {message}", flush=True)
119
-
120
- # 1) fireants itself — install --no-deps to sidestep its unsatisfiable ``simpleitk==2.2.1`` pin.
121
- try:
122
- importlib.import_module("fireants")
123
- except Exception:
124
- _log("installing fireants (--no-deps, reusing the host's SimpleITK/torch)...")
125
- try:
126
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "--no-deps", "fireants"])
127
- importlib.invalidate_caches()
128
- importlib.import_module("fireants")
129
- _log("fireants installed.")
130
- except Exception as exc:
131
- raise RuntimeError(
132
- "Could not install 'fireants'. Install it manually with:\n"
133
- f" {sys.executable} -m pip install --no-deps fireants\n"
134
- "(its hydra-core/nibabel/pandas dependencies ship in this app's requirements.txt).\n"
135
- f"Original error: {exc}"
136
- ) from exc
137
-
138
- if not build_kernels or os.environ.get("FIREANTS_SKIP_FUSED_OPS", "").strip().lower() in {"1", "true", "yes"}:
139
- _log("skipping the fused CUDA kernels -> pure PyTorch (correct, slower and more memory).")
140
- return
141
-
142
- # 2) fused CUDA kernels — optional accelerator.
143
- try:
144
- importlib.import_module("fireants_fused_ops")
145
- _log("fused CUDA kernels already available.")
146
- return
147
- except Exception:
148
- pass
149
-
150
- # 2a) a prebuilt wheel matching this torch's CUDA build, if one is ever published.
151
- cuda_tag = ""
152
- try:
153
- cuda_tag = (torch.version.cuda or "").replace(".", "")
154
- except Exception:
155
- pass
156
- if cuda_tag:
157
- wheel = f"fireants-fused-ops-cu{cuda_tag}"
158
- try:
159
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", wheel])
160
- importlib.invalidate_caches()
161
- importlib.import_module("fireants_fused_ops")
162
- _log(f"installed prebuilt CUDA kernels ({wheel}).")
163
- return
164
- except Exception:
165
- pass # no matching wheel -> try a local build
166
-
167
- # 2b) local build is OPT-IN: compiling CUDA kernels is heavy and can exhaust RAM on the user's
168
- # machine, so it NEVER runs by default. Set FIREANTS_BUILD_KERNELS=1 to enable it (devs with a
169
- # CUDA toolkit); it then builds ONE file at a time (MAX_JOBS=1) to keep memory bounded. The clean
170
- # path for end users is a prebuilt wheel (2a) — a local compile also needs Python dev headers
171
- # (absent from some embedded Pythons, e.g. Slicer) and a CUDA-compatible host compiler.
172
- if os.environ.get("FIREANTS_BUILD_KERNELS", "").strip().lower() not in ("1", "true", "yes"):
173
- _log(
174
- "no prebuilt kernels for this platform -> running FireANTs in pure PyTorch (correct, slower "
175
- "and heavier). Set FIREANTS_BUILD_KERNELS=1 to compile them locally (needs a CUDA toolkit)."
176
- )
177
- return
178
- cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
179
- nvcc = shutil.which("nvcc") or (os.path.join(cuda_home, "bin", "nvcc") if cuda_home else None)
180
- if not nvcc or not os.path.exists(nvcc) or shutil.which("git") is None:
181
- _log("FIREANTS_BUILD_KERNELS set but no CUDA toolkit (nvcc) / git found -> pure PyTorch.")
182
- return
183
-
184
- ref = os.environ.get("FIREANTS_FUSED_OPS_REF") or _fireants_git_ref()
185
- _log(f"nvcc found ({nvcc}); compiling the FireANTs CUDA kernels one file at a time (ref '{ref}')...")
186
- import tempfile
187
-
188
- env = os.environ.copy()
189
- env["MAX_JOBS"] = "1" # one compile at a time -> bounded RAM (prevents OOM on large hosts)
190
- env.setdefault("NVCC_APPEND_FLAGS", "-allow-unsupported-compiler") # tolerate a newer host compiler
191
- tmp = tempfile.mkdtemp(prefix="fireants_fused_ops_")
192
- try:
193
- # Shallow clone WITHOUT --recursive: FireANTs' submodules (an SSH-only 'cookbook') are unrelated
194
- # to fused_ops and would otherwise abort the build with a public-key/permission error.
195
- subprocess.check_call(
196
- ["git", "clone", "--depth", "1", "--branch", ref, "https://github.com/rohitrango/FireANTs.git", tmp]
197
- )
198
- subprocess.check_call(
199
- [sys.executable, "-m", "pip", "install", "-q", "--no-build-isolation", os.path.join(tmp, "fused_ops")],
200
- env=env,
201
- )
202
- importlib.invalidate_caches()
203
- importlib.import_module("fireants_fused_ops")
204
- _log("compiled and installed the fast CUDA kernels.")
205
- except Exception as exc:
206
- _log(
207
- "kernel build failed -> running in pure PyTorch (correct, only speed/memory affected). Set "
208
- "FIREANTS_FUSED_OPS_REF to a compatible FireANTs tag, or install a prebuilt fireants-fused-ops "
209
- f"wheel, to enable them. Details: {exc}"
210
- )
211
- finally:
212
- shutil.rmtree(tmp, ignore_errors=True)
213
-
214
-
215
- def registry_choices() -> list[str]:
216
- """The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry."""
217
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
218
- return [f"{repo}:{key}" for key in load_models_registry()]
219
-
220
-
221
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
222
- """Load ``models.json`` (available feature models). ``KONFAI_IMPACT_MODELS_REGISTRY`` (local path) wins
223
- for dev/offline; otherwise ``ref`` is a ``repo:file`` Hugging Face reference (fetched, not bundled)."""
224
- from huggingface_hub import hf_hub_download
225
-
226
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
227
- if local:
228
- path = Path(local)
229
- elif ":" in ref:
230
- repo, filename = ref.split(":", 1)
231
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
232
- else:
233
- raise ValueError(
234
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference — or set "
235
- "KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
236
- )
237
- return json.loads(path.read_text(encoding="utf-8"))
238
-
239
-
240
- def _sorted_specs(mapping: dict) -> list:
241
- """A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
242
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
243
-
244
-
245
- @dataclass
246
- class ModelSpec:
247
- """One IMPACT feature model in the deformable metric (several are fused). ``ref`` picks the model; the
248
- rest are its per-model knobs — the same as the ConvexAdam / elastix ``ModelSpec`` except ``voxel_size``
249
- (an itk-impact resampling knob) has no meaning for FireANTs' geometry-free torch ``custom_loss`` and is
250
- intentionally absent."""
251
-
252
- ref: Annotated[str, Choices(registry_choices)]
253
- layers_mask: str = "01" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
254
- layers_weight: float = 1.0 # this model's weight in the multi-model fusion
255
- pca: Annotated[int, Range(0, 100)] = 0 # keep the top-K principal components of the features (0 = no PCA)
256
- distance: Literal["L1", "L2"] = "L1"
257
-
258
-
259
- @contextlib.contextmanager
260
- def _no_texpr_fuser():
261
- """Disable the TensorExpr JIT fuser while IMPACT's TorchScript feature model runs under autograd.
262
-
263
- The IMPACT feature models are TorchScript; run under FireANTs' gradient optimisation the TensorExpr
264
- fuser trips on shape ops (``aten::size`` INTERNAL ASSERT). Scoped and restored so no other torch/JIT
265
- user is affected; the modern profiling executor stays on (this is NOT the legacy executor).
266
- """
267
- torch._C._jit_set_texpr_fuser_enabled(False)
268
- try:
269
- yield
270
- finally:
271
- torch._C._jit_set_texpr_fuser_enabled(True)
272
-
273
-
274
- class _ImpactCore(IMPACTReg):
275
- """One IMPACT feature model, exposed as a FireANTs ``forward(moved, fixed)``.
276
-
277
- Reuses ``IMPACTReg._compute`` / ``preprocessing`` verbatim — the stats-normalised feature extraction
278
- (the model wants per-image ``[min, mean, max, std]``) and the per-layer weighted distance — so the
279
- metric is exactly KonfAI's, not a re-derivation. Only KonfAI's config-binding ``__init__`` and its
280
- ``Attribute``-based geometry are replaced: FireANTs passes raw tensors at the current pyramid scale, so
281
- the intensity statistics are computed from those tensors directly. ``pca`` (absent from KonfAI's torch
282
- ``IMPACTReg``) is added here as a per-layer feature-space reduction matching itk-impact.
283
- """
284
-
285
- def __init__(self, ref: str, in_channels: int, weights: list[float], distance: str, pca: int) -> None:
286
- from huggingface_hub import hf_hub_download
287
-
288
- torch.nn.Module.__init__(self) # bypass IMPACTReg.__init__ (KONFAI_CONFIG_PATH / apply_config binding)
289
- self.name = "Reg"
290
- self.in_channels = int(in_channels)
291
- self.weights = [float(w) for w in weights]
292
- self.nb_layer = len(self.weights)
293
- self.loss = _DISTANCES[distance]()
294
- self.pca = int(pca) # PCA lives in KonfAI's IMPACTReg._compute (same behaviour as itk-impact)
295
- self.dim = DIM
296
- self.shape = None # score the whole (downsampled) tensor — no ModelPatch tiling
297
- if ":" in ref: # a "repo:path" HF reference; otherwise a local model file
298
- repo, filename = ref.split(":", 1)
299
- self.model_path = hf_hub_download(repo, filename, repo_type="model") # nosec B615
300
- else:
301
- self.model_path = ref
302
- self.model = None # lazy-loaded on the first forward, like IMPACTReg
303
-
304
- @staticmethod
305
- def _stats(tensor: torch.Tensor) -> dict:
306
- detached = tensor.detach()
307
- return {
308
- "ImageMin": float(detached.min()),
309
- "ImageMean": float(detached.mean()),
310
- "ImageMax": float(detached.max()),
311
- "ImageStd": float(detached.std()),
312
- }
313
-
314
- def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor: # type: ignore[override]
315
- if self.model is None:
316
- self.model = torch.jit.load(self.model_path) # nosec B614
317
- self.model.to(moved.device).eval()
318
- with _no_texpr_fuser():
319
- loss, true_nb = self._compute(moved, [self._stats(moved)], fixed, [self._stats(fixed)], None)
320
- return loss / max(true_nb, 1)
321
-
322
-
323
- class ImpactFeatureLoss(torch.nn.Module):
324
- """FireANTs ``custom_loss`` = the KonfAI IMPACT metric fused over several feature models.
325
-
326
- ``forward(moved, fixed)`` sums each model's ``layers_weight * IMPACT(model)``. A model's per-layer
327
- weights come from its ``layers_mask`` bitmask; its input channel count is read from the registry
328
- (``models.json`` ``numberofchannels``) so it never has to be configured by hand.
329
- """
330
-
331
- def __init__(self, specs: list["ModelSpec"]) -> None:
332
- super().__init__()
333
- registry = load_models_registry()
334
- self._cores = torch.nn.ModuleList()
335
- self._model_weights: list[float] = []
336
- for spec in specs:
337
- in_channels = int(registry.get(spec.ref.split(":", 1)[-1], {}).get("numberofchannels", 1))
338
- weights = [1.0 if char == "1" else 0.0 for char in spec.layers_mask]
339
- self._cores.append(_ImpactCore(spec.ref, in_channels, weights, spec.distance, spec.pca))
340
- self._model_weights.append(float(spec.layers_weight))
341
-
342
- def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor:
343
- total: torch.Tensor | None = None
344
- for weight, core in zip(self._model_weights, self._cores, strict=True):
345
- term = weight * core(moved, fixed)
346
- total = term if total is None else total + term
347
- return total
348
-
349
-
350
- class FireANTsEngine:
351
- """Register a fixed/moving pair with FireANTs (Rigid -> Affine -> [SyN | Greedy | none]); return
352
- (moved, dvf) on the fixed grid.
353
-
354
- ``fireants`` is imported lazily inside :meth:`register` so this module can be imported for config
355
- /signature introspection (SlicerImpactReg reads the tuning knobs off the ``RegistrationNet``
356
- annotations) on a machine without a GPU or without FireANTs installed.
357
- """
358
-
359
- def __init__(
360
- self,
361
- scales: list[int],
362
- affine_iterations: list[int],
363
- deformable_iterations: list[int],
364
- cc_kernel: int,
365
- affine_metric: str,
366
- affine_lr: float,
367
- deformable_method: str,
368
- deformable_metric: str,
369
- deformable_lr: float,
370
- integrator_n: int,
371
- smooth_warp_sigma: float,
372
- smooth_grad_sigma: float,
373
- seed: int,
374
- impact_specs: list["ModelSpec"],
375
- ) -> None:
376
- self._scales = [int(s) for s in scales]
377
- self._affine_iterations = [int(i) for i in affine_iterations]
378
- self._deformable_iterations = [int(i) for i in deformable_iterations]
379
- self._cc_kernel = int(cc_kernel)
380
- self._affine_metric = affine_metric
381
- self._affine_lr = float(affine_lr)
382
- self._deformable_method = deformable_method
383
- self._deformable_metric = deformable_metric
384
- self._deformable_lr = float(deformable_lr)
385
- self._integrator_n = int(integrator_n)
386
- self._smooth_warp_sigma = float(smooth_warp_sigma)
387
- self._smooth_grad_sigma = float(smooth_grad_sigma)
388
- self._seed = int(seed)
389
- # IMPACT deformable metric (only used when deformable_metric == "impact"): KonfAI IMPACT feature
390
- # models drive the SyN/greedy stage instead of the analytic CC/MI/MSE.
391
- self._impact_specs = impact_specs
392
-
393
- @staticmethod
394
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
395
- """True only for a mask that actually restricts the region — some voxels in, some out. An absent
396
- optional mask arrives as a whole-image (all-ones) default and an all-zero mask is degenerate; both
397
- are treated as no mask so the plain (non-masked) metric path is used."""
398
- if mask is None:
399
- return False
400
- arr = sitk.GetArrayViewFromImage(mask)
401
- return bool((arr > 0).any()) and bool((arr == 0).any())
402
-
403
- @staticmethod
404
- def _affine_to_sitk(affine_matrix: "torch.Tensor") -> sitk.AffineTransform:
405
- """FireANTs' physical (LPS) linear matrix -> SimpleITK AffineTransform (fixed -> moving points),
406
- the same convention FireANTs writes into an ANTs ``0GenericAffine.mat``."""
407
- matrix = affine_matrix.float().cpu().numpy()[0]
408
- affine = sitk.AffineTransform(DIM)
409
- affine.SetMatrix(matrix[:DIM, :DIM].flatten().astype(np.float64))
410
- affine.SetTranslation(matrix[:DIM, DIM].astype(np.float64))
411
- return affine
412
-
413
- def _total_field_transform(self, reg) -> sitk.Transform:
414
- """Optimise a deformable stage and return its TOTAL displacement (affine baked in) as a
415
- SimpleITK ``DisplacementFieldTransform`` on the fixed grid.
416
-
417
- FireANTs serialises the total field (ANTs convention, fixed grid) only to a file, so it is
418
- round-tripped through a temporary NIfTI — its public API, no internals reimplemented."""
419
- reg.optimize()
420
- with tempfile.TemporaryDirectory() as tmp:
421
- warp_path = os.path.join(tmp, "total_warp.nii.gz")
422
- reg.save_as_ants_transforms(warp_path)
423
- total_field = sitk.ReadImage(warp_path, sitk.sitkVectorFloat64)
424
- return sitk.DisplacementFieldTransform(total_field) # consumes total_field
425
-
426
- def register(
427
- self,
428
- fixed: sitk.Image,
429
- moving: sitk.Image,
430
- device_index: int,
431
- fixed_mask: sitk.Image | None = None,
432
- moving_mask: sitk.Image | None = None,
433
- ) -> tuple[np.ndarray, np.ndarray]:
434
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
435
- ensure_fireants_runtime()
436
- from fireants.io import BatchedImages, Image
437
- from fireants.io.imagemask import apply_mask_to_image, generate_image_mask_allones
438
- from fireants.registration.affine import AffineRegistration
439
- from fireants.registration.rigid import RigidRegistration
440
-
441
- torch.manual_seed(self._seed)
442
- device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
443
- # FireANTs' Image ctor accepts a SimpleITK image directly, so the fixed/moving cross into
444
- # FireANTs in-memory (no file load) with their geometry preserved.
445
- fixed_img = Image(fixed, device=device)
446
- moving_img = Image(moving, device=device)
447
-
448
- # Masked metric only when a mask genuinely restricts the region. FireANTs' masked mode wants the
449
- # mask as the last channel of BOTH images (all-ones where one side has none) and a ``masked_``
450
- # metric prefix; the plain path is untouched when no real mask is present.
451
- use_fixed_mask = self._is_partial_mask(fixed_mask)
452
- use_moving_mask = self._is_partial_mask(moving_mask)
453
- masked = use_fixed_mask or use_moving_mask
454
- if masked:
455
- fmask = Image(fixed_mask, device=device) if use_fixed_mask else generate_image_mask_allones(fixed_img)
456
- mmask = Image(moving_mask, device=device) if use_moving_mask else generate_image_mask_allones(moving_img)
457
- fixed_img = apply_mask_to_image(fixed_img, fmask)
458
- moving_img = apply_mask_to_image(moving_img, mmask)
459
-
460
- bf = BatchedImages([fixed_img])
461
- bm = BatchedImages([moving_img])
462
- affine_loss = f"masked_{self._affine_metric}" if masked else self._affine_metric
463
- deformable_loss = f"masked_{self._deformable_metric}" if masked else self._deformable_metric
464
-
465
- # Linear: Rigid(MI, COM init) -> Affine(MI, seeded by the rigid), mirroring ANTs. The affine
466
- # seeds the deformable stage (or is the whole transform when deformable_method == "none").
467
- rigid = RigidRegistration(
468
- scales=self._scales,
469
- iterations=self._affine_iterations,
470
- fixed_images=bf,
471
- moving_images=bm,
472
- loss_type=affine_loss,
473
- optimizer="Adam",
474
- optimizer_lr=self._affine_lr,
475
- cc_kernel_size=self._cc_kernel,
476
- init_translation="cof",
477
- )
478
- rigid.optimize()
479
- rigid_matrix = rigid.get_rigid_matrix().detach()
480
-
481
- affine = AffineRegistration(
482
- scales=self._scales,
483
- iterations=self._affine_iterations,
484
- fixed_images=bf,
485
- moving_images=bm,
486
- loss_type=affine_loss,
487
- optimizer="Adam",
488
- optimizer_lr=self._affine_lr,
489
- cc_kernel_size=self._cc_kernel,
490
- init_rigid=rigid_matrix,
491
- )
492
- affine.optimize()
493
- affine_matrix = affine.get_affine_matrix().detach()
494
-
495
- # Deformable stage (or none). SyN and Greedy share the same constructor surface; both warm-start
496
- # from the affine so their TOTAL transform already bakes in the linear pre-align.
497
- if self._deformable_method == "none":
498
- transform: sitk.Transform = self._affine_to_sitk(affine_matrix)
499
- else:
500
- if self._deformable_method == "syn":
501
- from fireants.registration.syn import SyNRegistration as Deformable
502
- elif self._deformable_method == "greedy":
503
- from fireants.registration.greedy import GreedyRegistration as Deformable
504
- else:
505
- raise ValueError(
506
- f"Unknown deformable_method '{self._deformable_method}' (expected 'syn', 'greedy' or 'none')."
507
- )
508
- # "impact" swaps the analytic metric for a KonfAI IMPACT feature loss on the deformable stage
509
- # (the linear pre-align keeps its own affine_metric); masks do not restrict the IMPACT metric.
510
- if self._deformable_metric == "impact":
511
- loss_type: str = "custom"
512
- custom_loss: torch.nn.Module | None = ImpactFeatureLoss(self._impact_specs)
513
- else:
514
- loss_type, custom_loss = deformable_loss, None
515
- reg = Deformable(
516
- scales=self._scales,
517
- iterations=self._deformable_iterations,
518
- fixed_images=bf,
519
- moving_images=bm,
520
- loss_type=loss_type,
521
- custom_loss=custom_loss,
522
- cc_kernel_size=self._cc_kernel,
523
- deformation_type="compositive",
524
- integrator_n=self._integrator_n,
525
- smooth_warp_sigma=self._smooth_warp_sigma,
526
- smooth_grad_sigma=self._smooth_grad_sigma,
527
- optimizer="Adam",
528
- optimizer_lr=self._deformable_lr,
529
- init_affine=affine_matrix,
530
- )
531
- transform = self._total_field_transform(reg)
532
-
533
- if torch.cuda.is_available():
534
- torch.cuda.synchronize()
535
-
536
- # Rebuild moved + DVF from the single transform on the fixed grid — the ConvexAdam output path,
537
- # so every FireANTs preset emits identical-shaped results.
538
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
539
- dvf = sitk.TransformToDisplacementField(
540
- transform,
541
- sitk.sitkVectorFloat64,
542
- fixed.GetSize(),
543
- fixed.GetOrigin(),
544
- fixed.GetSpacing(),
545
- fixed.GetDirection(),
546
- )
547
- moved_np, _ = image_to_data(moved)
548
- dvf_np, _ = image_to_data(dvf)
549
- return moved_np, dvf_np
550
-
551
-
552
- class FireANTsRegistration(torch.nn.Module):
553
- """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
554
-
555
- ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list
556
- alongside the tensors (same convention as the ConvexAdam / elastix engines); registration needs the
557
- physical geometry, and the mask branches restrict the metric.
558
- """
559
-
560
- accepts_attributes = True
561
-
562
- def __init__(self, engine: FireANTsEngine) -> None:
563
- super().__init__()
564
- self._engine = engine
565
-
566
- def forward(
567
- self,
568
- fixed: torch.Tensor,
569
- moving: torch.Tensor,
570
- fixed_mask: torch.Tensor,
571
- moving_mask: torch.Tensor,
572
- attributes: list[list[Attribute]],
573
- ) -> torch.Tensor:
574
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over
575
- # the batch. Returns, per sample, the moved image (1 channel) channel-stacked with the
576
- # displacement field (DIM channels); downstream ChannelSelect modules split them. A whole-image
577
- # mask (the default when none is supplied) restricts nothing.
578
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
579
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
580
- combined = []
581
- # FireANTs runs a gradient-based instance optimisation (Riemannian Adam over the warp); the
582
- # predictor calls forward under torch.inference_mode(), which forbids autograd. The image tensors
583
- # have already crossed to numpy/SimpleITK here, so re-enable grad for the optimisation.
584
- with torch.inference_mode(False), torch.enable_grad():
585
- for b in range(fixed.shape[0]):
586
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
587
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
588
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
589
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
590
- moved_np, dvf_np = self._engine.register(
591
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
592
- )
593
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
594
- return torch.stack(combined, dim=0).to(fixed.device)
595
-
596
-
597
- class ChannelSelect(torch.nn.Module):
598
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
599
-
600
- def __init__(self, start: int, stop: int) -> None:
601
- super().__init__()
602
- self._start = start
603
- self._stop = stop
604
-
605
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
606
- return tensor[:, self._start : self._stop]
607
-
608
-
609
- class RegistrationNet(network.Network):
610
- """Pairwise FireANTs registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
611
- fixed mask = 2, moving mask = 3; masks restrict the metric, whole-image = no restriction).
612
-
613
- Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
614
- (the DIM-component displacement field, in mm). Geometry is attached by the predictor via
615
- ``same_as_group: Volume_0:Fixed``. The knobs below are read straight from these annotations by the
616
- UI: ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` a dropdown. ``deformable_method``
617
- is the knob that specialises this shared model into each FireANTs preset.
618
- """
619
-
620
- def __init__(
621
- self,
622
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
623
- schedulers: dict[str, network.LRSchedulersLoader] = {
624
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
625
- },
626
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
627
- scales: list[int] = [4, 2, 1],
628
- affine_iterations: list[int] = [200, 100, 50],
629
- deformable_iterations: list[int] = [200, 100, 50],
630
- cc_kernel: Annotated[int, Range(1, 21)] = 5,
631
- affine_metric: Literal["mi", "cc", "mse"] = "mi",
632
- affine_lr: Annotated[float, Range(0.0, 10.0)] = 0.003,
633
- deformable_method: Literal["none", "syn", "greedy"] = "syn",
634
- deformable_metric: Literal["cc", "mi", "mse", "impact"] = "cc",
635
- deformable_lr: Annotated[float, Range(0.0, 10.0)] = 0.25,
636
- integrator_n: Annotated[int, Range(1, 100)] = 10,
637
- smooth_warp_sigma: Annotated[float, Range(0.0, 100.0)] = 0.5,
638
- smooth_grad_sigma: Annotated[float, Range(0.0, 100.0)] = 1.0,
639
- seed: int = 42,
640
- models: dict[str, ModelSpec] = {},
641
- ) -> None:
642
- super().__init__(
643
- in_channels=1,
644
- optimizer=optimizer,
645
- schedulers=schedulers,
646
- outputs_criterions=outputs_criterions,
647
- dim=3,
648
- )
649
- engine = FireANTsEngine(
650
- scales,
651
- affine_iterations,
652
- deformable_iterations,
653
- cc_kernel,
654
- affine_metric,
655
- affine_lr,
656
- deformable_method,
657
- deformable_metric,
658
- deformable_lr,
659
- integrator_n,
660
- smooth_warp_sigma,
661
- smooth_grad_sigma,
662
- seed,
663
- _sorted_specs(models),
664
- )
665
- self.add_module(
666
- "Registration", FireANTsRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
667
- )
668
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
669
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
FireANTs_Affine/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  scales:
6
  - 4
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.fireants:RegistrationNet
4
  RegistrationNet:
5
  scales:
6
  - 4
FireANTs_Affine/requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  hydra-core
2
  nibabel
3
  pandas
 
 
1
  hydra-core
2
  nibabel
3
  pandas
4
+ impact_reg_konfai>=1.6.1
FireANTs_IMPACT/Model.py DELETED
@@ -1,669 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
- #
17
- # This wrapper does NOT copy any FireANTs source: it only calls the public FireANTs API of the
18
- # separately-installed ``fireants`` wheel (PyPI). FireANTs is distributed under the FireANTs License
19
- # v1.0 and must be cited — see the NOTICE file in this directory for the license, copyright and
20
- # bibliography that ship with this app.
21
-
22
- """FireANTs registration as a self-contained KonfAI model (shared by the FireANTs presets).
23
-
24
- Same idiomatic ``add_module`` graph and the same output contract as the ConvexAdam preset
25
- (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``), so the
26
- orchestrator / app.json / ensemble / uncertainty are unchanged. The engine chains FireANTs' own
27
- composable stages (GPU, Riemannian Adam), each seeding the next like ANTs' ``-t`` stages:
28
-
29
- Rigid (MI, centre-of-mass init) -> Affine (MI, seeded by the rigid) -> deformable
30
-
31
- The deformable stage is selected by ``deformable_method`` — the ONE knob that specialises this shared
32
- Model.py into the different presets (exactly as ConvexAdam's shared Model.py is specialised by
33
- ``stages``):
34
-
35
- "syn" symmetric diffeomorphic SyN (CC) — invertible, higher quality, averages cleanly for ensembling
36
- "greedy" greedy diffeomorphic (CC) — one-directional, faster / lower VRAM
37
- "none" linear only — Rigid+Affine, no deformable (the FireANTs_Affine preset)
38
-
39
- Masks: the optional Fixed/Moving masks restrict the metric to a region. FireANTs implements this by
40
- carrying the mask as the last image channel and prefixing the metric with ``masked_``; a mask is only
41
- honoured when it actually restricts (some voxels in, some out), so the common mask-free path is
42
- unchanged (an absent optional mask arrives as a whole-image default and is treated as no mask).
43
-
44
- The deformable stages produce the single TOTAL displacement field on the fixed grid (the linear
45
- pre-align is baked in via ``init_affine``, ANTs convention); ``none`` uses the affine matrix directly.
46
- ``MovedImage`` and the emitted ``DisplacementField`` are rebuilt from that transform with SimpleITK —
47
- the same output path as the ConvexAdam engine — so all presets/engines are interchangeable in an
48
- ensemble. FireANTs' output-transform writer only serialises to a file, so the deformable field is
49
- round-tripped through a temporary NIfTI (no FireANTs internals are reimplemented here).
50
-
51
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
52
- runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
53
- """
54
-
55
- import contextlib
56
- import json
57
- import os
58
- import tempfile
59
- from dataclasses import dataclass
60
- from pathlib import Path
61
- from typing import Annotated, Literal
62
-
63
- import numpy as np
64
- import SimpleITK as sitk
65
- import torch
66
- from konfai.metric.measure import IMPACTReg
67
- from konfai.network import network
68
- from konfai.utils.config import Choices, Range
69
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
70
-
71
- DIM = 3
72
-
73
- # Feature-model registry (models.json): the available IMPACT feature models, fetched from HF (NOT bundled).
74
- # Only consulted by the "impact" deformable metric; ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins
75
- # for dev/offline. Mirrors the ConvexAdam preset so the same 30-model catalogue and picker are shared.
76
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
77
-
78
- _DISTANCES: dict[str, type[torch.nn.Module]] = {"L1": torch.nn.L1Loss, "L2": torch.nn.MSELoss}
79
-
80
-
81
- def _fireants_git_ref() -> str:
82
- """Best-effort FireANTs git ref whose ``fused_ops`` matches the installed ``fireants``.
83
-
84
- Overridable with ``FIREANTS_FUSED_OPS_REF``; falls back to ``main`` if the version is unknown.
85
- """
86
- try:
87
- import importlib.metadata
88
-
89
- version = importlib.metadata.version("fireants").strip()
90
- if version:
91
- return version if version.startswith("v") else f"v{version}"
92
- except Exception:
93
- pass
94
- return "main"
95
-
96
-
97
- def ensure_fireants_runtime(build_kernels: bool = True) -> None:
98
- """Make the ``fireants`` runtime importable before a preset uses it — best-effort, never fatal.
99
-
100
- A plain ``pip install fireants`` fails inside a host like 3D Slicer for two reasons, both handled
101
- here with a clear one-line status at each step:
102
-
103
- 1. **fireants won't install.** It pins ``simpleitk==2.2.1`` (no wheel on modern Python), while the
104
- host already ships a newer SimpleITK. We install it with ``--no-deps`` so that pin is ignored
105
- and the host's SimpleITK/torch are reused; its light deps ship in ``requirements.txt``.
106
- 2. **The fused CUDA kernels** (``fireants_fused_ops``) that make registration fast and
107
- memory-light are OPTIONAL. Without them fireants runs in pure PyTorch (correct, only slower).
108
- We enable them only when a CUDA compiler (``nvcc``) is present, compiling from the upstream
109
- FireANTs source at install time — nothing is vendored into this app. No compiler, or a failed
110
- build, simply falls back to pure PyTorch.
111
- """
112
- import importlib
113
- import shutil
114
- import subprocess
115
- import sys
116
-
117
- def _log(message: str) -> None:
118
- print(f"[FireANTs] {message}", flush=True)
119
-
120
- # 1) fireants itself — install --no-deps to sidestep its unsatisfiable ``simpleitk==2.2.1`` pin.
121
- try:
122
- importlib.import_module("fireants")
123
- except Exception:
124
- _log("installing fireants (--no-deps, reusing the host's SimpleITK/torch)...")
125
- try:
126
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "--no-deps", "fireants"])
127
- importlib.invalidate_caches()
128
- importlib.import_module("fireants")
129
- _log("fireants installed.")
130
- except Exception as exc:
131
- raise RuntimeError(
132
- "Could not install 'fireants'. Install it manually with:\n"
133
- f" {sys.executable} -m pip install --no-deps fireants\n"
134
- "(its hydra-core/nibabel/pandas dependencies ship in this app's requirements.txt).\n"
135
- f"Original error: {exc}"
136
- ) from exc
137
-
138
- if not build_kernels or os.environ.get("FIREANTS_SKIP_FUSED_OPS", "").strip().lower() in {"1", "true", "yes"}:
139
- _log("skipping the fused CUDA kernels -> pure PyTorch (correct, slower and more memory).")
140
- return
141
-
142
- # 2) fused CUDA kernels — optional accelerator.
143
- try:
144
- importlib.import_module("fireants_fused_ops")
145
- _log("fused CUDA kernels already available.")
146
- return
147
- except Exception:
148
- pass
149
-
150
- # 2a) a prebuilt wheel matching this torch's CUDA build, if one is ever published.
151
- cuda_tag = ""
152
- try:
153
- cuda_tag = (torch.version.cuda or "").replace(".", "")
154
- except Exception:
155
- pass
156
- if cuda_tag:
157
- wheel = f"fireants-fused-ops-cu{cuda_tag}"
158
- try:
159
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", wheel])
160
- importlib.invalidate_caches()
161
- importlib.import_module("fireants_fused_ops")
162
- _log(f"installed prebuilt CUDA kernels ({wheel}).")
163
- return
164
- except Exception:
165
- pass # no matching wheel -> try a local build
166
-
167
- # 2b) local build is OPT-IN: compiling CUDA kernels is heavy and can exhaust RAM on the user's
168
- # machine, so it NEVER runs by default. Set FIREANTS_BUILD_KERNELS=1 to enable it (devs with a
169
- # CUDA toolkit); it then builds ONE file at a time (MAX_JOBS=1) to keep memory bounded. The clean
170
- # path for end users is a prebuilt wheel (2a) — a local compile also needs Python dev headers
171
- # (absent from some embedded Pythons, e.g. Slicer) and a CUDA-compatible host compiler.
172
- if os.environ.get("FIREANTS_BUILD_KERNELS", "").strip().lower() not in ("1", "true", "yes"):
173
- _log(
174
- "no prebuilt kernels for this platform -> running FireANTs in pure PyTorch (correct, slower "
175
- "and heavier). Set FIREANTS_BUILD_KERNELS=1 to compile them locally (needs a CUDA toolkit)."
176
- )
177
- return
178
- cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
179
- nvcc = shutil.which("nvcc") or (os.path.join(cuda_home, "bin", "nvcc") if cuda_home else None)
180
- if not nvcc or not os.path.exists(nvcc) or shutil.which("git") is None:
181
- _log("FIREANTS_BUILD_KERNELS set but no CUDA toolkit (nvcc) / git found -> pure PyTorch.")
182
- return
183
-
184
- ref = os.environ.get("FIREANTS_FUSED_OPS_REF") or _fireants_git_ref()
185
- _log(f"nvcc found ({nvcc}); compiling the FireANTs CUDA kernels one file at a time (ref '{ref}')...")
186
- import tempfile
187
-
188
- env = os.environ.copy()
189
- env["MAX_JOBS"] = "1" # one compile at a time -> bounded RAM (prevents OOM on large hosts)
190
- env.setdefault("NVCC_APPEND_FLAGS", "-allow-unsupported-compiler") # tolerate a newer host compiler
191
- tmp = tempfile.mkdtemp(prefix="fireants_fused_ops_")
192
- try:
193
- # Shallow clone WITHOUT --recursive: FireANTs' submodules (an SSH-only 'cookbook') are unrelated
194
- # to fused_ops and would otherwise abort the build with a public-key/permission error.
195
- subprocess.check_call(
196
- ["git", "clone", "--depth", "1", "--branch", ref, "https://github.com/rohitrango/FireANTs.git", tmp]
197
- )
198
- subprocess.check_call(
199
- [sys.executable, "-m", "pip", "install", "-q", "--no-build-isolation", os.path.join(tmp, "fused_ops")],
200
- env=env,
201
- )
202
- importlib.invalidate_caches()
203
- importlib.import_module("fireants_fused_ops")
204
- _log("compiled and installed the fast CUDA kernels.")
205
- except Exception as exc:
206
- _log(
207
- "kernel build failed -> running in pure PyTorch (correct, only speed/memory affected). Set "
208
- "FIREANTS_FUSED_OPS_REF to a compatible FireANTs tag, or install a prebuilt fireants-fused-ops "
209
- f"wheel, to enable them. Details: {exc}"
210
- )
211
- finally:
212
- shutil.rmtree(tmp, ignore_errors=True)
213
-
214
-
215
- def registry_choices() -> list[str]:
216
- """The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry."""
217
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
218
- return [f"{repo}:{key}" for key in load_models_registry()]
219
-
220
-
221
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
222
- """Load ``models.json`` (available feature models). ``KONFAI_IMPACT_MODELS_REGISTRY`` (local path) wins
223
- for dev/offline; otherwise ``ref`` is a ``repo:file`` Hugging Face reference (fetched, not bundled)."""
224
- from huggingface_hub import hf_hub_download
225
-
226
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
227
- if local:
228
- path = Path(local)
229
- elif ":" in ref:
230
- repo, filename = ref.split(":", 1)
231
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
232
- else:
233
- raise ValueError(
234
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference — or set "
235
- "KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
236
- )
237
- return json.loads(path.read_text(encoding="utf-8"))
238
-
239
-
240
- def _sorted_specs(mapping: dict) -> list:
241
- """A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
242
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
243
-
244
-
245
- @dataclass
246
- class ModelSpec:
247
- """One IMPACT feature model in the deformable metric (several are fused). ``ref`` picks the model; the
248
- rest are its per-model knobs — the same as the ConvexAdam / elastix ``ModelSpec`` except ``voxel_size``
249
- (an itk-impact resampling knob) has no meaning for FireANTs' geometry-free torch ``custom_loss`` and is
250
- intentionally absent."""
251
-
252
- ref: Annotated[str, Choices(registry_choices)]
253
- layers_mask: str = "01" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
254
- layers_weight: float = 1.0 # this model's weight in the multi-model fusion
255
- pca: Annotated[int, Range(0, 100)] = 0 # keep the top-K principal components of the features (0 = no PCA)
256
- distance: Literal["L1", "L2"] = "L1"
257
-
258
-
259
- @contextlib.contextmanager
260
- def _no_texpr_fuser():
261
- """Disable the TensorExpr JIT fuser while IMPACT's TorchScript feature model runs under autograd.
262
-
263
- The IMPACT feature models are TorchScript; run under FireANTs' gradient optimisation the TensorExpr
264
- fuser trips on shape ops (``aten::size`` INTERNAL ASSERT). Scoped and restored so no other torch/JIT
265
- user is affected; the modern profiling executor stays on (this is NOT the legacy executor).
266
- """
267
- torch._C._jit_set_texpr_fuser_enabled(False)
268
- try:
269
- yield
270
- finally:
271
- torch._C._jit_set_texpr_fuser_enabled(True)
272
-
273
-
274
- class _ImpactCore(IMPACTReg):
275
- """One IMPACT feature model, exposed as a FireANTs ``forward(moved, fixed)``.
276
-
277
- Reuses ``IMPACTReg._compute`` / ``preprocessing`` verbatim — the stats-normalised feature extraction
278
- (the model wants per-image ``[min, mean, max, std]``) and the per-layer weighted distance — so the
279
- metric is exactly KonfAI's, not a re-derivation. Only KonfAI's config-binding ``__init__`` and its
280
- ``Attribute``-based geometry are replaced: FireANTs passes raw tensors at the current pyramid scale, so
281
- the intensity statistics are computed from those tensors directly. ``pca`` (absent from KonfAI's torch
282
- ``IMPACTReg``) is added here as a per-layer feature-space reduction matching itk-impact.
283
- """
284
-
285
- def __init__(self, ref: str, in_channels: int, weights: list[float], distance: str, pca: int) -> None:
286
- from huggingface_hub import hf_hub_download
287
-
288
- torch.nn.Module.__init__(self) # bypass IMPACTReg.__init__ (KONFAI_CONFIG_PATH / apply_config binding)
289
- self.name = "Reg"
290
- self.in_channels = int(in_channels)
291
- self.weights = [float(w) for w in weights]
292
- self.nb_layer = len(self.weights)
293
- self.loss = _DISTANCES[distance]()
294
- self.pca = int(pca) # PCA lives in KonfAI's IMPACTReg._compute (same behaviour as itk-impact)
295
- self.dim = DIM
296
- self.shape = None # score the whole (downsampled) tensor — no ModelPatch tiling
297
- if ":" in ref: # a "repo:path" HF reference; otherwise a local model file
298
- repo, filename = ref.split(":", 1)
299
- self.model_path = hf_hub_download(repo, filename, repo_type="model") # nosec B615
300
- else:
301
- self.model_path = ref
302
- self.model = None # lazy-loaded on the first forward, like IMPACTReg
303
-
304
- @staticmethod
305
- def _stats(tensor: torch.Tensor) -> dict:
306
- detached = tensor.detach()
307
- return {
308
- "ImageMin": float(detached.min()),
309
- "ImageMean": float(detached.mean()),
310
- "ImageMax": float(detached.max()),
311
- "ImageStd": float(detached.std()),
312
- }
313
-
314
- def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor: # type: ignore[override]
315
- if self.model is None:
316
- self.model = torch.jit.load(self.model_path) # nosec B614
317
- self.model.to(moved.device).eval()
318
- with _no_texpr_fuser():
319
- loss, true_nb = self._compute(moved, [self._stats(moved)], fixed, [self._stats(fixed)], None)
320
- return loss / max(true_nb, 1)
321
-
322
-
323
- class ImpactFeatureLoss(torch.nn.Module):
324
- """FireANTs ``custom_loss`` = the KonfAI IMPACT metric fused over several feature models.
325
-
326
- ``forward(moved, fixed)`` sums each model's ``layers_weight * IMPACT(model)``. A model's per-layer
327
- weights come from its ``layers_mask`` bitmask; its input channel count is read from the registry
328
- (``models.json`` ``numberofchannels``) so it never has to be configured by hand.
329
- """
330
-
331
- def __init__(self, specs: list["ModelSpec"]) -> None:
332
- super().__init__()
333
- registry = load_models_registry()
334
- self._cores = torch.nn.ModuleList()
335
- self._model_weights: list[float] = []
336
- for spec in specs:
337
- in_channels = int(registry.get(spec.ref.split(":", 1)[-1], {}).get("numberofchannels", 1))
338
- weights = [1.0 if char == "1" else 0.0 for char in spec.layers_mask]
339
- self._cores.append(_ImpactCore(spec.ref, in_channels, weights, spec.distance, spec.pca))
340
- self._model_weights.append(float(spec.layers_weight))
341
-
342
- def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor:
343
- total: torch.Tensor | None = None
344
- for weight, core in zip(self._model_weights, self._cores, strict=True):
345
- term = weight * core(moved, fixed)
346
- total = term if total is None else total + term
347
- return total
348
-
349
-
350
- class FireANTsEngine:
351
- """Register a fixed/moving pair with FireANTs (Rigid -> Affine -> [SyN | Greedy | none]); return
352
- (moved, dvf) on the fixed grid.
353
-
354
- ``fireants`` is imported lazily inside :meth:`register` so this module can be imported for config
355
- /signature introspection (SlicerImpactReg reads the tuning knobs off the ``RegistrationNet``
356
- annotations) on a machine without a GPU or without FireANTs installed.
357
- """
358
-
359
- def __init__(
360
- self,
361
- scales: list[int],
362
- affine_iterations: list[int],
363
- deformable_iterations: list[int],
364
- cc_kernel: int,
365
- affine_metric: str,
366
- affine_lr: float,
367
- deformable_method: str,
368
- deformable_metric: str,
369
- deformable_lr: float,
370
- integrator_n: int,
371
- smooth_warp_sigma: float,
372
- smooth_grad_sigma: float,
373
- seed: int,
374
- impact_specs: list["ModelSpec"],
375
- ) -> None:
376
- self._scales = [int(s) for s in scales]
377
- self._affine_iterations = [int(i) for i in affine_iterations]
378
- self._deformable_iterations = [int(i) for i in deformable_iterations]
379
- self._cc_kernel = int(cc_kernel)
380
- self._affine_metric = affine_metric
381
- self._affine_lr = float(affine_lr)
382
- self._deformable_method = deformable_method
383
- self._deformable_metric = deformable_metric
384
- self._deformable_lr = float(deformable_lr)
385
- self._integrator_n = int(integrator_n)
386
- self._smooth_warp_sigma = float(smooth_warp_sigma)
387
- self._smooth_grad_sigma = float(smooth_grad_sigma)
388
- self._seed = int(seed)
389
- # IMPACT deformable metric (only used when deformable_metric == "impact"): KonfAI IMPACT feature
390
- # models drive the SyN/greedy stage instead of the analytic CC/MI/MSE.
391
- self._impact_specs = impact_specs
392
-
393
- @staticmethod
394
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
395
- """True only for a mask that actually restricts the region — some voxels in, some out. An absent
396
- optional mask arrives as a whole-image (all-ones) default and an all-zero mask is degenerate; both
397
- are treated as no mask so the plain (non-masked) metric path is used."""
398
- if mask is None:
399
- return False
400
- arr = sitk.GetArrayViewFromImage(mask)
401
- return bool((arr > 0).any()) and bool((arr == 0).any())
402
-
403
- @staticmethod
404
- def _affine_to_sitk(affine_matrix: "torch.Tensor") -> sitk.AffineTransform:
405
- """FireANTs' physical (LPS) linear matrix -> SimpleITK AffineTransform (fixed -> moving points),
406
- the same convention FireANTs writes into an ANTs ``0GenericAffine.mat``."""
407
- matrix = affine_matrix.float().cpu().numpy()[0]
408
- affine = sitk.AffineTransform(DIM)
409
- affine.SetMatrix(matrix[:DIM, :DIM].flatten().astype(np.float64))
410
- affine.SetTranslation(matrix[:DIM, DIM].astype(np.float64))
411
- return affine
412
-
413
- def _total_field_transform(self, reg) -> sitk.Transform:
414
- """Optimise a deformable stage and return its TOTAL displacement (affine baked in) as a
415
- SimpleITK ``DisplacementFieldTransform`` on the fixed grid.
416
-
417
- FireANTs serialises the total field (ANTs convention, fixed grid) only to a file, so it is
418
- round-tripped through a temporary NIfTI — its public API, no internals reimplemented."""
419
- reg.optimize()
420
- with tempfile.TemporaryDirectory() as tmp:
421
- warp_path = os.path.join(tmp, "total_warp.nii.gz")
422
- reg.save_as_ants_transforms(warp_path)
423
- total_field = sitk.ReadImage(warp_path, sitk.sitkVectorFloat64)
424
- return sitk.DisplacementFieldTransform(total_field) # consumes total_field
425
-
426
- def register(
427
- self,
428
- fixed: sitk.Image,
429
- moving: sitk.Image,
430
- device_index: int,
431
- fixed_mask: sitk.Image | None = None,
432
- moving_mask: sitk.Image | None = None,
433
- ) -> tuple[np.ndarray, np.ndarray]:
434
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
435
- ensure_fireants_runtime()
436
- from fireants.io import BatchedImages, Image
437
- from fireants.io.imagemask import apply_mask_to_image, generate_image_mask_allones
438
- from fireants.registration.affine import AffineRegistration
439
- from fireants.registration.rigid import RigidRegistration
440
-
441
- torch.manual_seed(self._seed)
442
- device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
443
- # FireANTs' Image ctor accepts a SimpleITK image directly, so the fixed/moving cross into
444
- # FireANTs in-memory (no file load) with their geometry preserved.
445
- fixed_img = Image(fixed, device=device)
446
- moving_img = Image(moving, device=device)
447
-
448
- # Masked metric only when a mask genuinely restricts the region. FireANTs' masked mode wants the
449
- # mask as the last channel of BOTH images (all-ones where one side has none) and a ``masked_``
450
- # metric prefix; the plain path is untouched when no real mask is present.
451
- use_fixed_mask = self._is_partial_mask(fixed_mask)
452
- use_moving_mask = self._is_partial_mask(moving_mask)
453
- masked = use_fixed_mask or use_moving_mask
454
- if masked:
455
- fmask = Image(fixed_mask, device=device) if use_fixed_mask else generate_image_mask_allones(fixed_img)
456
- mmask = Image(moving_mask, device=device) if use_moving_mask else generate_image_mask_allones(moving_img)
457
- fixed_img = apply_mask_to_image(fixed_img, fmask)
458
- moving_img = apply_mask_to_image(moving_img, mmask)
459
-
460
- bf = BatchedImages([fixed_img])
461
- bm = BatchedImages([moving_img])
462
- affine_loss = f"masked_{self._affine_metric}" if masked else self._affine_metric
463
- deformable_loss = f"masked_{self._deformable_metric}" if masked else self._deformable_metric
464
-
465
- # Linear: Rigid(MI, COM init) -> Affine(MI, seeded by the rigid), mirroring ANTs. The affine
466
- # seeds the deformable stage (or is the whole transform when deformable_method == "none").
467
- rigid = RigidRegistration(
468
- scales=self._scales,
469
- iterations=self._affine_iterations,
470
- fixed_images=bf,
471
- moving_images=bm,
472
- loss_type=affine_loss,
473
- optimizer="Adam",
474
- optimizer_lr=self._affine_lr,
475
- cc_kernel_size=self._cc_kernel,
476
- init_translation="cof",
477
- )
478
- rigid.optimize()
479
- rigid_matrix = rigid.get_rigid_matrix().detach()
480
-
481
- affine = AffineRegistration(
482
- scales=self._scales,
483
- iterations=self._affine_iterations,
484
- fixed_images=bf,
485
- moving_images=bm,
486
- loss_type=affine_loss,
487
- optimizer="Adam",
488
- optimizer_lr=self._affine_lr,
489
- cc_kernel_size=self._cc_kernel,
490
- init_rigid=rigid_matrix,
491
- )
492
- affine.optimize()
493
- affine_matrix = affine.get_affine_matrix().detach()
494
-
495
- # Deformable stage (or none). SyN and Greedy share the same constructor surface; both warm-start
496
- # from the affine so their TOTAL transform already bakes in the linear pre-align.
497
- if self._deformable_method == "none":
498
- transform: sitk.Transform = self._affine_to_sitk(affine_matrix)
499
- else:
500
- if self._deformable_method == "syn":
501
- from fireants.registration.syn import SyNRegistration as Deformable
502
- elif self._deformable_method == "greedy":
503
- from fireants.registration.greedy import GreedyRegistration as Deformable
504
- else:
505
- raise ValueError(
506
- f"Unknown deformable_method '{self._deformable_method}' (expected 'syn', 'greedy' or 'none')."
507
- )
508
- # "impact" swaps the analytic metric for a KonfAI IMPACT feature loss on the deformable stage
509
- # (the linear pre-align keeps its own affine_metric); masks do not restrict the IMPACT metric.
510
- if self._deformable_metric == "impact":
511
- loss_type: str = "custom"
512
- custom_loss: torch.nn.Module | None = ImpactFeatureLoss(self._impact_specs)
513
- else:
514
- loss_type, custom_loss = deformable_loss, None
515
- reg = Deformable(
516
- scales=self._scales,
517
- iterations=self._deformable_iterations,
518
- fixed_images=bf,
519
- moving_images=bm,
520
- loss_type=loss_type,
521
- custom_loss=custom_loss,
522
- cc_kernel_size=self._cc_kernel,
523
- deformation_type="compositive",
524
- integrator_n=self._integrator_n,
525
- smooth_warp_sigma=self._smooth_warp_sigma,
526
- smooth_grad_sigma=self._smooth_grad_sigma,
527
- optimizer="Adam",
528
- optimizer_lr=self._deformable_lr,
529
- init_affine=affine_matrix,
530
- )
531
- transform = self._total_field_transform(reg)
532
-
533
- if torch.cuda.is_available():
534
- torch.cuda.synchronize()
535
-
536
- # Rebuild moved + DVF from the single transform on the fixed grid — the ConvexAdam output path,
537
- # so every FireANTs preset emits identical-shaped results.
538
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
539
- dvf = sitk.TransformToDisplacementField(
540
- transform,
541
- sitk.sitkVectorFloat64,
542
- fixed.GetSize(),
543
- fixed.GetOrigin(),
544
- fixed.GetSpacing(),
545
- fixed.GetDirection(),
546
- )
547
- moved_np, _ = image_to_data(moved)
548
- dvf_np, _ = image_to_data(dvf)
549
- return moved_np, dvf_np
550
-
551
-
552
- class FireANTsRegistration(torch.nn.Module):
553
- """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
554
-
555
- ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list
556
- alongside the tensors (same convention as the ConvexAdam / elastix engines); registration needs the
557
- physical geometry, and the mask branches restrict the metric.
558
- """
559
-
560
- accepts_attributes = True
561
-
562
- def __init__(self, engine: FireANTsEngine) -> None:
563
- super().__init__()
564
- self._engine = engine
565
-
566
- def forward(
567
- self,
568
- fixed: torch.Tensor,
569
- moving: torch.Tensor,
570
- fixed_mask: torch.Tensor,
571
- moving_mask: torch.Tensor,
572
- attributes: list[list[Attribute]],
573
- ) -> torch.Tensor:
574
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over
575
- # the batch. Returns, per sample, the moved image (1 channel) channel-stacked with the
576
- # displacement field (DIM channels); downstream ChannelSelect modules split them. A whole-image
577
- # mask (the default when none is supplied) restricts nothing.
578
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
579
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
580
- combined = []
581
- # FireANTs runs a gradient-based instance optimisation (Riemannian Adam over the warp); the
582
- # predictor calls forward under torch.inference_mode(), which forbids autograd. The image tensors
583
- # have already crossed to numpy/SimpleITK here, so re-enable grad for the optimisation.
584
- with torch.inference_mode(False), torch.enable_grad():
585
- for b in range(fixed.shape[0]):
586
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
587
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
588
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
589
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
590
- moved_np, dvf_np = self._engine.register(
591
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
592
- )
593
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
594
- return torch.stack(combined, dim=0).to(fixed.device)
595
-
596
-
597
- class ChannelSelect(torch.nn.Module):
598
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
599
-
600
- def __init__(self, start: int, stop: int) -> None:
601
- super().__init__()
602
- self._start = start
603
- self._stop = stop
604
-
605
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
606
- return tensor[:, self._start : self._stop]
607
-
608
-
609
- class RegistrationNet(network.Network):
610
- """Pairwise FireANTs registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
611
- fixed mask = 2, moving mask = 3; masks restrict the metric, whole-image = no restriction).
612
-
613
- Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
614
- (the DIM-component displacement field, in mm). Geometry is attached by the predictor via
615
- ``same_as_group: Volume_0:Fixed``. The knobs below are read straight from these annotations by the
616
- UI: ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` a dropdown. ``deformable_method``
617
- is the knob that specialises this shared model into each FireANTs preset.
618
- """
619
-
620
- def __init__(
621
- self,
622
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
623
- schedulers: dict[str, network.LRSchedulersLoader] = {
624
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
625
- },
626
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
627
- scales: list[int] = [4, 2, 1],
628
- affine_iterations: list[int] = [200, 100, 50],
629
- deformable_iterations: list[int] = [200, 100, 50],
630
- cc_kernel: Annotated[int, Range(1, 21)] = 5,
631
- affine_metric: Literal["mi", "cc", "mse"] = "mi",
632
- affine_lr: Annotated[float, Range(0.0, 10.0)] = 0.003,
633
- deformable_method: Literal["none", "syn", "greedy"] = "syn",
634
- deformable_metric: Literal["cc", "mi", "mse", "impact"] = "cc",
635
- deformable_lr: Annotated[float, Range(0.0, 10.0)] = 0.25,
636
- integrator_n: Annotated[int, Range(1, 100)] = 10,
637
- smooth_warp_sigma: Annotated[float, Range(0.0, 100.0)] = 0.5,
638
- smooth_grad_sigma: Annotated[float, Range(0.0, 100.0)] = 1.0,
639
- seed: int = 42,
640
- models: dict[str, ModelSpec] = {},
641
- ) -> None:
642
- super().__init__(
643
- in_channels=1,
644
- optimizer=optimizer,
645
- schedulers=schedulers,
646
- outputs_criterions=outputs_criterions,
647
- dim=3,
648
- )
649
- engine = FireANTsEngine(
650
- scales,
651
- affine_iterations,
652
- deformable_iterations,
653
- cc_kernel,
654
- affine_metric,
655
- affine_lr,
656
- deformable_method,
657
- deformable_metric,
658
- deformable_lr,
659
- integrator_n,
660
- smooth_warp_sigma,
661
- smooth_grad_sigma,
662
- seed,
663
- _sorted_specs(models),
664
- )
665
- self.add_module(
666
- "Registration", FireANTsRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
667
- )
668
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
669
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
FireANTs_IMPACT/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  scales:
6
  - 4
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.fireants:RegistrationNet
4
  RegistrationNet:
5
  scales:
6
  - 4
FireANTs_IMPACT/requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  hydra-core
2
  nibabel
3
  pandas
 
 
1
  hydra-core
2
  nibabel
3
  pandas
4
+ impact_reg_konfai>=1.6.1
FireANTs_SyN/Model.py DELETED
@@ -1,669 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
- #
17
- # This wrapper does NOT copy any FireANTs source: it only calls the public FireANTs API of the
18
- # separately-installed ``fireants`` wheel (PyPI). FireANTs is distributed under the FireANTs License
19
- # v1.0 and must be cited — see the NOTICE file in this directory for the license, copyright and
20
- # bibliography that ship with this app.
21
-
22
- """FireANTs registration as a self-contained KonfAI model (shared by the FireANTs presets).
23
-
24
- Same idiomatic ``add_module`` graph and the same output contract as the ConvexAdam preset
25
- (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``), so the
26
- orchestrator / app.json / ensemble / uncertainty are unchanged. The engine chains FireANTs' own
27
- composable stages (GPU, Riemannian Adam), each seeding the next like ANTs' ``-t`` stages:
28
-
29
- Rigid (MI, centre-of-mass init) -> Affine (MI, seeded by the rigid) -> deformable
30
-
31
- The deformable stage is selected by ``deformable_method`` — the ONE knob that specialises this shared
32
- Model.py into the different presets (exactly as ConvexAdam's shared Model.py is specialised by
33
- ``stages``):
34
-
35
- "syn" symmetric diffeomorphic SyN (CC) — invertible, higher quality, averages cleanly for ensembling
36
- "greedy" greedy diffeomorphic (CC) — one-directional, faster / lower VRAM
37
- "none" linear only — Rigid+Affine, no deformable (the FireANTs_Affine preset)
38
-
39
- Masks: the optional Fixed/Moving masks restrict the metric to a region. FireANTs implements this by
40
- carrying the mask as the last image channel and prefixing the metric with ``masked_``; a mask is only
41
- honoured when it actually restricts (some voxels in, some out), so the common mask-free path is
42
- unchanged (an absent optional mask arrives as a whole-image default and is treated as no mask).
43
-
44
- The deformable stages produce the single TOTAL displacement field on the fixed grid (the linear
45
- pre-align is baked in via ``init_affine``, ANTs convention); ``none`` uses the affine matrix directly.
46
- ``MovedImage`` and the emitted ``DisplacementField`` are rebuilt from that transform with SimpleITK —
47
- the same output path as the ConvexAdam engine — so all presets/engines are interchangeable in an
48
- ensemble. FireANTs' output-transform writer only serialises to a file, so the deformable field is
49
- round-tripped through a temporary NIfTI (no FireANTs internals are reimplemented here).
50
-
51
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
52
- runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
53
- """
54
-
55
- import contextlib
56
- import json
57
- import os
58
- import tempfile
59
- from dataclasses import dataclass
60
- from pathlib import Path
61
- from typing import Annotated, Literal
62
-
63
- import numpy as np
64
- import SimpleITK as sitk
65
- import torch
66
- from konfai.metric.measure import IMPACTReg
67
- from konfai.network import network
68
- from konfai.utils.config import Choices, Range
69
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
70
-
71
- DIM = 3
72
-
73
- # Feature-model registry (models.json): the available IMPACT feature models, fetched from HF (NOT bundled).
74
- # Only consulted by the "impact" deformable metric; ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins
75
- # for dev/offline. Mirrors the ConvexAdam preset so the same 30-model catalogue and picker are shared.
76
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
77
-
78
- _DISTANCES: dict[str, type[torch.nn.Module]] = {"L1": torch.nn.L1Loss, "L2": torch.nn.MSELoss}
79
-
80
-
81
- def _fireants_git_ref() -> str:
82
- """Best-effort FireANTs git ref whose ``fused_ops`` matches the installed ``fireants``.
83
-
84
- Overridable with ``FIREANTS_FUSED_OPS_REF``; falls back to ``main`` if the version is unknown.
85
- """
86
- try:
87
- import importlib.metadata
88
-
89
- version = importlib.metadata.version("fireants").strip()
90
- if version:
91
- return version if version.startswith("v") else f"v{version}"
92
- except Exception:
93
- pass
94
- return "main"
95
-
96
-
97
- def ensure_fireants_runtime(build_kernels: bool = True) -> None:
98
- """Make the ``fireants`` runtime importable before a preset uses it — best-effort, never fatal.
99
-
100
- A plain ``pip install fireants`` fails inside a host like 3D Slicer for two reasons, both handled
101
- here with a clear one-line status at each step:
102
-
103
- 1. **fireants won't install.** It pins ``simpleitk==2.2.1`` (no wheel on modern Python), while the
104
- host already ships a newer SimpleITK. We install it with ``--no-deps`` so that pin is ignored
105
- and the host's SimpleITK/torch are reused; its light deps ship in ``requirements.txt``.
106
- 2. **The fused CUDA kernels** (``fireants_fused_ops``) that make registration fast and
107
- memory-light are OPTIONAL. Without them fireants runs in pure PyTorch (correct, only slower).
108
- We enable them only when a CUDA compiler (``nvcc``) is present, compiling from the upstream
109
- FireANTs source at install time — nothing is vendored into this app. No compiler, or a failed
110
- build, simply falls back to pure PyTorch.
111
- """
112
- import importlib
113
- import shutil
114
- import subprocess
115
- import sys
116
-
117
- def _log(message: str) -> None:
118
- print(f"[FireANTs] {message}", flush=True)
119
-
120
- # 1) fireants itself — install --no-deps to sidestep its unsatisfiable ``simpleitk==2.2.1`` pin.
121
- try:
122
- importlib.import_module("fireants")
123
- except Exception:
124
- _log("installing fireants (--no-deps, reusing the host's SimpleITK/torch)...")
125
- try:
126
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "--no-deps", "fireants"])
127
- importlib.invalidate_caches()
128
- importlib.import_module("fireants")
129
- _log("fireants installed.")
130
- except Exception as exc:
131
- raise RuntimeError(
132
- "Could not install 'fireants'. Install it manually with:\n"
133
- f" {sys.executable} -m pip install --no-deps fireants\n"
134
- "(its hydra-core/nibabel/pandas dependencies ship in this app's requirements.txt).\n"
135
- f"Original error: {exc}"
136
- ) from exc
137
-
138
- if not build_kernels or os.environ.get("FIREANTS_SKIP_FUSED_OPS", "").strip().lower() in {"1", "true", "yes"}:
139
- _log("skipping the fused CUDA kernels -> pure PyTorch (correct, slower and more memory).")
140
- return
141
-
142
- # 2) fused CUDA kernels — optional accelerator.
143
- try:
144
- importlib.import_module("fireants_fused_ops")
145
- _log("fused CUDA kernels already available.")
146
- return
147
- except Exception:
148
- pass
149
-
150
- # 2a) a prebuilt wheel matching this torch's CUDA build, if one is ever published.
151
- cuda_tag = ""
152
- try:
153
- cuda_tag = (torch.version.cuda or "").replace(".", "")
154
- except Exception:
155
- pass
156
- if cuda_tag:
157
- wheel = f"fireants-fused-ops-cu{cuda_tag}"
158
- try:
159
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", wheel])
160
- importlib.invalidate_caches()
161
- importlib.import_module("fireants_fused_ops")
162
- _log(f"installed prebuilt CUDA kernels ({wheel}).")
163
- return
164
- except Exception:
165
- pass # no matching wheel -> try a local build
166
-
167
- # 2b) local build is OPT-IN: compiling CUDA kernels is heavy and can exhaust RAM on the user's
168
- # machine, so it NEVER runs by default. Set FIREANTS_BUILD_KERNELS=1 to enable it (devs with a
169
- # CUDA toolkit); it then builds ONE file at a time (MAX_JOBS=1) to keep memory bounded. The clean
170
- # path for end users is a prebuilt wheel (2a) — a local compile also needs Python dev headers
171
- # (absent from some embedded Pythons, e.g. Slicer) and a CUDA-compatible host compiler.
172
- if os.environ.get("FIREANTS_BUILD_KERNELS", "").strip().lower() not in ("1", "true", "yes"):
173
- _log(
174
- "no prebuilt kernels for this platform -> running FireANTs in pure PyTorch (correct, slower "
175
- "and heavier). Set FIREANTS_BUILD_KERNELS=1 to compile them locally (needs a CUDA toolkit)."
176
- )
177
- return
178
- cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
179
- nvcc = shutil.which("nvcc") or (os.path.join(cuda_home, "bin", "nvcc") if cuda_home else None)
180
- if not nvcc or not os.path.exists(nvcc) or shutil.which("git") is None:
181
- _log("FIREANTS_BUILD_KERNELS set but no CUDA toolkit (nvcc) / git found -> pure PyTorch.")
182
- return
183
-
184
- ref = os.environ.get("FIREANTS_FUSED_OPS_REF") or _fireants_git_ref()
185
- _log(f"nvcc found ({nvcc}); compiling the FireANTs CUDA kernels one file at a time (ref '{ref}')...")
186
- import tempfile
187
-
188
- env = os.environ.copy()
189
- env["MAX_JOBS"] = "1" # one compile at a time -> bounded RAM (prevents OOM on large hosts)
190
- env.setdefault("NVCC_APPEND_FLAGS", "-allow-unsupported-compiler") # tolerate a newer host compiler
191
- tmp = tempfile.mkdtemp(prefix="fireants_fused_ops_")
192
- try:
193
- # Shallow clone WITHOUT --recursive: FireANTs' submodules (an SSH-only 'cookbook') are unrelated
194
- # to fused_ops and would otherwise abort the build with a public-key/permission error.
195
- subprocess.check_call(
196
- ["git", "clone", "--depth", "1", "--branch", ref, "https://github.com/rohitrango/FireANTs.git", tmp]
197
- )
198
- subprocess.check_call(
199
- [sys.executable, "-m", "pip", "install", "-q", "--no-build-isolation", os.path.join(tmp, "fused_ops")],
200
- env=env,
201
- )
202
- importlib.invalidate_caches()
203
- importlib.import_module("fireants_fused_ops")
204
- _log("compiled and installed the fast CUDA kernels.")
205
- except Exception as exc:
206
- _log(
207
- "kernel build failed -> running in pure PyTorch (correct, only speed/memory affected). Set "
208
- "FIREANTS_FUSED_OPS_REF to a compatible FireANTs tag, or install a prebuilt fireants-fused-ops "
209
- f"wheel, to enable them. Details: {exc}"
210
- )
211
- finally:
212
- shutil.rmtree(tmp, ignore_errors=True)
213
-
214
-
215
- def registry_choices() -> list[str]:
216
- """The per-model ``ref`` picker's values — model refs (``repo:path``) from the feature-model registry."""
217
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
218
- return [f"{repo}:{key}" for key in load_models_registry()]
219
-
220
-
221
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
222
- """Load ``models.json`` (available feature models). ``KONFAI_IMPACT_MODELS_REGISTRY`` (local path) wins
223
- for dev/offline; otherwise ``ref`` is a ``repo:file`` Hugging Face reference (fetched, not bundled)."""
224
- from huggingface_hub import hf_hub_download
225
-
226
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
227
- if local:
228
- path = Path(local)
229
- elif ":" in ref:
230
- repo, filename = ref.split(":", 1)
231
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
232
- else:
233
- raise ValueError(
234
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference — or set "
235
- "KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
236
- )
237
- return json.loads(path.read_text(encoding="utf-8"))
238
-
239
-
240
- def _sorted_specs(mapping: dict) -> list:
241
- """A dict keyed by string indices ('0','1',...) -> its values in numeric order."""
242
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
243
-
244
-
245
- @dataclass
246
- class ModelSpec:
247
- """One IMPACT feature model in the deformable metric (several are fused). ``ref`` picks the model; the
248
- rest are its per-model knobs — the same as the ConvexAdam / elastix ``ModelSpec`` except ``voxel_size``
249
- (an itk-impact resampling knob) has no meaning for FireANTs' geometry-free torch ``custom_loss`` and is
250
- intentionally absent."""
251
-
252
- ref: Annotated[str, Choices(registry_choices)]
253
- layers_mask: str = "01" # per-layer bitmask, one char per model layer ('1' = use, '0' = skip), like elastix
254
- layers_weight: float = 1.0 # this model's weight in the multi-model fusion
255
- pca: Annotated[int, Range(0, 100)] = 0 # keep the top-K principal components of the features (0 = no PCA)
256
- distance: Literal["L1", "L2"] = "L1"
257
-
258
-
259
- @contextlib.contextmanager
260
- def _no_texpr_fuser():
261
- """Disable the TensorExpr JIT fuser while IMPACT's TorchScript feature model runs under autograd.
262
-
263
- The IMPACT feature models are TorchScript; run under FireANTs' gradient optimisation the TensorExpr
264
- fuser trips on shape ops (``aten::size`` INTERNAL ASSERT). Scoped and restored so no other torch/JIT
265
- user is affected; the modern profiling executor stays on (this is NOT the legacy executor).
266
- """
267
- torch._C._jit_set_texpr_fuser_enabled(False)
268
- try:
269
- yield
270
- finally:
271
- torch._C._jit_set_texpr_fuser_enabled(True)
272
-
273
-
274
- class _ImpactCore(IMPACTReg):
275
- """One IMPACT feature model, exposed as a FireANTs ``forward(moved, fixed)``.
276
-
277
- Reuses ``IMPACTReg._compute`` / ``preprocessing`` verbatim — the stats-normalised feature extraction
278
- (the model wants per-image ``[min, mean, max, std]``) and the per-layer weighted distance — so the
279
- metric is exactly KonfAI's, not a re-derivation. Only KonfAI's config-binding ``__init__`` and its
280
- ``Attribute``-based geometry are replaced: FireANTs passes raw tensors at the current pyramid scale, so
281
- the intensity statistics are computed from those tensors directly. ``pca`` (absent from KonfAI's torch
282
- ``IMPACTReg``) is added here as a per-layer feature-space reduction matching itk-impact.
283
- """
284
-
285
- def __init__(self, ref: str, in_channels: int, weights: list[float], distance: str, pca: int) -> None:
286
- from huggingface_hub import hf_hub_download
287
-
288
- torch.nn.Module.__init__(self) # bypass IMPACTReg.__init__ (KONFAI_CONFIG_PATH / apply_config binding)
289
- self.name = "Reg"
290
- self.in_channels = int(in_channels)
291
- self.weights = [float(w) for w in weights]
292
- self.nb_layer = len(self.weights)
293
- self.loss = _DISTANCES[distance]()
294
- self.pca = int(pca) # PCA lives in KonfAI's IMPACTReg._compute (same behaviour as itk-impact)
295
- self.dim = DIM
296
- self.shape = None # score the whole (downsampled) tensor — no ModelPatch tiling
297
- if ":" in ref: # a "repo:path" HF reference; otherwise a local model file
298
- repo, filename = ref.split(":", 1)
299
- self.model_path = hf_hub_download(repo, filename, repo_type="model") # nosec B615
300
- else:
301
- self.model_path = ref
302
- self.model = None # lazy-loaded on the first forward, like IMPACTReg
303
-
304
- @staticmethod
305
- def _stats(tensor: torch.Tensor) -> dict:
306
- detached = tensor.detach()
307
- return {
308
- "ImageMin": float(detached.min()),
309
- "ImageMean": float(detached.mean()),
310
- "ImageMax": float(detached.max()),
311
- "ImageStd": float(detached.std()),
312
- }
313
-
314
- def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor: # type: ignore[override]
315
- if self.model is None:
316
- self.model = torch.jit.load(self.model_path) # nosec B614
317
- self.model.to(moved.device).eval()
318
- with _no_texpr_fuser():
319
- loss, true_nb = self._compute(moved, [self._stats(moved)], fixed, [self._stats(fixed)], None)
320
- return loss / max(true_nb, 1)
321
-
322
-
323
- class ImpactFeatureLoss(torch.nn.Module):
324
- """FireANTs ``custom_loss`` = the KonfAI IMPACT metric fused over several feature models.
325
-
326
- ``forward(moved, fixed)`` sums each model's ``layers_weight * IMPACT(model)``. A model's per-layer
327
- weights come from its ``layers_mask`` bitmask; its input channel count is read from the registry
328
- (``models.json`` ``numberofchannels``) so it never has to be configured by hand.
329
- """
330
-
331
- def __init__(self, specs: list["ModelSpec"]) -> None:
332
- super().__init__()
333
- registry = load_models_registry()
334
- self._cores = torch.nn.ModuleList()
335
- self._model_weights: list[float] = []
336
- for spec in specs:
337
- in_channels = int(registry.get(spec.ref.split(":", 1)[-1], {}).get("numberofchannels", 1))
338
- weights = [1.0 if char == "1" else 0.0 for char in spec.layers_mask]
339
- self._cores.append(_ImpactCore(spec.ref, in_channels, weights, spec.distance, spec.pca))
340
- self._model_weights.append(float(spec.layers_weight))
341
-
342
- def forward(self, moved: torch.Tensor, fixed: torch.Tensor) -> torch.Tensor:
343
- total: torch.Tensor | None = None
344
- for weight, core in zip(self._model_weights, self._cores, strict=True):
345
- term = weight * core(moved, fixed)
346
- total = term if total is None else total + term
347
- return total
348
-
349
-
350
- class FireANTsEngine:
351
- """Register a fixed/moving pair with FireANTs (Rigid -> Affine -> [SyN | Greedy | none]); return
352
- (moved, dvf) on the fixed grid.
353
-
354
- ``fireants`` is imported lazily inside :meth:`register` so this module can be imported for config
355
- /signature introspection (SlicerImpactReg reads the tuning knobs off the ``RegistrationNet``
356
- annotations) on a machine without a GPU or without FireANTs installed.
357
- """
358
-
359
- def __init__(
360
- self,
361
- scales: list[int],
362
- affine_iterations: list[int],
363
- deformable_iterations: list[int],
364
- cc_kernel: int,
365
- affine_metric: str,
366
- affine_lr: float,
367
- deformable_method: str,
368
- deformable_metric: str,
369
- deformable_lr: float,
370
- integrator_n: int,
371
- smooth_warp_sigma: float,
372
- smooth_grad_sigma: float,
373
- seed: int,
374
- impact_specs: list["ModelSpec"],
375
- ) -> None:
376
- self._scales = [int(s) for s in scales]
377
- self._affine_iterations = [int(i) for i in affine_iterations]
378
- self._deformable_iterations = [int(i) for i in deformable_iterations]
379
- self._cc_kernel = int(cc_kernel)
380
- self._affine_metric = affine_metric
381
- self._affine_lr = float(affine_lr)
382
- self._deformable_method = deformable_method
383
- self._deformable_metric = deformable_metric
384
- self._deformable_lr = float(deformable_lr)
385
- self._integrator_n = int(integrator_n)
386
- self._smooth_warp_sigma = float(smooth_warp_sigma)
387
- self._smooth_grad_sigma = float(smooth_grad_sigma)
388
- self._seed = int(seed)
389
- # IMPACT deformable metric (only used when deformable_metric == "impact"): KonfAI IMPACT feature
390
- # models drive the SyN/greedy stage instead of the analytic CC/MI/MSE.
391
- self._impact_specs = impact_specs
392
-
393
- @staticmethod
394
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
395
- """True only for a mask that actually restricts the region — some voxels in, some out. An absent
396
- optional mask arrives as a whole-image (all-ones) default and an all-zero mask is degenerate; both
397
- are treated as no mask so the plain (non-masked) metric path is used."""
398
- if mask is None:
399
- return False
400
- arr = sitk.GetArrayViewFromImage(mask)
401
- return bool((arr > 0).any()) and bool((arr == 0).any())
402
-
403
- @staticmethod
404
- def _affine_to_sitk(affine_matrix: "torch.Tensor") -> sitk.AffineTransform:
405
- """FireANTs' physical (LPS) linear matrix -> SimpleITK AffineTransform (fixed -> moving points),
406
- the same convention FireANTs writes into an ANTs ``0GenericAffine.mat``."""
407
- matrix = affine_matrix.float().cpu().numpy()[0]
408
- affine = sitk.AffineTransform(DIM)
409
- affine.SetMatrix(matrix[:DIM, :DIM].flatten().astype(np.float64))
410
- affine.SetTranslation(matrix[:DIM, DIM].astype(np.float64))
411
- return affine
412
-
413
- def _total_field_transform(self, reg) -> sitk.Transform:
414
- """Optimise a deformable stage and return its TOTAL displacement (affine baked in) as a
415
- SimpleITK ``DisplacementFieldTransform`` on the fixed grid.
416
-
417
- FireANTs serialises the total field (ANTs convention, fixed grid) only to a file, so it is
418
- round-tripped through a temporary NIfTI — its public API, no internals reimplemented."""
419
- reg.optimize()
420
- with tempfile.TemporaryDirectory() as tmp:
421
- warp_path = os.path.join(tmp, "total_warp.nii.gz")
422
- reg.save_as_ants_transforms(warp_path)
423
- total_field = sitk.ReadImage(warp_path, sitk.sitkVectorFloat64)
424
- return sitk.DisplacementFieldTransform(total_field) # consumes total_field
425
-
426
- def register(
427
- self,
428
- fixed: sitk.Image,
429
- moving: sitk.Image,
430
- device_index: int,
431
- fixed_mask: sitk.Image | None = None,
432
- moving_mask: sitk.Image | None = None,
433
- ) -> tuple[np.ndarray, np.ndarray]:
434
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
435
- ensure_fireants_runtime()
436
- from fireants.io import BatchedImages, Image
437
- from fireants.io.imagemask import apply_mask_to_image, generate_image_mask_allones
438
- from fireants.registration.affine import AffineRegistration
439
- from fireants.registration.rigid import RigidRegistration
440
-
441
- torch.manual_seed(self._seed)
442
- device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
443
- # FireANTs' Image ctor accepts a SimpleITK image directly, so the fixed/moving cross into
444
- # FireANTs in-memory (no file load) with their geometry preserved.
445
- fixed_img = Image(fixed, device=device)
446
- moving_img = Image(moving, device=device)
447
-
448
- # Masked metric only when a mask genuinely restricts the region. FireANTs' masked mode wants the
449
- # mask as the last channel of BOTH images (all-ones where one side has none) and a ``masked_``
450
- # metric prefix; the plain path is untouched when no real mask is present.
451
- use_fixed_mask = self._is_partial_mask(fixed_mask)
452
- use_moving_mask = self._is_partial_mask(moving_mask)
453
- masked = use_fixed_mask or use_moving_mask
454
- if masked:
455
- fmask = Image(fixed_mask, device=device) if use_fixed_mask else generate_image_mask_allones(fixed_img)
456
- mmask = Image(moving_mask, device=device) if use_moving_mask else generate_image_mask_allones(moving_img)
457
- fixed_img = apply_mask_to_image(fixed_img, fmask)
458
- moving_img = apply_mask_to_image(moving_img, mmask)
459
-
460
- bf = BatchedImages([fixed_img])
461
- bm = BatchedImages([moving_img])
462
- affine_loss = f"masked_{self._affine_metric}" if masked else self._affine_metric
463
- deformable_loss = f"masked_{self._deformable_metric}" if masked else self._deformable_metric
464
-
465
- # Linear: Rigid(MI, COM init) -> Affine(MI, seeded by the rigid), mirroring ANTs. The affine
466
- # seeds the deformable stage (or is the whole transform when deformable_method == "none").
467
- rigid = RigidRegistration(
468
- scales=self._scales,
469
- iterations=self._affine_iterations,
470
- fixed_images=bf,
471
- moving_images=bm,
472
- loss_type=affine_loss,
473
- optimizer="Adam",
474
- optimizer_lr=self._affine_lr,
475
- cc_kernel_size=self._cc_kernel,
476
- init_translation="cof",
477
- )
478
- rigid.optimize()
479
- rigid_matrix = rigid.get_rigid_matrix().detach()
480
-
481
- affine = AffineRegistration(
482
- scales=self._scales,
483
- iterations=self._affine_iterations,
484
- fixed_images=bf,
485
- moving_images=bm,
486
- loss_type=affine_loss,
487
- optimizer="Adam",
488
- optimizer_lr=self._affine_lr,
489
- cc_kernel_size=self._cc_kernel,
490
- init_rigid=rigid_matrix,
491
- )
492
- affine.optimize()
493
- affine_matrix = affine.get_affine_matrix().detach()
494
-
495
- # Deformable stage (or none). SyN and Greedy share the same constructor surface; both warm-start
496
- # from the affine so their TOTAL transform already bakes in the linear pre-align.
497
- if self._deformable_method == "none":
498
- transform: sitk.Transform = self._affine_to_sitk(affine_matrix)
499
- else:
500
- if self._deformable_method == "syn":
501
- from fireants.registration.syn import SyNRegistration as Deformable
502
- elif self._deformable_method == "greedy":
503
- from fireants.registration.greedy import GreedyRegistration as Deformable
504
- else:
505
- raise ValueError(
506
- f"Unknown deformable_method '{self._deformable_method}' (expected 'syn', 'greedy' or 'none')."
507
- )
508
- # "impact" swaps the analytic metric for a KonfAI IMPACT feature loss on the deformable stage
509
- # (the linear pre-align keeps its own affine_metric); masks do not restrict the IMPACT metric.
510
- if self._deformable_metric == "impact":
511
- loss_type: str = "custom"
512
- custom_loss: torch.nn.Module | None = ImpactFeatureLoss(self._impact_specs)
513
- else:
514
- loss_type, custom_loss = deformable_loss, None
515
- reg = Deformable(
516
- scales=self._scales,
517
- iterations=self._deformable_iterations,
518
- fixed_images=bf,
519
- moving_images=bm,
520
- loss_type=loss_type,
521
- custom_loss=custom_loss,
522
- cc_kernel_size=self._cc_kernel,
523
- deformation_type="compositive",
524
- integrator_n=self._integrator_n,
525
- smooth_warp_sigma=self._smooth_warp_sigma,
526
- smooth_grad_sigma=self._smooth_grad_sigma,
527
- optimizer="Adam",
528
- optimizer_lr=self._deformable_lr,
529
- init_affine=affine_matrix,
530
- )
531
- transform = self._total_field_transform(reg)
532
-
533
- if torch.cuda.is_available():
534
- torch.cuda.synchronize()
535
-
536
- # Rebuild moved + DVF from the single transform on the fixed grid — the ConvexAdam output path,
537
- # so every FireANTs preset emits identical-shaped results.
538
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
539
- dvf = sitk.TransformToDisplacementField(
540
- transform,
541
- sitk.sitkVectorFloat64,
542
- fixed.GetSize(),
543
- fixed.GetOrigin(),
544
- fixed.GetSpacing(),
545
- fixed.GetDirection(),
546
- )
547
- moved_np, _ = image_to_data(moved)
548
- dvf_np, _ = image_to_data(dvf)
549
- return moved_np, dvf_np
550
-
551
-
552
- class FireANTsRegistration(torch.nn.Module):
553
- """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
554
-
555
- ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list
556
- alongside the tensors (same convention as the ConvexAdam / elastix engines); registration needs the
557
- physical geometry, and the mask branches restrict the metric.
558
- """
559
-
560
- accepts_attributes = True
561
-
562
- def __init__(self, engine: FireANTsEngine) -> None:
563
- super().__init__()
564
- self._engine = engine
565
-
566
- def forward(
567
- self,
568
- fixed: torch.Tensor,
569
- moving: torch.Tensor,
570
- fixed_mask: torch.Tensor,
571
- moving_mask: torch.Tensor,
572
- attributes: list[list[Attribute]],
573
- ) -> torch.Tensor:
574
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over
575
- # the batch. Returns, per sample, the moved image (1 channel) channel-stacked with the
576
- # displacement field (DIM channels); downstream ChannelSelect modules split them. A whole-image
577
- # mask (the default when none is supplied) restricts nothing.
578
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
579
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
580
- combined = []
581
- # FireANTs runs a gradient-based instance optimisation (Riemannian Adam over the warp); the
582
- # predictor calls forward under torch.inference_mode(), which forbids autograd. The image tensors
583
- # have already crossed to numpy/SimpleITK here, so re-enable grad for the optimisation.
584
- with torch.inference_mode(False), torch.enable_grad():
585
- for b in range(fixed.shape[0]):
586
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
587
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
588
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
589
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
590
- moved_np, dvf_np = self._engine.register(
591
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
592
- )
593
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
594
- return torch.stack(combined, dim=0).to(fixed.device)
595
-
596
-
597
- class ChannelSelect(torch.nn.Module):
598
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
599
-
600
- def __init__(self, start: int, stop: int) -> None:
601
- super().__init__()
602
- self._start = start
603
- self._stop = stop
604
-
605
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
606
- return tensor[:, self._start : self._stop]
607
-
608
-
609
- class RegistrationNet(network.Network):
610
- """Pairwise FireANTs registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
611
- fixed mask = 2, moving mask = 3; masks restrict the metric, whole-image = no restriction).
612
-
613
- Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
614
- (the DIM-component displacement field, in mm). Geometry is attached by the predictor via
615
- ``same_as_group: Volume_0:Fixed``. The knobs below are read straight from these annotations by the
616
- UI: ``Annotated[.., Range]`` gives numeric spin bounds; ``Literal`` a dropdown. ``deformable_method``
617
- is the knob that specialises this shared model into each FireANTs preset.
618
- """
619
-
620
- def __init__(
621
- self,
622
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
623
- schedulers: dict[str, network.LRSchedulersLoader] = {
624
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
625
- },
626
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
627
- scales: list[int] = [4, 2, 1],
628
- affine_iterations: list[int] = [200, 100, 50],
629
- deformable_iterations: list[int] = [200, 100, 50],
630
- cc_kernel: Annotated[int, Range(1, 21)] = 5,
631
- affine_metric: Literal["mi", "cc", "mse"] = "mi",
632
- affine_lr: Annotated[float, Range(0.0, 10.0)] = 0.003,
633
- deformable_method: Literal["none", "syn", "greedy"] = "syn",
634
- deformable_metric: Literal["cc", "mi", "mse", "impact"] = "cc",
635
- deformable_lr: Annotated[float, Range(0.0, 10.0)] = 0.25,
636
- integrator_n: Annotated[int, Range(1, 100)] = 10,
637
- smooth_warp_sigma: Annotated[float, Range(0.0, 100.0)] = 0.5,
638
- smooth_grad_sigma: Annotated[float, Range(0.0, 100.0)] = 1.0,
639
- seed: int = 42,
640
- models: dict[str, ModelSpec] = {},
641
- ) -> None:
642
- super().__init__(
643
- in_channels=1,
644
- optimizer=optimizer,
645
- schedulers=schedulers,
646
- outputs_criterions=outputs_criterions,
647
- dim=3,
648
- )
649
- engine = FireANTsEngine(
650
- scales,
651
- affine_iterations,
652
- deformable_iterations,
653
- cc_kernel,
654
- affine_metric,
655
- affine_lr,
656
- deformable_method,
657
- deformable_metric,
658
- deformable_lr,
659
- integrator_n,
660
- smooth_warp_sigma,
661
- smooth_grad_sigma,
662
- seed,
663
- _sorted_specs(models),
664
- )
665
- self.add_module(
666
- "Registration", FireANTsRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
667
- )
668
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
669
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
FireANTs_SyN/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  scales:
6
  - 4
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.fireants:RegistrationNet
4
  RegistrationNet:
5
  scales:
6
  - 4
FireANTs_SyN/requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  hydra-core
2
  nibabel
3
  pandas
 
 
1
  hydra-core
2
  nibabel
3
  pandas
4
+ impact_reg_konfai>=1.6.1
Generic_Rigid/Model.py DELETED
@@ -1,301 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
18
-
19
- ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
20
- 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
21
- the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
22
- the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
23
- subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
24
-
25
- A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
26
- ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
27
-
28
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
29
- (``get_origin``); PEP 563 stringized annotations break arg resolution.
30
- """
31
-
32
- import json
33
- import os
34
- import re
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Annotated, Literal
38
-
39
- import torch
40
- from huggingface_hub import hf_hub_download
41
- from konfai.network import network
42
- from konfai.utils.config import Choices, Range
43
-
44
- # IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
45
- # A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
46
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
47
- # iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
48
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
49
-
50
- # ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
51
- # Static anyway; in Jacobian we clamp ``l`` to this plateau.
52
- _FOV_RAMP_MAX_LAYER = 6
53
-
54
-
55
- def registry_choices() -> list[str]:
56
- """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
57
- (offline-first). A user may still point ``ref`` at a local model."""
58
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
59
- return [f"{repo}:{key}" for key in load_models_registry()]
60
-
61
-
62
- def _num(x: object) -> str:
63
- """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
64
- return "%g" % float(x)
65
-
66
-
67
- @dataclass
68
- class ModelSpec:
69
- """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
70
- rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
71
- (``models.json``) keyed by ``ref`` — never tuned."""
72
-
73
- ref: Annotated[str, Choices(registry_choices)]
74
- voxel_size: list[float] = field(default_factory=list)
75
- layers_weight: list[float] = field(default_factory=lambda: [1.0])
76
- subset_features: Annotated[int, Range(0, 1000)] = 0
77
- pca: Annotated[int, Range(0, 100)] = 0
78
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
79
- layers_mask: str = ""
80
-
81
-
82
- @dataclass
83
- class ResolutionSpec:
84
- """One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
85
-
86
- max_iterations: Annotated[int, Range(1, 100000)]
87
- models: dict[str, ModelSpec]
88
-
89
-
90
- def _sorted_specs(mapping: dict) -> list:
91
- """dict keyed by string indices ('0','1',...) -> values in numeric order."""
92
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
93
-
94
-
95
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
96
- """Load models.json (the fixed params per model) from the model repo on Hugging Face.
97
-
98
- The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
99
- dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
100
- """
101
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
102
- if local:
103
- path = Path(local)
104
- elif ":" in ref:
105
- repo, filename = ref.split(":", 1)
106
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
107
- else:
108
- raise ValueError(
109
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
110
- f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
111
- )
112
- return json.loads(path.read_text(encoding="utf-8"))
113
-
114
-
115
- def _model_key(ref: str) -> str:
116
- """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
117
- return ref.split(":", 1)[1] if ":" in ref else ref
118
-
119
-
120
- def _deepest_active_layer(layers_mask: str) -> int:
121
- """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
122
-
123
- A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
124
- == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
125
- receptive field, so the FOV is governed by the rightmost ``'1'``.
126
- """
127
- mask = layers_mask.strip().strip('"')
128
- active = [i for i, char in enumerate(mask) if char == "1"]
129
- if not active:
130
- raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
131
- return max(active)
132
-
133
-
134
- def _fov_value(fov: dict, layers_mask: str) -> int:
135
- """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
136
-
137
- Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
138
- ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
139
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
140
- to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
141
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
142
- ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
143
- An explicit ``value`` in the spec is honoured as a precomputed shortcut.
144
- """
145
- formula = str(fov.get("formula", "")).strip()
146
- key = re.sub(r"\s+", "", formula).lower()
147
- if key.isdigit():
148
- return int(key)
149
- if key == "2*r*d+1":
150
- return 2 * int(fov["r"]) * int(fov["d"]) + 1
151
- if key == "2^l+3":
152
- return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
153
- if "global" in key:
154
- raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
155
- if fov.get("value") is not None:
156
- return int(fov["value"])
157
- raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
158
-
159
-
160
- def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
161
- """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
162
- image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
163
- e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
164
- dim = int(entry.get("dimension", 3))
165
- if mode.strip().strip('"').lower() != "jacobian":
166
- return " ".join(["0"] * dim)
167
- fov = _fov_value(entry.get("fov", {}), layers_mask)
168
- return " ".join([str(fov)] * dim)
169
-
170
-
171
- def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
172
- """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
173
-
174
- Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
175
- ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
176
- is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
177
- from the registry formula and the cell's ``layers_mask``.
178
- """
179
- res = _sorted_specs(resolutions)
180
- n = len(res)
181
- mode_clean = mode.strip().strip('"') or "Static"
182
-
183
- impact: list[str] = []
184
- for k, r in enumerate(res):
185
- models = _sorted_specs(r.models)
186
- entries = [registry[_model_key(m.ref)] for m in models]
187
-
188
- def row(stem: str, values: list[str]) -> None:
189
- impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
190
-
191
- # From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
192
- # model FOV); everything else is a per-model knob taken straight from the cell.
193
- row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
194
- row("Dimension", [e["dimension"] for e in entries])
195
- row("NumberOfChannels", [e["numberofchannels"] for e in entries])
196
- row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
197
- row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
198
- row("LayersMask", [f'"{m.layers_mask}"' for m in models])
199
- row("SubsetFeatures", [str(m.subset_features) for m in models])
200
- row("PCA", [str(m.pca) for m in models])
201
- row("Distance", [f'"{m.distance}"' for m in models])
202
- row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
203
- impact.append("") # blank line between resolutions, mirroring the reference maps
204
-
205
- # The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
206
- # (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
207
- lines = template_text.splitlines()
208
- indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
209
- block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
210
- block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
211
-
212
- out: list[str] = []
213
- for i, (m, line) in enumerate(indexed):
214
- key = m.group(1) if m else None
215
- if block_lo <= i <= block_hi:
216
- if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
217
- out.extend(impact[:-1])
218
- elif key == "MaximumNumberOfIterations":
219
- out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
220
- elif key == "NumberOfResolutions":
221
- out.append(f"(NumberOfResolutions {n})")
222
- elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
223
- out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
224
- elif key == "ImpactMode":
225
- out.append(f'(ImpactMode "{mode_clean}")')
226
- else:
227
- out.append(line)
228
- return "\n".join(out)
229
-
230
-
231
- class ChannelSelect(torch.nn.Module):
232
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
233
-
234
- def __init__(self, start: int, stop: int) -> None:
235
- super().__init__()
236
- self._start = start
237
- self._stop = stop
238
-
239
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
240
- return tensor[:, self._start : self._stop]
241
-
242
-
243
- class RegistrationNet(network.Network):
244
- """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
245
- moving mask = 3; masks restrict the metric, whole-image = no restriction).
246
-
247
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
248
- (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
249
- ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
250
- ``same_as_group: Volume_0:Fixed``.
251
- """
252
-
253
- def __init__(
254
- self,
255
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
256
- schedulers: dict[str, network.LRSchedulersLoader] = {
257
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
258
- },
259
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
260
- engine: str = "elastix",
261
- parameter_maps: list[str] = [],
262
- max_iterations: Annotated[int, Range(0, 100000)] = 0,
263
- final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
264
- subset_features: Annotated[int, Range(0, 1000)] = 0,
265
- spatial_samples: Annotated[int, Range(0, 100000)] = 0,
266
- parameter_overrides: list[str] = [],
267
- resolutions: dict[str, ResolutionSpec] = {},
268
- mode: Literal["Static", "Jacobian"] = "Static",
269
- ) -> None:
270
- # The registration is fully described by ``resolutions`` (config = source of truth): each resolution
271
- # lists its self-configured models; the download list is derived from the cells. Global knobs override
272
- # the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
273
- # NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
274
- # preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
275
- from elastix_engine import ElastixRegistration
276
-
277
- super().__init__(
278
- in_channels=1,
279
- optimizer=optimizer,
280
- schedulers=schedulers,
281
- outputs_criterions=outputs_criterions,
282
- dim=3,
283
- )
284
- self.add_module(
285
- "Registration",
286
- ElastixRegistration(
287
- engine,
288
- parameter_maps,
289
- max_iterations,
290
- final_grid_spacing,
291
- subset_features,
292
- spatial_samples,
293
- parameter_overrides,
294
- resolutions,
295
- mode,
296
- ),
297
- in_branch=[0, 1, 2, 3],
298
- out_branch=["registration"],
299
- )
300
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
301
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Generic_Rigid/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.elastix:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
Generic_Rigid/elastix_engine.py DELETED
@@ -1,386 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Elastix-IMPACT runtime for the registration bundle.
18
-
19
- ``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
20
- parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
21
- ``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
22
- images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
23
- """
24
-
25
- import os
26
- import re
27
- import shutil
28
- import subprocess # nosec B404
29
- import tempfile
30
- from pathlib import Path
31
-
32
- import numpy as np
33
- import SimpleITK as sitk
34
- import torch
35
- import tqdm
36
- from huggingface_hub import hf_hub_download
37
- from install import get_elastix_bin, install_elastix_impact, try_elastix
38
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
39
-
40
- from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
41
-
42
- # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
43
- # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
44
- ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
45
-
46
-
47
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
48
- """True only for a mask that actually restricts the metric region — some voxels in, some out. An
49
- absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
50
- is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
51
- the whole image) instead of paying for a mask that restricts nothing."""
52
- if mask is None:
53
- return False
54
- arr = sitk.GetArrayViewFromImage(mask)
55
- return bool((arr > 0).any()) and bool((arr == 0).any())
56
-
57
-
58
- class ElastixEngine:
59
- """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
60
-
61
- NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
62
- NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
63
- """
64
-
65
- def __init__(
66
- self,
67
- parameter_maps: list[str],
68
- max_iterations: int = 0,
69
- final_grid_spacing: float = 0.0,
70
- subset_features: int = 0,
71
- spatial_samples: int = 0,
72
- parameter_overrides: list[str] = [],
73
- resolutions: dict = {},
74
- mode: str = "Static",
75
- ) -> None:
76
- self._bundle_dir = Path(__file__).resolve().parent
77
- self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
78
- self._max_iterations = max_iterations
79
- self._final_grid_spacing = final_grid_spacing
80
- self._subset_features = subset_features
81
- self._spatial_samples = spatial_samples
82
- self._parameter_overrides = list(parameter_overrides)
83
- # ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
84
- # samples random FOV-sized patches each iteration. One mode per preset.
85
- self._mode = mode
86
- # Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
87
- # intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
88
- self._resolutions = resolutions
89
- self._registry = load_models_registry() if resolutions else {}
90
- # Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
91
- models: list[str] = []
92
- for res in _sorted_specs(resolutions):
93
- for model in _sorted_specs(res.models):
94
- if model.ref not in models:
95
- models.append(model.ref)
96
- self._models = models
97
- # ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
98
- self._iterations = self._total_iterations()
99
- self._elastix_bin = self._ensure_binary()
100
- self._local_models = self._download_models()
101
-
102
- def _total_iterations(self) -> int:
103
- """Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
104
- if self._resolutions:
105
- return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
106
- total = 0
107
- for src in self._parameter_maps:
108
- match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
109
- if match:
110
- total += sum(int(token) for token in match.group(1).split())
111
- return total
112
-
113
- def _ensure_binary(self) -> Path:
114
- # Optional override: point at an existing elastix-IMPACT install (skips the download).
115
- override = os.environ.get("KONFAI_ELASTIX_DIR", "")
116
- if override:
117
- try_elastix(Path(override))
118
- return get_elastix_bin(Path(override)).resolve()
119
- ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
120
- try:
121
- try_elastix(ELASTIX_CACHE)
122
- except Exception:
123
- install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
124
- try_elastix(ELASTIX_CACHE)
125
- return get_elastix_bin(ELASTIX_CACHE).resolve()
126
-
127
- def _download_models(self) -> list[tuple[str, Path]]:
128
- """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
129
- models = []
130
- for ref in self._models:
131
- repo, filename = ref.split(":", 1)
132
- local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
- models.append((filename, local))
134
- return models
135
-
136
- def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
137
- """The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
138
-
139
- ``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
140
- **each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
141
- ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
142
- knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
143
- ``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
144
- """
145
- per_token: dict[str, str] = {}
146
- if not global_only and self._max_iterations > 0:
147
- per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
148
- if self._final_grid_spacing > 0:
149
- per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
150
- if not global_only and self._subset_features > 0:
151
- per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
152
- if self._spatial_samples > 0:
153
- per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
154
- exact: list[tuple[str, str]] = []
155
- for entry in self._parameter_overrides:
156
- key, sep, value = entry.partition("=")
157
- if not sep or not key.strip():
158
- raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
159
- exact.append((key.strip(), value.strip()))
160
- return per_token, exact
161
-
162
- @staticmethod
163
- def _apply_map_overrides(
164
- text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
165
- ) -> str:
166
- """Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
167
- of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
168
- """
169
- entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
170
- requested = set(per_token) | {key for key, _ in exact}
171
- seen: set[str] = set()
172
- lines = []
173
- for line in text.splitlines():
174
- match = entry_pattern.match(line)
175
- if match:
176
- indent, key, values = match.group(1), match.group(2), match.group(3)
177
- if key == "ImpactGPU":
178
- line = f"{indent}(ImpactGPU {device_index})"
179
- else:
180
- exact_value = next((value for k, value in exact if k == key), None)
181
- if exact_value is not None:
182
- seen.add(key)
183
- line = f"{indent}({key} {exact_value})"
184
- else:
185
- token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
186
- if token_key in per_token:
187
- seen.add(token_key)
188
- replaced = " ".join(per_token[token_key] for _ in values.split())
189
- line = f"{indent}({key} {replaced})"
190
- lines.append(line)
191
- # Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
192
- # surface it (e.g. final_grid_spacing on a rigid-only preset).
193
- for key in sorted(requested - seen):
194
- print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
195
- return "\n".join(lines)
196
-
197
- def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
198
- """Stage the parameter maps into ``work``.
199
-
200
- Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
201
- knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
202
- applies every per-token / exact override. Both set the ImpactGPU device.
203
- """
204
- staged = []
205
- for src in self._parameter_maps:
206
- if self._resolutions:
207
- text = generate_impact_parameter_map(
208
- src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
209
- )
210
- per_token, exact = self._parameter_map_overrides(global_only=True)
211
- else:
212
- text = src.read_text(encoding="utf-8")
213
- per_token, exact = self._parameter_map_overrides()
214
- text = self._apply_map_overrides(text, per_token, exact, device_index)
215
- dst = work / src.name
216
- dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
217
- staged.append(dst)
218
- return staged
219
-
220
- def register(
221
- self,
222
- fixed: sitk.Image,
223
- moving: sitk.Image,
224
- device_index: int,
225
- fixed_mask: sitk.Image | None = None,
226
- moving_mask: sitk.Image | None = None,
227
- ) -> tuple[np.ndarray, np.ndarray]:
228
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
229
-
230
- Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
231
- ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
232
- """
233
- work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
234
- try:
235
- fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
236
- sitk.WriteImage(fixed, str(fixed_path))
237
- sitk.WriteImage(moving, str(moving_path))
238
-
239
- # Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
240
- # "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
241
- for rel_name, model_path in self._local_models:
242
- dst = work / rel_name
243
- dst.parent.mkdir(parents=True, exist_ok=True)
244
- if not dst.exists():
245
- dst.symlink_to(model_path)
246
-
247
- args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
248
- for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
249
- if _is_partial_mask(mask):
250
- mask_path = work / name
251
- sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
252
- args += [flag, str(mask_path)]
253
- args += ["-out", str(work)]
254
- for pmap in self._stage_parameter_maps(work, device_index):
255
- args += ["-p", str(pmap)]
256
-
257
- # Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
258
- # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
259
- env = os.environ.copy()
260
- extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
261
- env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
262
- proc = subprocess.Popen( # nosec B603
263
- args,
264
- cwd=str(work),
265
- stdout=subprocess.PIPE,
266
- stderr=subprocess.STDOUT,
267
- text=True,
268
- bufsize=1,
269
- env=env,
270
- )
271
- # Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
272
- # progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
273
- # open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
274
- captured: list[str] = []
275
- iteration_line = re.compile(r"^\d+\s")
276
- budget = None if self._max_iterations > 0 else (self._iterations or None)
277
- progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
278
- assert proc.stdout is not None
279
- resolution = 0
280
- for line in proc.stdout:
281
- captured.append(line)
282
- stripped = line.strip()
283
- if stripped.startswith("Resolution:"):
284
- try:
285
- resolution = int(stripped.split(":", 1)[1])
286
- except ValueError:
287
- pass
288
- elif iteration_line.match(line):
289
- progress.update(1)
290
- columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
291
- if len(columns) > 1:
292
- try:
293
- progress.set_description(
294
- f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
295
- )
296
- except ValueError:
297
- pass
298
- progress.close()
299
- returncode = proc.wait()
300
- if returncode != 0:
301
- raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
302
-
303
- transforms = sorted(
304
- work.glob("TransformParameters.*-Composite.itk.txt"),
305
- key=lambda p: int(p.name.split(".")[1].split("-")[0]),
306
- )
307
- if not transforms:
308
- raise FileNotFoundError("elastix produced no composite transform file.")
309
- transform = sitk.ReadTransform(str(transforms[-1]))
310
-
311
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
312
- dvf = sitk.TransformToDisplacementField(
313
- transform,
314
- sitk.sitkVectorFloat64,
315
- fixed.GetSize(),
316
- fixed.GetOrigin(),
317
- fixed.GetSpacing(),
318
- fixed.GetDirection(),
319
- )
320
- moved_np, _ = image_to_data(moved)
321
- dvf_np, _ = image_to_data(dvf)
322
- return moved_np, dvf_np
323
- finally:
324
- shutil.rmtree(work, ignore_errors=True)
325
-
326
-
327
- class ElastixRegistration(torch.nn.Module):
328
- """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
329
-
330
- ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
331
- ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
332
- the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
333
- """
334
-
335
- accepts_attributes = True
336
-
337
- def __init__(
338
- self,
339
- engine: str,
340
- parameter_maps: list[str],
341
- max_iterations: int = 0,
342
- final_grid_spacing: float = 0.0,
343
- subset_features: int = 0,
344
- spatial_samples: int = 0,
345
- parameter_overrides: list[str] = [],
346
- resolutions: dict = {},
347
- mode: str = "Static",
348
- ) -> None:
349
- super().__init__()
350
- if engine != "elastix":
351
- raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
352
- self._engine = ElastixEngine(
353
- parameter_maps,
354
- max_iterations,
355
- final_grid_spacing,
356
- subset_features,
357
- spatial_samples,
358
- parameter_overrides,
359
- resolutions,
360
- mode,
361
- )
362
-
363
- def forward(
364
- self,
365
- fixed: torch.Tensor,
366
- moving: torch.Tensor,
367
- fixed_mask: torch.Tensor,
368
- moving_mask: torch.Tensor,
369
- attributes: list[list[Attribute]],
370
- ) -> torch.Tensor:
371
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
372
- # batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
373
- # the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
374
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
375
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
376
- combined = []
377
- for b in range(fixed.shape[0]):
378
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
379
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
380
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
381
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
382
- moved_np, dvf_np = self._engine.register(
383
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
384
- )
385
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
- return torch.stack(combined, dim=0).to(fixed.device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Generic_Rigid/install.py DELETED
@@ -1,325 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- import argparse
18
- import platform
19
- import re
20
- import shutil
21
- import stat
22
- import subprocess # nosec B404
23
- import zipfile
24
- from pathlib import Path
25
-
26
- import requests
27
- from tqdm import tqdm
28
-
29
- # -----------------------------------------------------------------------------
30
- # Elastix + IMPACT binary assets hosted on GitHub Releases.
31
- #
32
- # Key format: (OS, ARCH, FLAVOR)
33
- # - OS : platform.system() -> "Linux", "Windows", "Darwin"
34
- # - ARCH : normalized architecture -> "x86_64"
35
- # - FLAVOR : "cpu" or "cu128"
36
- #
37
- # CPU assets are standalone (LibTorch CPU bundled).
38
- # CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
39
- # -----------------------------------------------------------------------------
40
- ELX_ASSET_TEMPLATE = {
41
- ("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
42
- ("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
43
- ("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
44
- ("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
45
- ("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
46
- }
47
-
48
- # -----------------------------------------------------------------------------
49
- # Official LibTorch downloads (PyTorch).
50
- #
51
- # IMPORTANT:
52
- # - Version MUST match the one used at build time (ABI compatibility).
53
- # - Using "shared-with-deps" ensures CUDA runtime libraries are included
54
- # (except the NVIDIA driver, which must be installed system-wide).
55
- # -----------------------------------------------------------------------------
56
-
57
- LIBTORCH_URL = {
58
- (
59
- "Linux",
60
- "x86_64",
61
- "cpu",
62
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
63
- (
64
- "Windows",
65
- "x86_64",
66
- "cpu",
67
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
68
- (
69
- "Darwin",
70
- "arm64",
71
- "cpu",
72
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
73
- (
74
- "Linux",
75
- "x86_64",
76
- "cu128",
77
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
78
- (
79
- "Windows",
80
- "x86_64",
81
- "cu128",
82
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
83
- }
84
-
85
- # -----------------------------------------------------------------------------
86
- # Minimum NVIDIA driver versions required for CUDA 12.8.
87
- #
88
- # The CUDA Toolkit itself is NOT required.
89
- # Only a sufficiently recent NVIDIA driver must be installed.
90
- # -----------------------------------------------------------------------------
91
- CUDA128_MIN_DRIVER_LINUX = (570, 26)
92
- CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
93
-
94
- GITHUB_OWNER = "vboussot"
95
- GITHUB_REPO = "ImpactElastix"
96
- GITHUB_TAG = "1.0.0"
97
-
98
-
99
- DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
100
-
101
-
102
- def run_cmd(cmd: list[str]) -> str:
103
- return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
104
-
105
-
106
- def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
107
- """
108
- Detect presence of an NVIDIA GPU and extract the driver version.
109
- Returns:
110
- (True, (major, minor)) if detected
111
- (False, None) if nvidia-smi is not available
112
- """
113
- try:
114
- nvidia_smi = shutil.which("nvidia-smi")
115
- if nvidia_smi is None:
116
- raise RuntimeError("nvidia-smi not found in PATH")
117
-
118
- out = (
119
- subprocess.check_output(
120
- [nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
121
- ) # nosec B603
122
- .decode("utf-8")
123
- .strip()
124
- )
125
- except Exception:
126
- return (False, None)
127
-
128
- # Exemple: "575.64"
129
- m = re.match(r"([0-9]+)\.([0-9]+)", out)
130
- if not m:
131
- return (True, None)
132
-
133
- return (True, (int(m.group(1)), int(m.group(2))))
134
-
135
-
136
- def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
137
- """
138
- Check whether the detected NVIDIA driver satisfies the minimum
139
- requirement for CUDA 12.8 on the given operating system.
140
- """
141
- if drv is None:
142
- return False
143
- if os_name == "Linux":
144
- return drv >= CUDA128_MIN_DRIVER_LINUX
145
- if os_name == "Windows":
146
- return drv >= CUDA128_MIN_DRIVER_WINDOWS
147
- return False
148
-
149
-
150
- def normalize_arch(machine: str) -> str:
151
- """
152
- Normalize platform.machine() output across operating systems.
153
-
154
- Examples:
155
- AMD64 -> x86_64
156
- aarch64 -> arm64
157
- """
158
- m = machine.lower()
159
- if m in ("x86_64", "amd64"):
160
- return "x86_64"
161
- if m in ("aarch64", "arm64"):
162
- return "arm64"
163
- return machine
164
-
165
-
166
- def download_file(url: str, dst: Path) -> None:
167
- """
168
- Download a file from the given URL with a progress indicator.
169
- """
170
- dst.parent.mkdir(parents=True, exist_ok=True)
171
- print(f"Downloading: {url}", flush=True)
172
- print(f" to: {dst}", flush=True)
173
-
174
- try:
175
- with requests.get(url, stream=True, timeout=60) as r:
176
- r.raise_for_status()
177
- total = int(r.headers.get("content-length", 0))
178
- with open(dst, "wb") as f:
179
- with tqdm(
180
- total=total,
181
- unit="B",
182
- unit_scale=True,
183
- desc=f"Downloading {dst.name}",
184
- ) as pbar:
185
- for chunk in r.iter_content(chunk_size=8192):
186
- f.write(chunk)
187
- pbar.update(len(chunk))
188
- except Exception as e:
189
- raise e
190
-
191
-
192
- def extract_archive(archive: Path, dst_dir: Path) -> None:
193
- """
194
- Extract a ZIP archive to the destination directory.
195
- """
196
- dst_dir.mkdir(parents=True, exist_ok=True)
197
- print(f"Extracting: {archive} -> {dst_dir}", flush=True)
198
- with zipfile.ZipFile(archive, "r") as z:
199
- z.extractall(dst_dir)
200
- archive.unlink()
201
-
202
-
203
- def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
204
- os_name = platform.system()
205
- arch = normalize_arch(platform.machine())
206
- has_nvidia, drv = detect_nvidia_driver()
207
-
208
- if os_name not in ("Linux", "Windows", "Darwin"):
209
- raise NameError(f"Unsupported OS: {os_name}")
210
-
211
- if arch not in ("x86_64", "arm64"):
212
- raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
213
-
214
- flavor = "cpu"
215
- if force_cuda:
216
- if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
217
- raise NameError(
218
- "CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
219
- )
220
-
221
- flavor = "cu128"
222
- elif not force_cpu:
223
- if has_nvidia and driver_ok_for_cuda(os_name, drv):
224
- flavor = "cu128"
225
- else:
226
- flavor = "cpu"
227
-
228
- print(f"System: {os_name} {arch}", flush=True)
229
- print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
230
- print(f"Selected flavor: {flavor}", flush=True)
231
-
232
- key = (os_name, arch, flavor)
233
- if key not in ELX_ASSET_TEMPLATE:
234
- raise NameError(f"No elastix asset configured for {key}")
235
- if flavor != "cpu" and key not in LIBTORCH_URL:
236
- raise NameError(f"No libtorch url configured for {key}")
237
-
238
- install_path = install_path.resolve()
239
- install_path.mkdir(parents=True, exist_ok=True)
240
-
241
- elx_asset = ELX_ASSET_TEMPLATE[key]
242
- elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
243
- elx_archive = install_path / elx_asset
244
- download_file(elx_url, elx_archive)
245
- extract_archive(elx_archive, install_path)
246
-
247
- # -------------------------------------------------------------------------
248
- # ZIP archives may drop executable permissions.
249
- # Ensure elastix and transformix are executable on Unix platforms.
250
- # -------------------------------------------------------------------------
251
- if os_name in ("Linux", "Darwin"):
252
- for exe in ("elastix", "transformix"):
253
- p = install_path / "bin" / exe
254
- if p.exists():
255
- p.chmod(p.stat().st_mode | stat.S_IEXEC)
256
-
257
- # ---------------------------------------------------------------------
258
- # - Download matching LibTorch 12.8 with or without CUDA 12.8
259
- # - Extract it
260
- # - Move runtime libraries to a location visible to the dynamic loader
261
- #
262
- # Linux / macOS : prefix/lib
263
- # Windows : prefix (next to elastix.exe)
264
- # ---------------------------------------------------------------------
265
- lt_url = LIBTORCH_URL[key]
266
- lt_archive = install_path / Path(lt_url).name
267
- download_file(lt_url, lt_archive)
268
- extract_archive(lt_archive, install_path)
269
- for p in (install_path / "libtorch" / "lib").iterdir():
270
- if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
271
- shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
272
-
273
- shutil.rmtree(install_path / "libtorch")
274
- if os_name == "Linux" and flavor == "cu128":
275
- shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
276
-
277
-
278
- def get_elastix_bin(install_path: Path) -> Path:
279
- return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
280
-
281
-
282
- def try_elastix(install_path: Path) -> None:
283
- try:
284
- subprocess.run(
285
- [str(get_elastix_bin(install_path)), "-h"],
286
- capture_output=True,
287
- text=True,
288
- check=True,
289
- ) # nosec B603
290
- except subprocess.CalledProcessError as e:
291
- msg = "Elastix execution failed.\n\n"
292
-
293
- msg += f"Command:\n{' '.join(e.cmd)}\n"
294
- msg += f"Return code: {e.returncode}\n\n"
295
-
296
- if e.stderr:
297
- msg += "Error output:\n"
298
- msg += e.stderr.strip()
299
- raise NameError(msg)
300
-
301
- except OSError as e:
302
- msg = (
303
- "Elastix could not be started.\n\n"
304
- "This is usually caused by missing shared libraries "
305
- "(e.g. LibTorch or CUDA runtime).\n\n"
306
- f"System error:\n{str(e)}"
307
- )
308
-
309
- raise NameError(msg)
310
-
311
-
312
- def main() -> None:
313
- ap = argparse.ArgumentParser()
314
- ap.add_argument(
315
- "--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
316
- )
317
- group = ap.add_mutually_exclusive_group()
318
- group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
319
- group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
320
- args = vars(ap.parse_args())
321
- install_elastix_impact(**args)
322
-
323
-
324
- if __name__ == "__main__":
325
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Generic_Rigid/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ impact_reg_konfai>=1.6.1
Generic_Rigid_BSpline/Model.py DELETED
@@ -1,301 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
18
-
19
- ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
20
- 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
21
- the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
22
- the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
23
- subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
24
-
25
- A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
26
- ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
27
-
28
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
29
- (``get_origin``); PEP 563 stringized annotations break arg resolution.
30
- """
31
-
32
- import json
33
- import os
34
- import re
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Annotated, Literal
38
-
39
- import torch
40
- from huggingface_hub import hf_hub_download
41
- from konfai.network import network
42
- from konfai.utils.config import Choices, Range
43
-
44
- # IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
45
- # A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
46
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
47
- # iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
48
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
49
-
50
- # ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
51
- # Static anyway; in Jacobian we clamp ``l`` to this plateau.
52
- _FOV_RAMP_MAX_LAYER = 6
53
-
54
-
55
- def registry_choices() -> list[str]:
56
- """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
57
- (offline-first). A user may still point ``ref`` at a local model."""
58
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
59
- return [f"{repo}:{key}" for key in load_models_registry()]
60
-
61
-
62
- def _num(x: object) -> str:
63
- """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
64
- return "%g" % float(x)
65
-
66
-
67
- @dataclass
68
- class ModelSpec:
69
- """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
70
- rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
71
- (``models.json``) keyed by ``ref`` — never tuned."""
72
-
73
- ref: Annotated[str, Choices(registry_choices)]
74
- voxel_size: list[float] = field(default_factory=list)
75
- layers_weight: list[float] = field(default_factory=lambda: [1.0])
76
- subset_features: Annotated[int, Range(0, 1000)] = 0
77
- pca: Annotated[int, Range(0, 100)] = 0
78
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
79
- layers_mask: str = ""
80
-
81
-
82
- @dataclass
83
- class ResolutionSpec:
84
- """One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
85
-
86
- max_iterations: Annotated[int, Range(1, 100000)]
87
- models: dict[str, ModelSpec]
88
-
89
-
90
- def _sorted_specs(mapping: dict) -> list:
91
- """dict keyed by string indices ('0','1',...) -> values in numeric order."""
92
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
93
-
94
-
95
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
96
- """Load models.json (the fixed params per model) from the model repo on Hugging Face.
97
-
98
- The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
99
- dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
100
- """
101
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
102
- if local:
103
- path = Path(local)
104
- elif ":" in ref:
105
- repo, filename = ref.split(":", 1)
106
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
107
- else:
108
- raise ValueError(
109
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
110
- f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
111
- )
112
- return json.loads(path.read_text(encoding="utf-8"))
113
-
114
-
115
- def _model_key(ref: str) -> str:
116
- """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
117
- return ref.split(":", 1)[1] if ":" in ref else ref
118
-
119
-
120
- def _deepest_active_layer(layers_mask: str) -> int:
121
- """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
122
-
123
- A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
124
- == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
125
- receptive field, so the FOV is governed by the rightmost ``'1'``.
126
- """
127
- mask = layers_mask.strip().strip('"')
128
- active = [i for i, char in enumerate(mask) if char == "1"]
129
- if not active:
130
- raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
131
- return max(active)
132
-
133
-
134
- def _fov_value(fov: dict, layers_mask: str) -> int:
135
- """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
136
-
137
- Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
138
- ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
139
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
140
- to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
141
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
142
- ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
143
- An explicit ``value`` in the spec is honoured as a precomputed shortcut.
144
- """
145
- formula = str(fov.get("formula", "")).strip()
146
- key = re.sub(r"\s+", "", formula).lower()
147
- if key.isdigit():
148
- return int(key)
149
- if key == "2*r*d+1":
150
- return 2 * int(fov["r"]) * int(fov["d"]) + 1
151
- if key == "2^l+3":
152
- return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
153
- if "global" in key:
154
- raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
155
- if fov.get("value") is not None:
156
- return int(fov["value"])
157
- raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
158
-
159
-
160
- def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
161
- """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
162
- image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
163
- e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
164
- dim = int(entry.get("dimension", 3))
165
- if mode.strip().strip('"').lower() != "jacobian":
166
- return " ".join(["0"] * dim)
167
- fov = _fov_value(entry.get("fov", {}), layers_mask)
168
- return " ".join([str(fov)] * dim)
169
-
170
-
171
- def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
172
- """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
173
-
174
- Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
175
- ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
176
- is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
177
- from the registry formula and the cell's ``layers_mask``.
178
- """
179
- res = _sorted_specs(resolutions)
180
- n = len(res)
181
- mode_clean = mode.strip().strip('"') or "Static"
182
-
183
- impact: list[str] = []
184
- for k, r in enumerate(res):
185
- models = _sorted_specs(r.models)
186
- entries = [registry[_model_key(m.ref)] for m in models]
187
-
188
- def row(stem: str, values: list[str]) -> None:
189
- impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
190
-
191
- # From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
192
- # model FOV); everything else is a per-model knob taken straight from the cell.
193
- row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
194
- row("Dimension", [e["dimension"] for e in entries])
195
- row("NumberOfChannels", [e["numberofchannels"] for e in entries])
196
- row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
197
- row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
198
- row("LayersMask", [f'"{m.layers_mask}"' for m in models])
199
- row("SubsetFeatures", [str(m.subset_features) for m in models])
200
- row("PCA", [str(m.pca) for m in models])
201
- row("Distance", [f'"{m.distance}"' for m in models])
202
- row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
203
- impact.append("") # blank line between resolutions, mirroring the reference maps
204
-
205
- # The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
206
- # (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
207
- lines = template_text.splitlines()
208
- indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
209
- block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
210
- block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
211
-
212
- out: list[str] = []
213
- for i, (m, line) in enumerate(indexed):
214
- key = m.group(1) if m else None
215
- if block_lo <= i <= block_hi:
216
- if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
217
- out.extend(impact[:-1])
218
- elif key == "MaximumNumberOfIterations":
219
- out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
220
- elif key == "NumberOfResolutions":
221
- out.append(f"(NumberOfResolutions {n})")
222
- elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
223
- out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
224
- elif key == "ImpactMode":
225
- out.append(f'(ImpactMode "{mode_clean}")')
226
- else:
227
- out.append(line)
228
- return "\n".join(out)
229
-
230
-
231
- class ChannelSelect(torch.nn.Module):
232
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
233
-
234
- def __init__(self, start: int, stop: int) -> None:
235
- super().__init__()
236
- self._start = start
237
- self._stop = stop
238
-
239
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
240
- return tensor[:, self._start : self._stop]
241
-
242
-
243
- class RegistrationNet(network.Network):
244
- """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
245
- moving mask = 3; masks restrict the metric, whole-image = no restriction).
246
-
247
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
248
- (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
249
- ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
250
- ``same_as_group: Volume_0:Fixed``.
251
- """
252
-
253
- def __init__(
254
- self,
255
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
256
- schedulers: dict[str, network.LRSchedulersLoader] = {
257
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
258
- },
259
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
260
- engine: str = "elastix",
261
- parameter_maps: list[str] = [],
262
- max_iterations: Annotated[int, Range(0, 100000)] = 0,
263
- final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
264
- subset_features: Annotated[int, Range(0, 1000)] = 0,
265
- spatial_samples: Annotated[int, Range(0, 100000)] = 0,
266
- parameter_overrides: list[str] = [],
267
- resolutions: dict[str, ResolutionSpec] = {},
268
- mode: Literal["Static", "Jacobian"] = "Static",
269
- ) -> None:
270
- # The registration is fully described by ``resolutions`` (config = source of truth): each resolution
271
- # lists its self-configured models; the download list is derived from the cells. Global knobs override
272
- # the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
273
- # NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
274
- # preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
275
- from elastix_engine import ElastixRegistration
276
-
277
- super().__init__(
278
- in_channels=1,
279
- optimizer=optimizer,
280
- schedulers=schedulers,
281
- outputs_criterions=outputs_criterions,
282
- dim=3,
283
- )
284
- self.add_module(
285
- "Registration",
286
- ElastixRegistration(
287
- engine,
288
- parameter_maps,
289
- max_iterations,
290
- final_grid_spacing,
291
- subset_features,
292
- spatial_samples,
293
- parameter_overrides,
294
- resolutions,
295
- mode,
296
- ),
297
- in_branch=[0, 1, 2, 3],
298
- out_branch=["registration"],
299
- )
300
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
301
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Generic_Rigid_BSpline/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.elastix:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
Generic_Rigid_BSpline/elastix_engine.py DELETED
@@ -1,386 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Elastix-IMPACT runtime for the registration bundle.
18
-
19
- ``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
20
- parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
21
- ``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
22
- images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
23
- """
24
-
25
- import os
26
- import re
27
- import shutil
28
- import subprocess # nosec B404
29
- import tempfile
30
- from pathlib import Path
31
-
32
- import numpy as np
33
- import SimpleITK as sitk
34
- import torch
35
- import tqdm
36
- from huggingface_hub import hf_hub_download
37
- from install import get_elastix_bin, install_elastix_impact, try_elastix
38
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
39
-
40
- from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
41
-
42
- # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
43
- # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
44
- ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
45
-
46
-
47
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
48
- """True only for a mask that actually restricts the metric region — some voxels in, some out. An
49
- absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
50
- is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
51
- the whole image) instead of paying for a mask that restricts nothing."""
52
- if mask is None:
53
- return False
54
- arr = sitk.GetArrayViewFromImage(mask)
55
- return bool((arr > 0).any()) and bool((arr == 0).any())
56
-
57
-
58
- class ElastixEngine:
59
- """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
60
-
61
- NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
62
- NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
63
- """
64
-
65
- def __init__(
66
- self,
67
- parameter_maps: list[str],
68
- max_iterations: int = 0,
69
- final_grid_spacing: float = 0.0,
70
- subset_features: int = 0,
71
- spatial_samples: int = 0,
72
- parameter_overrides: list[str] = [],
73
- resolutions: dict = {},
74
- mode: str = "Static",
75
- ) -> None:
76
- self._bundle_dir = Path(__file__).resolve().parent
77
- self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
78
- self._max_iterations = max_iterations
79
- self._final_grid_spacing = final_grid_spacing
80
- self._subset_features = subset_features
81
- self._spatial_samples = spatial_samples
82
- self._parameter_overrides = list(parameter_overrides)
83
- # ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
84
- # samples random FOV-sized patches each iteration. One mode per preset.
85
- self._mode = mode
86
- # Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
87
- # intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
88
- self._resolutions = resolutions
89
- self._registry = load_models_registry() if resolutions else {}
90
- # Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
91
- models: list[str] = []
92
- for res in _sorted_specs(resolutions):
93
- for model in _sorted_specs(res.models):
94
- if model.ref not in models:
95
- models.append(model.ref)
96
- self._models = models
97
- # ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
98
- self._iterations = self._total_iterations()
99
- self._elastix_bin = self._ensure_binary()
100
- self._local_models = self._download_models()
101
-
102
- def _total_iterations(self) -> int:
103
- """Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
104
- if self._resolutions:
105
- return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
106
- total = 0
107
- for src in self._parameter_maps:
108
- match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
109
- if match:
110
- total += sum(int(token) for token in match.group(1).split())
111
- return total
112
-
113
- def _ensure_binary(self) -> Path:
114
- # Optional override: point at an existing elastix-IMPACT install (skips the download).
115
- override = os.environ.get("KONFAI_ELASTIX_DIR", "")
116
- if override:
117
- try_elastix(Path(override))
118
- return get_elastix_bin(Path(override)).resolve()
119
- ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
120
- try:
121
- try_elastix(ELASTIX_CACHE)
122
- except Exception:
123
- install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
124
- try_elastix(ELASTIX_CACHE)
125
- return get_elastix_bin(ELASTIX_CACHE).resolve()
126
-
127
- def _download_models(self) -> list[tuple[str, Path]]:
128
- """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
129
- models = []
130
- for ref in self._models:
131
- repo, filename = ref.split(":", 1)
132
- local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
- models.append((filename, local))
134
- return models
135
-
136
- def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
137
- """The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
138
-
139
- ``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
140
- **each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
141
- ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
142
- knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
143
- ``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
144
- """
145
- per_token: dict[str, str] = {}
146
- if not global_only and self._max_iterations > 0:
147
- per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
148
- if self._final_grid_spacing > 0:
149
- per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
150
- if not global_only and self._subset_features > 0:
151
- per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
152
- if self._spatial_samples > 0:
153
- per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
154
- exact: list[tuple[str, str]] = []
155
- for entry in self._parameter_overrides:
156
- key, sep, value = entry.partition("=")
157
- if not sep or not key.strip():
158
- raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
159
- exact.append((key.strip(), value.strip()))
160
- return per_token, exact
161
-
162
- @staticmethod
163
- def _apply_map_overrides(
164
- text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
165
- ) -> str:
166
- """Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
167
- of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
168
- """
169
- entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
170
- requested = set(per_token) | {key for key, _ in exact}
171
- seen: set[str] = set()
172
- lines = []
173
- for line in text.splitlines():
174
- match = entry_pattern.match(line)
175
- if match:
176
- indent, key, values = match.group(1), match.group(2), match.group(3)
177
- if key == "ImpactGPU":
178
- line = f"{indent}(ImpactGPU {device_index})"
179
- else:
180
- exact_value = next((value for k, value in exact if k == key), None)
181
- if exact_value is not None:
182
- seen.add(key)
183
- line = f"{indent}({key} {exact_value})"
184
- else:
185
- token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
186
- if token_key in per_token:
187
- seen.add(token_key)
188
- replaced = " ".join(per_token[token_key] for _ in values.split())
189
- line = f"{indent}({key} {replaced})"
190
- lines.append(line)
191
- # Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
192
- # surface it (e.g. final_grid_spacing on a rigid-only preset).
193
- for key in sorted(requested - seen):
194
- print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
195
- return "\n".join(lines)
196
-
197
- def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
198
- """Stage the parameter maps into ``work``.
199
-
200
- Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
201
- knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
202
- applies every per-token / exact override. Both set the ImpactGPU device.
203
- """
204
- staged = []
205
- for src in self._parameter_maps:
206
- if self._resolutions:
207
- text = generate_impact_parameter_map(
208
- src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
209
- )
210
- per_token, exact = self._parameter_map_overrides(global_only=True)
211
- else:
212
- text = src.read_text(encoding="utf-8")
213
- per_token, exact = self._parameter_map_overrides()
214
- text = self._apply_map_overrides(text, per_token, exact, device_index)
215
- dst = work / src.name
216
- dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
217
- staged.append(dst)
218
- return staged
219
-
220
- def register(
221
- self,
222
- fixed: sitk.Image,
223
- moving: sitk.Image,
224
- device_index: int,
225
- fixed_mask: sitk.Image | None = None,
226
- moving_mask: sitk.Image | None = None,
227
- ) -> tuple[np.ndarray, np.ndarray]:
228
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
229
-
230
- Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
231
- ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
232
- """
233
- work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
234
- try:
235
- fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
236
- sitk.WriteImage(fixed, str(fixed_path))
237
- sitk.WriteImage(moving, str(moving_path))
238
-
239
- # Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
240
- # "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
241
- for rel_name, model_path in self._local_models:
242
- dst = work / rel_name
243
- dst.parent.mkdir(parents=True, exist_ok=True)
244
- if not dst.exists():
245
- dst.symlink_to(model_path)
246
-
247
- args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
248
- for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
249
- if _is_partial_mask(mask):
250
- mask_path = work / name
251
- sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
252
- args += [flag, str(mask_path)]
253
- args += ["-out", str(work)]
254
- for pmap in self._stage_parameter_maps(work, device_index):
255
- args += ["-p", str(pmap)]
256
-
257
- # Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
258
- # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
259
- env = os.environ.copy()
260
- extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
261
- env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
262
- proc = subprocess.Popen( # nosec B603
263
- args,
264
- cwd=str(work),
265
- stdout=subprocess.PIPE,
266
- stderr=subprocess.STDOUT,
267
- text=True,
268
- bufsize=1,
269
- env=env,
270
- )
271
- # Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
272
- # progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
273
- # open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
274
- captured: list[str] = []
275
- iteration_line = re.compile(r"^\d+\s")
276
- budget = None if self._max_iterations > 0 else (self._iterations or None)
277
- progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
278
- assert proc.stdout is not None
279
- resolution = 0
280
- for line in proc.stdout:
281
- captured.append(line)
282
- stripped = line.strip()
283
- if stripped.startswith("Resolution:"):
284
- try:
285
- resolution = int(stripped.split(":", 1)[1])
286
- except ValueError:
287
- pass
288
- elif iteration_line.match(line):
289
- progress.update(1)
290
- columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
291
- if len(columns) > 1:
292
- try:
293
- progress.set_description(
294
- f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
295
- )
296
- except ValueError:
297
- pass
298
- progress.close()
299
- returncode = proc.wait()
300
- if returncode != 0:
301
- raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
302
-
303
- transforms = sorted(
304
- work.glob("TransformParameters.*-Composite.itk.txt"),
305
- key=lambda p: int(p.name.split(".")[1].split("-")[0]),
306
- )
307
- if not transforms:
308
- raise FileNotFoundError("elastix produced no composite transform file.")
309
- transform = sitk.ReadTransform(str(transforms[-1]))
310
-
311
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
312
- dvf = sitk.TransformToDisplacementField(
313
- transform,
314
- sitk.sitkVectorFloat64,
315
- fixed.GetSize(),
316
- fixed.GetOrigin(),
317
- fixed.GetSpacing(),
318
- fixed.GetDirection(),
319
- )
320
- moved_np, _ = image_to_data(moved)
321
- dvf_np, _ = image_to_data(dvf)
322
- return moved_np, dvf_np
323
- finally:
324
- shutil.rmtree(work, ignore_errors=True)
325
-
326
-
327
- class ElastixRegistration(torch.nn.Module):
328
- """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
329
-
330
- ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
331
- ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
332
- the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
333
- """
334
-
335
- accepts_attributes = True
336
-
337
- def __init__(
338
- self,
339
- engine: str,
340
- parameter_maps: list[str],
341
- max_iterations: int = 0,
342
- final_grid_spacing: float = 0.0,
343
- subset_features: int = 0,
344
- spatial_samples: int = 0,
345
- parameter_overrides: list[str] = [],
346
- resolutions: dict = {},
347
- mode: str = "Static",
348
- ) -> None:
349
- super().__init__()
350
- if engine != "elastix":
351
- raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
352
- self._engine = ElastixEngine(
353
- parameter_maps,
354
- max_iterations,
355
- final_grid_spacing,
356
- subset_features,
357
- spatial_samples,
358
- parameter_overrides,
359
- resolutions,
360
- mode,
361
- )
362
-
363
- def forward(
364
- self,
365
- fixed: torch.Tensor,
366
- moving: torch.Tensor,
367
- fixed_mask: torch.Tensor,
368
- moving_mask: torch.Tensor,
369
- attributes: list[list[Attribute]],
370
- ) -> torch.Tensor:
371
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
372
- # batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
373
- # the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
374
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
375
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
376
- combined = []
377
- for b in range(fixed.shape[0]):
378
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
379
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
380
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
381
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
382
- moved_np, dvf_np = self._engine.register(
383
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
384
- )
385
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
- return torch.stack(combined, dim=0).to(fixed.device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Generic_Rigid_BSpline/install.py DELETED
@@ -1,325 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- import argparse
18
- import platform
19
- import re
20
- import shutil
21
- import stat
22
- import subprocess # nosec B404
23
- import zipfile
24
- from pathlib import Path
25
-
26
- import requests
27
- from tqdm import tqdm
28
-
29
- # -----------------------------------------------------------------------------
30
- # Elastix + IMPACT binary assets hosted on GitHub Releases.
31
- #
32
- # Key format: (OS, ARCH, FLAVOR)
33
- # - OS : platform.system() -> "Linux", "Windows", "Darwin"
34
- # - ARCH : normalized architecture -> "x86_64"
35
- # - FLAVOR : "cpu" or "cu128"
36
- #
37
- # CPU assets are standalone (LibTorch CPU bundled).
38
- # CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
39
- # -----------------------------------------------------------------------------
40
- ELX_ASSET_TEMPLATE = {
41
- ("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
42
- ("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
43
- ("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
44
- ("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
45
- ("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
46
- }
47
-
48
- # -----------------------------------------------------------------------------
49
- # Official LibTorch downloads (PyTorch).
50
- #
51
- # IMPORTANT:
52
- # - Version MUST match the one used at build time (ABI compatibility).
53
- # - Using "shared-with-deps" ensures CUDA runtime libraries are included
54
- # (except the NVIDIA driver, which must be installed system-wide).
55
- # -----------------------------------------------------------------------------
56
-
57
- LIBTORCH_URL = {
58
- (
59
- "Linux",
60
- "x86_64",
61
- "cpu",
62
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
63
- (
64
- "Windows",
65
- "x86_64",
66
- "cpu",
67
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
68
- (
69
- "Darwin",
70
- "arm64",
71
- "cpu",
72
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
73
- (
74
- "Linux",
75
- "x86_64",
76
- "cu128",
77
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
78
- (
79
- "Windows",
80
- "x86_64",
81
- "cu128",
82
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
83
- }
84
-
85
- # -----------------------------------------------------------------------------
86
- # Minimum NVIDIA driver versions required for CUDA 12.8.
87
- #
88
- # The CUDA Toolkit itself is NOT required.
89
- # Only a sufficiently recent NVIDIA driver must be installed.
90
- # -----------------------------------------------------------------------------
91
- CUDA128_MIN_DRIVER_LINUX = (570, 26)
92
- CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
93
-
94
- GITHUB_OWNER = "vboussot"
95
- GITHUB_REPO = "ImpactElastix"
96
- GITHUB_TAG = "1.0.0"
97
-
98
-
99
- DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
100
-
101
-
102
- def run_cmd(cmd: list[str]) -> str:
103
- return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
104
-
105
-
106
- def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
107
- """
108
- Detect presence of an NVIDIA GPU and extract the driver version.
109
- Returns:
110
- (True, (major, minor)) if detected
111
- (False, None) if nvidia-smi is not available
112
- """
113
- try:
114
- nvidia_smi = shutil.which("nvidia-smi")
115
- if nvidia_smi is None:
116
- raise RuntimeError("nvidia-smi not found in PATH")
117
-
118
- out = (
119
- subprocess.check_output(
120
- [nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
121
- ) # nosec B603
122
- .decode("utf-8")
123
- .strip()
124
- )
125
- except Exception:
126
- return (False, None)
127
-
128
- # Exemple: "575.64"
129
- m = re.match(r"([0-9]+)\.([0-9]+)", out)
130
- if not m:
131
- return (True, None)
132
-
133
- return (True, (int(m.group(1)), int(m.group(2))))
134
-
135
-
136
- def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
137
- """
138
- Check whether the detected NVIDIA driver satisfies the minimum
139
- requirement for CUDA 12.8 on the given operating system.
140
- """
141
- if drv is None:
142
- return False
143
- if os_name == "Linux":
144
- return drv >= CUDA128_MIN_DRIVER_LINUX
145
- if os_name == "Windows":
146
- return drv >= CUDA128_MIN_DRIVER_WINDOWS
147
- return False
148
-
149
-
150
- def normalize_arch(machine: str) -> str:
151
- """
152
- Normalize platform.machine() output across operating systems.
153
-
154
- Examples:
155
- AMD64 -> x86_64
156
- aarch64 -> arm64
157
- """
158
- m = machine.lower()
159
- if m in ("x86_64", "amd64"):
160
- return "x86_64"
161
- if m in ("aarch64", "arm64"):
162
- return "arm64"
163
- return machine
164
-
165
-
166
- def download_file(url: str, dst: Path) -> None:
167
- """
168
- Download a file from the given URL with a progress indicator.
169
- """
170
- dst.parent.mkdir(parents=True, exist_ok=True)
171
- print(f"Downloading: {url}", flush=True)
172
- print(f" to: {dst}", flush=True)
173
-
174
- try:
175
- with requests.get(url, stream=True, timeout=60) as r:
176
- r.raise_for_status()
177
- total = int(r.headers.get("content-length", 0))
178
- with open(dst, "wb") as f:
179
- with tqdm(
180
- total=total,
181
- unit="B",
182
- unit_scale=True,
183
- desc=f"Downloading {dst.name}",
184
- ) as pbar:
185
- for chunk in r.iter_content(chunk_size=8192):
186
- f.write(chunk)
187
- pbar.update(len(chunk))
188
- except Exception as e:
189
- raise e
190
-
191
-
192
- def extract_archive(archive: Path, dst_dir: Path) -> None:
193
- """
194
- Extract a ZIP archive to the destination directory.
195
- """
196
- dst_dir.mkdir(parents=True, exist_ok=True)
197
- print(f"Extracting: {archive} -> {dst_dir}", flush=True)
198
- with zipfile.ZipFile(archive, "r") as z:
199
- z.extractall(dst_dir)
200
- archive.unlink()
201
-
202
-
203
- def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
204
- os_name = platform.system()
205
- arch = normalize_arch(platform.machine())
206
- has_nvidia, drv = detect_nvidia_driver()
207
-
208
- if os_name not in ("Linux", "Windows", "Darwin"):
209
- raise NameError(f"Unsupported OS: {os_name}")
210
-
211
- if arch not in ("x86_64", "arm64"):
212
- raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
213
-
214
- flavor = "cpu"
215
- if force_cuda:
216
- if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
217
- raise NameError(
218
- "CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
219
- )
220
-
221
- flavor = "cu128"
222
- elif not force_cpu:
223
- if has_nvidia and driver_ok_for_cuda(os_name, drv):
224
- flavor = "cu128"
225
- else:
226
- flavor = "cpu"
227
-
228
- print(f"System: {os_name} {arch}", flush=True)
229
- print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
230
- print(f"Selected flavor: {flavor}", flush=True)
231
-
232
- key = (os_name, arch, flavor)
233
- if key not in ELX_ASSET_TEMPLATE:
234
- raise NameError(f"No elastix asset configured for {key}")
235
- if flavor != "cpu" and key not in LIBTORCH_URL:
236
- raise NameError(f"No libtorch url configured for {key}")
237
-
238
- install_path = install_path.resolve()
239
- install_path.mkdir(parents=True, exist_ok=True)
240
-
241
- elx_asset = ELX_ASSET_TEMPLATE[key]
242
- elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
243
- elx_archive = install_path / elx_asset
244
- download_file(elx_url, elx_archive)
245
- extract_archive(elx_archive, install_path)
246
-
247
- # -------------------------------------------------------------------------
248
- # ZIP archives may drop executable permissions.
249
- # Ensure elastix and transformix are executable on Unix platforms.
250
- # -------------------------------------------------------------------------
251
- if os_name in ("Linux", "Darwin"):
252
- for exe in ("elastix", "transformix"):
253
- p = install_path / "bin" / exe
254
- if p.exists():
255
- p.chmod(p.stat().st_mode | stat.S_IEXEC)
256
-
257
- # ---------------------------------------------------------------------
258
- # - Download matching LibTorch 12.8 with or without CUDA 12.8
259
- # - Extract it
260
- # - Move runtime libraries to a location visible to the dynamic loader
261
- #
262
- # Linux / macOS : prefix/lib
263
- # Windows : prefix (next to elastix.exe)
264
- # ---------------------------------------------------------------------
265
- lt_url = LIBTORCH_URL[key]
266
- lt_archive = install_path / Path(lt_url).name
267
- download_file(lt_url, lt_archive)
268
- extract_archive(lt_archive, install_path)
269
- for p in (install_path / "libtorch" / "lib").iterdir():
270
- if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
271
- shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
272
-
273
- shutil.rmtree(install_path / "libtorch")
274
- if os_name == "Linux" and flavor == "cu128":
275
- shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
276
-
277
-
278
- def get_elastix_bin(install_path: Path) -> Path:
279
- return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
280
-
281
-
282
- def try_elastix(install_path: Path) -> None:
283
- try:
284
- subprocess.run(
285
- [str(get_elastix_bin(install_path)), "-h"],
286
- capture_output=True,
287
- text=True,
288
- check=True,
289
- ) # nosec B603
290
- except subprocess.CalledProcessError as e:
291
- msg = "Elastix execution failed.\n\n"
292
-
293
- msg += f"Command:\n{' '.join(e.cmd)}\n"
294
- msg += f"Return code: {e.returncode}\n\n"
295
-
296
- if e.stderr:
297
- msg += "Error output:\n"
298
- msg += e.stderr.strip()
299
- raise NameError(msg)
300
-
301
- except OSError as e:
302
- msg = (
303
- "Elastix could not be started.\n\n"
304
- "This is usually caused by missing shared libraries "
305
- "(e.g. LibTorch or CUDA runtime).\n\n"
306
- f"System error:\n{str(e)}"
307
- )
308
-
309
- raise NameError(msg)
310
-
311
-
312
- def main() -> None:
313
- ap = argparse.ArgumentParser()
314
- ap.add_argument(
315
- "--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
316
- )
317
- group = ap.add_mutually_exclusive_group()
318
- group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
319
- group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
320
- args = vars(ap.parse_args())
321
- install_elastix_impact(**args)
322
-
323
-
324
- if __name__ == "__main__":
325
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Generic_Rigid_BSpline/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ impact_reg_konfai>=1.6.1
MR_CT_HeadNeck/Model.py DELETED
@@ -1,301 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
18
-
19
- ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
20
- 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
21
- the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
22
- the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
23
- subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
24
-
25
- A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
26
- ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
27
-
28
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
29
- (``get_origin``); PEP 563 stringized annotations break arg resolution.
30
- """
31
-
32
- import json
33
- import os
34
- import re
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Annotated, Literal
38
-
39
- import torch
40
- from huggingface_hub import hf_hub_download
41
- from konfai.network import network
42
- from konfai.utils.config import Choices, Range
43
-
44
- # IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
45
- # A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
46
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
47
- # iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
48
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
49
-
50
- # ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
51
- # Static anyway; in Jacobian we clamp ``l`` to this plateau.
52
- _FOV_RAMP_MAX_LAYER = 6
53
-
54
-
55
- def registry_choices() -> list[str]:
56
- """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
57
- (offline-first). A user may still point ``ref`` at a local model."""
58
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
59
- return [f"{repo}:{key}" for key in load_models_registry()]
60
-
61
-
62
- def _num(x: object) -> str:
63
- """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
64
- return "%g" % float(x)
65
-
66
-
67
- @dataclass
68
- class ModelSpec:
69
- """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
70
- rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
71
- (``models.json``) keyed by ``ref`` — never tuned."""
72
-
73
- ref: Annotated[str, Choices(registry_choices)]
74
- voxel_size: list[float] = field(default_factory=list)
75
- layers_weight: list[float] = field(default_factory=lambda: [1.0])
76
- subset_features: Annotated[int, Range(0, 1000)] = 0
77
- pca: Annotated[int, Range(0, 100)] = 0
78
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
79
- layers_mask: str = ""
80
-
81
-
82
- @dataclass
83
- class ResolutionSpec:
84
- """One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
85
-
86
- max_iterations: Annotated[int, Range(1, 100000)]
87
- models: dict[str, ModelSpec]
88
-
89
-
90
- def _sorted_specs(mapping: dict) -> list:
91
- """dict keyed by string indices ('0','1',...) -> values in numeric order."""
92
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
93
-
94
-
95
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
96
- """Load models.json (the fixed params per model) from the model repo on Hugging Face.
97
-
98
- The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
99
- dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
100
- """
101
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
102
- if local:
103
- path = Path(local)
104
- elif ":" in ref:
105
- repo, filename = ref.split(":", 1)
106
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
107
- else:
108
- raise ValueError(
109
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
110
- f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
111
- )
112
- return json.loads(path.read_text(encoding="utf-8"))
113
-
114
-
115
- def _model_key(ref: str) -> str:
116
- """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
117
- return ref.split(":", 1)[1] if ":" in ref else ref
118
-
119
-
120
- def _deepest_active_layer(layers_mask: str) -> int:
121
- """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
122
-
123
- A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
124
- == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
125
- receptive field, so the FOV is governed by the rightmost ``'1'``.
126
- """
127
- mask = layers_mask.strip().strip('"')
128
- active = [i for i, char in enumerate(mask) if char == "1"]
129
- if not active:
130
- raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
131
- return max(active)
132
-
133
-
134
- def _fov_value(fov: dict, layers_mask: str) -> int:
135
- """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
136
-
137
- Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
138
- ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
139
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
140
- to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
141
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
142
- ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
143
- An explicit ``value`` in the spec is honoured as a precomputed shortcut.
144
- """
145
- formula = str(fov.get("formula", "")).strip()
146
- key = re.sub(r"\s+", "", formula).lower()
147
- if key.isdigit():
148
- return int(key)
149
- if key == "2*r*d+1":
150
- return 2 * int(fov["r"]) * int(fov["d"]) + 1
151
- if key == "2^l+3":
152
- return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
153
- if "global" in key:
154
- raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
155
- if fov.get("value") is not None:
156
- return int(fov["value"])
157
- raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
158
-
159
-
160
- def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
161
- """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
162
- image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
163
- e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
164
- dim = int(entry.get("dimension", 3))
165
- if mode.strip().strip('"').lower() != "jacobian":
166
- return " ".join(["0"] * dim)
167
- fov = _fov_value(entry.get("fov", {}), layers_mask)
168
- return " ".join([str(fov)] * dim)
169
-
170
-
171
- def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
172
- """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
173
-
174
- Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
175
- ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
176
- is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
177
- from the registry formula and the cell's ``layers_mask``.
178
- """
179
- res = _sorted_specs(resolutions)
180
- n = len(res)
181
- mode_clean = mode.strip().strip('"') or "Static"
182
-
183
- impact: list[str] = []
184
- for k, r in enumerate(res):
185
- models = _sorted_specs(r.models)
186
- entries = [registry[_model_key(m.ref)] for m in models]
187
-
188
- def row(stem: str, values: list[str]) -> None:
189
- impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
190
-
191
- # From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
192
- # model FOV); everything else is a per-model knob taken straight from the cell.
193
- row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
194
- row("Dimension", [e["dimension"] for e in entries])
195
- row("NumberOfChannels", [e["numberofchannels"] for e in entries])
196
- row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
197
- row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
198
- row("LayersMask", [f'"{m.layers_mask}"' for m in models])
199
- row("SubsetFeatures", [str(m.subset_features) for m in models])
200
- row("PCA", [str(m.pca) for m in models])
201
- row("Distance", [f'"{m.distance}"' for m in models])
202
- row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
203
- impact.append("") # blank line between resolutions, mirroring the reference maps
204
-
205
- # The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
206
- # (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
207
- lines = template_text.splitlines()
208
- indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
209
- block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
210
- block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
211
-
212
- out: list[str] = []
213
- for i, (m, line) in enumerate(indexed):
214
- key = m.group(1) if m else None
215
- if block_lo <= i <= block_hi:
216
- if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
217
- out.extend(impact[:-1])
218
- elif key == "MaximumNumberOfIterations":
219
- out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
220
- elif key == "NumberOfResolutions":
221
- out.append(f"(NumberOfResolutions {n})")
222
- elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
223
- out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
224
- elif key == "ImpactMode":
225
- out.append(f'(ImpactMode "{mode_clean}")')
226
- else:
227
- out.append(line)
228
- return "\n".join(out)
229
-
230
-
231
- class ChannelSelect(torch.nn.Module):
232
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
233
-
234
- def __init__(self, start: int, stop: int) -> None:
235
- super().__init__()
236
- self._start = start
237
- self._stop = stop
238
-
239
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
240
- return tensor[:, self._start : self._stop]
241
-
242
-
243
- class RegistrationNet(network.Network):
244
- """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
245
- moving mask = 3; masks restrict the metric, whole-image = no restriction).
246
-
247
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
248
- (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
249
- ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
250
- ``same_as_group: Volume_0:Fixed``.
251
- """
252
-
253
- def __init__(
254
- self,
255
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
256
- schedulers: dict[str, network.LRSchedulersLoader] = {
257
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
258
- },
259
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
260
- engine: str = "elastix",
261
- parameter_maps: list[str] = [],
262
- max_iterations: Annotated[int, Range(0, 100000)] = 0,
263
- final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
264
- subset_features: Annotated[int, Range(0, 1000)] = 0,
265
- spatial_samples: Annotated[int, Range(0, 100000)] = 0,
266
- parameter_overrides: list[str] = [],
267
- resolutions: dict[str, ResolutionSpec] = {},
268
- mode: Literal["Static", "Jacobian"] = "Static",
269
- ) -> None:
270
- # The registration is fully described by ``resolutions`` (config = source of truth): each resolution
271
- # lists its self-configured models; the download list is derived from the cells. Global knobs override
272
- # the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
273
- # NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
274
- # preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
275
- from elastix_engine import ElastixRegistration
276
-
277
- super().__init__(
278
- in_channels=1,
279
- optimizer=optimizer,
280
- schedulers=schedulers,
281
- outputs_criterions=outputs_criterions,
282
- dim=3,
283
- )
284
- self.add_module(
285
- "Registration",
286
- ElastixRegistration(
287
- engine,
288
- parameter_maps,
289
- max_iterations,
290
- final_grid_spacing,
291
- subset_features,
292
- spatial_samples,
293
- parameter_overrides,
294
- resolutions,
295
- mode,
296
- ),
297
- in_branch=[0, 1, 2, 3],
298
- out_branch=["registration"],
299
- )
300
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
301
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MR_CT_HeadNeck/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.elastix:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
MR_CT_HeadNeck/elastix_engine.py DELETED
@@ -1,386 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Elastix-IMPACT runtime for the registration bundle.
18
-
19
- ``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
20
- parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
21
- ``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
22
- images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
23
- """
24
-
25
- import os
26
- import re
27
- import shutil
28
- import subprocess # nosec B404
29
- import tempfile
30
- from pathlib import Path
31
-
32
- import numpy as np
33
- import SimpleITK as sitk
34
- import torch
35
- import tqdm
36
- from huggingface_hub import hf_hub_download
37
- from install import get_elastix_bin, install_elastix_impact, try_elastix
38
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
39
-
40
- from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
41
-
42
- # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
43
- # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
44
- ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
45
-
46
-
47
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
48
- """True only for a mask that actually restricts the metric region — some voxels in, some out. An
49
- absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
50
- is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
51
- the whole image) instead of paying for a mask that restricts nothing."""
52
- if mask is None:
53
- return False
54
- arr = sitk.GetArrayViewFromImage(mask)
55
- return bool((arr > 0).any()) and bool((arr == 0).any())
56
-
57
-
58
- class ElastixEngine:
59
- """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
60
-
61
- NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
62
- NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
63
- """
64
-
65
- def __init__(
66
- self,
67
- parameter_maps: list[str],
68
- max_iterations: int = 0,
69
- final_grid_spacing: float = 0.0,
70
- subset_features: int = 0,
71
- spatial_samples: int = 0,
72
- parameter_overrides: list[str] = [],
73
- resolutions: dict = {},
74
- mode: str = "Static",
75
- ) -> None:
76
- self._bundle_dir = Path(__file__).resolve().parent
77
- self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
78
- self._max_iterations = max_iterations
79
- self._final_grid_spacing = final_grid_spacing
80
- self._subset_features = subset_features
81
- self._spatial_samples = spatial_samples
82
- self._parameter_overrides = list(parameter_overrides)
83
- # ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
84
- # samples random FOV-sized patches each iteration. One mode per preset.
85
- self._mode = mode
86
- # Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
87
- # intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
88
- self._resolutions = resolutions
89
- self._registry = load_models_registry() if resolutions else {}
90
- # Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
91
- models: list[str] = []
92
- for res in _sorted_specs(resolutions):
93
- for model in _sorted_specs(res.models):
94
- if model.ref not in models:
95
- models.append(model.ref)
96
- self._models = models
97
- # ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
98
- self._iterations = self._total_iterations()
99
- self._elastix_bin = self._ensure_binary()
100
- self._local_models = self._download_models()
101
-
102
- def _total_iterations(self) -> int:
103
- """Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
104
- if self._resolutions:
105
- return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
106
- total = 0
107
- for src in self._parameter_maps:
108
- match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
109
- if match:
110
- total += sum(int(token) for token in match.group(1).split())
111
- return total
112
-
113
- def _ensure_binary(self) -> Path:
114
- # Optional override: point at an existing elastix-IMPACT install (skips the download).
115
- override = os.environ.get("KONFAI_ELASTIX_DIR", "")
116
- if override:
117
- try_elastix(Path(override))
118
- return get_elastix_bin(Path(override)).resolve()
119
- ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
120
- try:
121
- try_elastix(ELASTIX_CACHE)
122
- except Exception:
123
- install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
124
- try_elastix(ELASTIX_CACHE)
125
- return get_elastix_bin(ELASTIX_CACHE).resolve()
126
-
127
- def _download_models(self) -> list[tuple[str, Path]]:
128
- """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
129
- models = []
130
- for ref in self._models:
131
- repo, filename = ref.split(":", 1)
132
- local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
- models.append((filename, local))
134
- return models
135
-
136
- def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
137
- """The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
138
-
139
- ``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
140
- **each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
141
- ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
142
- knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
143
- ``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
144
- """
145
- per_token: dict[str, str] = {}
146
- if not global_only and self._max_iterations > 0:
147
- per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
148
- if self._final_grid_spacing > 0:
149
- per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
150
- if not global_only and self._subset_features > 0:
151
- per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
152
- if self._spatial_samples > 0:
153
- per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
154
- exact: list[tuple[str, str]] = []
155
- for entry in self._parameter_overrides:
156
- key, sep, value = entry.partition("=")
157
- if not sep or not key.strip():
158
- raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
159
- exact.append((key.strip(), value.strip()))
160
- return per_token, exact
161
-
162
- @staticmethod
163
- def _apply_map_overrides(
164
- text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
165
- ) -> str:
166
- """Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
167
- of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
168
- """
169
- entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
170
- requested = set(per_token) | {key for key, _ in exact}
171
- seen: set[str] = set()
172
- lines = []
173
- for line in text.splitlines():
174
- match = entry_pattern.match(line)
175
- if match:
176
- indent, key, values = match.group(1), match.group(2), match.group(3)
177
- if key == "ImpactGPU":
178
- line = f"{indent}(ImpactGPU {device_index})"
179
- else:
180
- exact_value = next((value for k, value in exact if k == key), None)
181
- if exact_value is not None:
182
- seen.add(key)
183
- line = f"{indent}({key} {exact_value})"
184
- else:
185
- token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
186
- if token_key in per_token:
187
- seen.add(token_key)
188
- replaced = " ".join(per_token[token_key] for _ in values.split())
189
- line = f"{indent}({key} {replaced})"
190
- lines.append(line)
191
- # Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
192
- # surface it (e.g. final_grid_spacing on a rigid-only preset).
193
- for key in sorted(requested - seen):
194
- print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
195
- return "\n".join(lines)
196
-
197
- def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
198
- """Stage the parameter maps into ``work``.
199
-
200
- Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
201
- knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
202
- applies every per-token / exact override. Both set the ImpactGPU device.
203
- """
204
- staged = []
205
- for src in self._parameter_maps:
206
- if self._resolutions:
207
- text = generate_impact_parameter_map(
208
- src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
209
- )
210
- per_token, exact = self._parameter_map_overrides(global_only=True)
211
- else:
212
- text = src.read_text(encoding="utf-8")
213
- per_token, exact = self._parameter_map_overrides()
214
- text = self._apply_map_overrides(text, per_token, exact, device_index)
215
- dst = work / src.name
216
- dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
217
- staged.append(dst)
218
- return staged
219
-
220
- def register(
221
- self,
222
- fixed: sitk.Image,
223
- moving: sitk.Image,
224
- device_index: int,
225
- fixed_mask: sitk.Image | None = None,
226
- moving_mask: sitk.Image | None = None,
227
- ) -> tuple[np.ndarray, np.ndarray]:
228
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
229
-
230
- Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
231
- ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
232
- """
233
- work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
234
- try:
235
- fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
236
- sitk.WriteImage(fixed, str(fixed_path))
237
- sitk.WriteImage(moving, str(moving_path))
238
-
239
- # Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
240
- # "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
241
- for rel_name, model_path in self._local_models:
242
- dst = work / rel_name
243
- dst.parent.mkdir(parents=True, exist_ok=True)
244
- if not dst.exists():
245
- dst.symlink_to(model_path)
246
-
247
- args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
248
- for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
249
- if _is_partial_mask(mask):
250
- mask_path = work / name
251
- sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
252
- args += [flag, str(mask_path)]
253
- args += ["-out", str(work)]
254
- for pmap in self._stage_parameter_maps(work, device_index):
255
- args += ["-p", str(pmap)]
256
-
257
- # Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
258
- # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
259
- env = os.environ.copy()
260
- extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
261
- env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
262
- proc = subprocess.Popen( # nosec B603
263
- args,
264
- cwd=str(work),
265
- stdout=subprocess.PIPE,
266
- stderr=subprocess.STDOUT,
267
- text=True,
268
- bufsize=1,
269
- env=env,
270
- )
271
- # Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
272
- # progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
273
- # open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
274
- captured: list[str] = []
275
- iteration_line = re.compile(r"^\d+\s")
276
- budget = None if self._max_iterations > 0 else (self._iterations or None)
277
- progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
278
- assert proc.stdout is not None
279
- resolution = 0
280
- for line in proc.stdout:
281
- captured.append(line)
282
- stripped = line.strip()
283
- if stripped.startswith("Resolution:"):
284
- try:
285
- resolution = int(stripped.split(":", 1)[1])
286
- except ValueError:
287
- pass
288
- elif iteration_line.match(line):
289
- progress.update(1)
290
- columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
291
- if len(columns) > 1:
292
- try:
293
- progress.set_description(
294
- f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
295
- )
296
- except ValueError:
297
- pass
298
- progress.close()
299
- returncode = proc.wait()
300
- if returncode != 0:
301
- raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
302
-
303
- transforms = sorted(
304
- work.glob("TransformParameters.*-Composite.itk.txt"),
305
- key=lambda p: int(p.name.split(".")[1].split("-")[0]),
306
- )
307
- if not transforms:
308
- raise FileNotFoundError("elastix produced no composite transform file.")
309
- transform = sitk.ReadTransform(str(transforms[-1]))
310
-
311
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
312
- dvf = sitk.TransformToDisplacementField(
313
- transform,
314
- sitk.sitkVectorFloat64,
315
- fixed.GetSize(),
316
- fixed.GetOrigin(),
317
- fixed.GetSpacing(),
318
- fixed.GetDirection(),
319
- )
320
- moved_np, _ = image_to_data(moved)
321
- dvf_np, _ = image_to_data(dvf)
322
- return moved_np, dvf_np
323
- finally:
324
- shutil.rmtree(work, ignore_errors=True)
325
-
326
-
327
- class ElastixRegistration(torch.nn.Module):
328
- """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
329
-
330
- ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
331
- ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
332
- the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
333
- """
334
-
335
- accepts_attributes = True
336
-
337
- def __init__(
338
- self,
339
- engine: str,
340
- parameter_maps: list[str],
341
- max_iterations: int = 0,
342
- final_grid_spacing: float = 0.0,
343
- subset_features: int = 0,
344
- spatial_samples: int = 0,
345
- parameter_overrides: list[str] = [],
346
- resolutions: dict = {},
347
- mode: str = "Static",
348
- ) -> None:
349
- super().__init__()
350
- if engine != "elastix":
351
- raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
352
- self._engine = ElastixEngine(
353
- parameter_maps,
354
- max_iterations,
355
- final_grid_spacing,
356
- subset_features,
357
- spatial_samples,
358
- parameter_overrides,
359
- resolutions,
360
- mode,
361
- )
362
-
363
- def forward(
364
- self,
365
- fixed: torch.Tensor,
366
- moving: torch.Tensor,
367
- fixed_mask: torch.Tensor,
368
- moving_mask: torch.Tensor,
369
- attributes: list[list[Attribute]],
370
- ) -> torch.Tensor:
371
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
372
- # batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
373
- # the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
374
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
375
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
376
- combined = []
377
- for b in range(fixed.shape[0]):
378
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
379
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
380
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
381
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
382
- moved_np, dvf_np = self._engine.register(
383
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
384
- )
385
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
- return torch.stack(combined, dim=0).to(fixed.device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MR_CT_HeadNeck/install.py DELETED
@@ -1,325 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- import argparse
18
- import platform
19
- import re
20
- import shutil
21
- import stat
22
- import subprocess # nosec B404
23
- import zipfile
24
- from pathlib import Path
25
-
26
- import requests
27
- from tqdm import tqdm
28
-
29
- # -----------------------------------------------------------------------------
30
- # Elastix + IMPACT binary assets hosted on GitHub Releases.
31
- #
32
- # Key format: (OS, ARCH, FLAVOR)
33
- # - OS : platform.system() -> "Linux", "Windows", "Darwin"
34
- # - ARCH : normalized architecture -> "x86_64"
35
- # - FLAVOR : "cpu" or "cu128"
36
- #
37
- # CPU assets are standalone (LibTorch CPU bundled).
38
- # CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
39
- # -----------------------------------------------------------------------------
40
- ELX_ASSET_TEMPLATE = {
41
- ("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
42
- ("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
43
- ("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
44
- ("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
45
- ("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
46
- }
47
-
48
- # -----------------------------------------------------------------------------
49
- # Official LibTorch downloads (PyTorch).
50
- #
51
- # IMPORTANT:
52
- # - Version MUST match the one used at build time (ABI compatibility).
53
- # - Using "shared-with-deps" ensures CUDA runtime libraries are included
54
- # (except the NVIDIA driver, which must be installed system-wide).
55
- # -----------------------------------------------------------------------------
56
-
57
- LIBTORCH_URL = {
58
- (
59
- "Linux",
60
- "x86_64",
61
- "cpu",
62
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
63
- (
64
- "Windows",
65
- "x86_64",
66
- "cpu",
67
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
68
- (
69
- "Darwin",
70
- "arm64",
71
- "cpu",
72
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
73
- (
74
- "Linux",
75
- "x86_64",
76
- "cu128",
77
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
78
- (
79
- "Windows",
80
- "x86_64",
81
- "cu128",
82
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
83
- }
84
-
85
- # -----------------------------------------------------------------------------
86
- # Minimum NVIDIA driver versions required for CUDA 12.8.
87
- #
88
- # The CUDA Toolkit itself is NOT required.
89
- # Only a sufficiently recent NVIDIA driver must be installed.
90
- # -----------------------------------------------------------------------------
91
- CUDA128_MIN_DRIVER_LINUX = (570, 26)
92
- CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
93
-
94
- GITHUB_OWNER = "vboussot"
95
- GITHUB_REPO = "ImpactElastix"
96
- GITHUB_TAG = "1.0.0"
97
-
98
-
99
- DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
100
-
101
-
102
- def run_cmd(cmd: list[str]) -> str:
103
- return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
104
-
105
-
106
- def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
107
- """
108
- Detect presence of an NVIDIA GPU and extract the driver version.
109
- Returns:
110
- (True, (major, minor)) if detected
111
- (False, None) if nvidia-smi is not available
112
- """
113
- try:
114
- nvidia_smi = shutil.which("nvidia-smi")
115
- if nvidia_smi is None:
116
- raise RuntimeError("nvidia-smi not found in PATH")
117
-
118
- out = (
119
- subprocess.check_output(
120
- [nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
121
- ) # nosec B603
122
- .decode("utf-8")
123
- .strip()
124
- )
125
- except Exception:
126
- return (False, None)
127
-
128
- # Exemple: "575.64"
129
- m = re.match(r"([0-9]+)\.([0-9]+)", out)
130
- if not m:
131
- return (True, None)
132
-
133
- return (True, (int(m.group(1)), int(m.group(2))))
134
-
135
-
136
- def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
137
- """
138
- Check whether the detected NVIDIA driver satisfies the minimum
139
- requirement for CUDA 12.8 on the given operating system.
140
- """
141
- if drv is None:
142
- return False
143
- if os_name == "Linux":
144
- return drv >= CUDA128_MIN_DRIVER_LINUX
145
- if os_name == "Windows":
146
- return drv >= CUDA128_MIN_DRIVER_WINDOWS
147
- return False
148
-
149
-
150
- def normalize_arch(machine: str) -> str:
151
- """
152
- Normalize platform.machine() output across operating systems.
153
-
154
- Examples:
155
- AMD64 -> x86_64
156
- aarch64 -> arm64
157
- """
158
- m = machine.lower()
159
- if m in ("x86_64", "amd64"):
160
- return "x86_64"
161
- if m in ("aarch64", "arm64"):
162
- return "arm64"
163
- return machine
164
-
165
-
166
- def download_file(url: str, dst: Path) -> None:
167
- """
168
- Download a file from the given URL with a progress indicator.
169
- """
170
- dst.parent.mkdir(parents=True, exist_ok=True)
171
- print(f"Downloading: {url}", flush=True)
172
- print(f" to: {dst}", flush=True)
173
-
174
- try:
175
- with requests.get(url, stream=True, timeout=60) as r:
176
- r.raise_for_status()
177
- total = int(r.headers.get("content-length", 0))
178
- with open(dst, "wb") as f:
179
- with tqdm(
180
- total=total,
181
- unit="B",
182
- unit_scale=True,
183
- desc=f"Downloading {dst.name}",
184
- ) as pbar:
185
- for chunk in r.iter_content(chunk_size=8192):
186
- f.write(chunk)
187
- pbar.update(len(chunk))
188
- except Exception as e:
189
- raise e
190
-
191
-
192
- def extract_archive(archive: Path, dst_dir: Path) -> None:
193
- """
194
- Extract a ZIP archive to the destination directory.
195
- """
196
- dst_dir.mkdir(parents=True, exist_ok=True)
197
- print(f"Extracting: {archive} -> {dst_dir}", flush=True)
198
- with zipfile.ZipFile(archive, "r") as z:
199
- z.extractall(dst_dir)
200
- archive.unlink()
201
-
202
-
203
- def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
204
- os_name = platform.system()
205
- arch = normalize_arch(platform.machine())
206
- has_nvidia, drv = detect_nvidia_driver()
207
-
208
- if os_name not in ("Linux", "Windows", "Darwin"):
209
- raise NameError(f"Unsupported OS: {os_name}")
210
-
211
- if arch not in ("x86_64", "arm64"):
212
- raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
213
-
214
- flavor = "cpu"
215
- if force_cuda:
216
- if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
217
- raise NameError(
218
- "CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
219
- )
220
-
221
- flavor = "cu128"
222
- elif not force_cpu:
223
- if has_nvidia and driver_ok_for_cuda(os_name, drv):
224
- flavor = "cu128"
225
- else:
226
- flavor = "cpu"
227
-
228
- print(f"System: {os_name} {arch}", flush=True)
229
- print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
230
- print(f"Selected flavor: {flavor}", flush=True)
231
-
232
- key = (os_name, arch, flavor)
233
- if key not in ELX_ASSET_TEMPLATE:
234
- raise NameError(f"No elastix asset configured for {key}")
235
- if flavor != "cpu" and key not in LIBTORCH_URL:
236
- raise NameError(f"No libtorch url configured for {key}")
237
-
238
- install_path = install_path.resolve()
239
- install_path.mkdir(parents=True, exist_ok=True)
240
-
241
- elx_asset = ELX_ASSET_TEMPLATE[key]
242
- elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
243
- elx_archive = install_path / elx_asset
244
- download_file(elx_url, elx_archive)
245
- extract_archive(elx_archive, install_path)
246
-
247
- # -------------------------------------------------------------------------
248
- # ZIP archives may drop executable permissions.
249
- # Ensure elastix and transformix are executable on Unix platforms.
250
- # -------------------------------------------------------------------------
251
- if os_name in ("Linux", "Darwin"):
252
- for exe in ("elastix", "transformix"):
253
- p = install_path / "bin" / exe
254
- if p.exists():
255
- p.chmod(p.stat().st_mode | stat.S_IEXEC)
256
-
257
- # ---------------------------------------------------------------------
258
- # - Download matching LibTorch 12.8 with or without CUDA 12.8
259
- # - Extract it
260
- # - Move runtime libraries to a location visible to the dynamic loader
261
- #
262
- # Linux / macOS : prefix/lib
263
- # Windows : prefix (next to elastix.exe)
264
- # ---------------------------------------------------------------------
265
- lt_url = LIBTORCH_URL[key]
266
- lt_archive = install_path / Path(lt_url).name
267
- download_file(lt_url, lt_archive)
268
- extract_archive(lt_archive, install_path)
269
- for p in (install_path / "libtorch" / "lib").iterdir():
270
- if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
271
- shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
272
-
273
- shutil.rmtree(install_path / "libtorch")
274
- if os_name == "Linux" and flavor == "cu128":
275
- shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
276
-
277
-
278
- def get_elastix_bin(install_path: Path) -> Path:
279
- return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
280
-
281
-
282
- def try_elastix(install_path: Path) -> None:
283
- try:
284
- subprocess.run(
285
- [str(get_elastix_bin(install_path)), "-h"],
286
- capture_output=True,
287
- text=True,
288
- check=True,
289
- ) # nosec B603
290
- except subprocess.CalledProcessError as e:
291
- msg = "Elastix execution failed.\n\n"
292
-
293
- msg += f"Command:\n{' '.join(e.cmd)}\n"
294
- msg += f"Return code: {e.returncode}\n\n"
295
-
296
- if e.stderr:
297
- msg += "Error output:\n"
298
- msg += e.stderr.strip()
299
- raise NameError(msg)
300
-
301
- except OSError as e:
302
- msg = (
303
- "Elastix could not be started.\n\n"
304
- "This is usually caused by missing shared libraries "
305
- "(e.g. LibTorch or CUDA runtime).\n\n"
306
- f"System error:\n{str(e)}"
307
- )
308
-
309
- raise NameError(msg)
310
-
311
-
312
- def main() -> None:
313
- ap = argparse.ArgumentParser()
314
- ap.add_argument(
315
- "--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
316
- )
317
- group = ap.add_mutually_exclusive_group()
318
- group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
319
- group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
320
- args = vars(ap.parse_args())
321
- install_elastix_impact(**args)
322
-
323
-
324
- if __name__ == "__main__":
325
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MR_CT_HeadNeck/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ impact_reg_konfai>=1.6.1
MR_CT_MRSeg/Model.py DELETED
@@ -1,301 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
18
-
19
- ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
20
- 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
21
- the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
22
- the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
23
- subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
24
-
25
- A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
26
- ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
27
-
28
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
29
- (``get_origin``); PEP 563 stringized annotations break arg resolution.
30
- """
31
-
32
- import json
33
- import os
34
- import re
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Annotated, Literal
38
-
39
- import torch
40
- from huggingface_hub import hf_hub_download
41
- from konfai.network import network
42
- from konfai.utils.config import Choices, Range
43
-
44
- # IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
45
- # A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
46
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
47
- # iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
48
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
49
-
50
- # ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
51
- # Static anyway; in Jacobian we clamp ``l`` to this plateau.
52
- _FOV_RAMP_MAX_LAYER = 6
53
-
54
-
55
- def registry_choices() -> list[str]:
56
- """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
57
- (offline-first). A user may still point ``ref`` at a local model."""
58
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
59
- return [f"{repo}:{key}" for key in load_models_registry()]
60
-
61
-
62
- def _num(x: object) -> str:
63
- """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
64
- return "%g" % float(x)
65
-
66
-
67
- @dataclass
68
- class ModelSpec:
69
- """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
70
- rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
71
- (``models.json``) keyed by ``ref`` — never tuned."""
72
-
73
- ref: Annotated[str, Choices(registry_choices)]
74
- voxel_size: list[float] = field(default_factory=list)
75
- layers_weight: list[float] = field(default_factory=lambda: [1.0])
76
- subset_features: Annotated[int, Range(0, 1000)] = 0
77
- pca: Annotated[int, Range(0, 100)] = 0
78
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
79
- layers_mask: str = ""
80
-
81
-
82
- @dataclass
83
- class ResolutionSpec:
84
- """One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
85
-
86
- max_iterations: Annotated[int, Range(1, 100000)]
87
- models: dict[str, ModelSpec]
88
-
89
-
90
- def _sorted_specs(mapping: dict) -> list:
91
- """dict keyed by string indices ('0','1',...) -> values in numeric order."""
92
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
93
-
94
-
95
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
96
- """Load models.json (the fixed params per model) from the model repo on Hugging Face.
97
-
98
- The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
99
- dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
100
- """
101
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
102
- if local:
103
- path = Path(local)
104
- elif ":" in ref:
105
- repo, filename = ref.split(":", 1)
106
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
107
- else:
108
- raise ValueError(
109
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
110
- f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
111
- )
112
- return json.loads(path.read_text(encoding="utf-8"))
113
-
114
-
115
- def _model_key(ref: str) -> str:
116
- """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
117
- return ref.split(":", 1)[1] if ":" in ref else ref
118
-
119
-
120
- def _deepest_active_layer(layers_mask: str) -> int:
121
- """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
122
-
123
- A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
124
- == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
125
- receptive field, so the FOV is governed by the rightmost ``'1'``.
126
- """
127
- mask = layers_mask.strip().strip('"')
128
- active = [i for i, char in enumerate(mask) if char == "1"]
129
- if not active:
130
- raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
131
- return max(active)
132
-
133
-
134
- def _fov_value(fov: dict, layers_mask: str) -> int:
135
- """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
136
-
137
- Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
138
- ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
139
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
140
- to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
141
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
142
- ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
143
- An explicit ``value`` in the spec is honoured as a precomputed shortcut.
144
- """
145
- formula = str(fov.get("formula", "")).strip()
146
- key = re.sub(r"\s+", "", formula).lower()
147
- if key.isdigit():
148
- return int(key)
149
- if key == "2*r*d+1":
150
- return 2 * int(fov["r"]) * int(fov["d"]) + 1
151
- if key == "2^l+3":
152
- return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
153
- if "global" in key:
154
- raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
155
- if fov.get("value") is not None:
156
- return int(fov["value"])
157
- raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
158
-
159
-
160
- def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
161
- """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
162
- image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
163
- e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
164
- dim = int(entry.get("dimension", 3))
165
- if mode.strip().strip('"').lower() != "jacobian":
166
- return " ".join(["0"] * dim)
167
- fov = _fov_value(entry.get("fov", {}), layers_mask)
168
- return " ".join([str(fov)] * dim)
169
-
170
-
171
- def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
172
- """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
173
-
174
- Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
175
- ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
176
- is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
177
- from the registry formula and the cell's ``layers_mask``.
178
- """
179
- res = _sorted_specs(resolutions)
180
- n = len(res)
181
- mode_clean = mode.strip().strip('"') or "Static"
182
-
183
- impact: list[str] = []
184
- for k, r in enumerate(res):
185
- models = _sorted_specs(r.models)
186
- entries = [registry[_model_key(m.ref)] for m in models]
187
-
188
- def row(stem: str, values: list[str]) -> None:
189
- impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
190
-
191
- # From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
192
- # model FOV); everything else is a per-model knob taken straight from the cell.
193
- row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
194
- row("Dimension", [e["dimension"] for e in entries])
195
- row("NumberOfChannels", [e["numberofchannels"] for e in entries])
196
- row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
197
- row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
198
- row("LayersMask", [f'"{m.layers_mask}"' for m in models])
199
- row("SubsetFeatures", [str(m.subset_features) for m in models])
200
- row("PCA", [str(m.pca) for m in models])
201
- row("Distance", [f'"{m.distance}"' for m in models])
202
- row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
203
- impact.append("") # blank line between resolutions, mirroring the reference maps
204
-
205
- # The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
206
- # (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
207
- lines = template_text.splitlines()
208
- indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
209
- block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
210
- block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
211
-
212
- out: list[str] = []
213
- for i, (m, line) in enumerate(indexed):
214
- key = m.group(1) if m else None
215
- if block_lo <= i <= block_hi:
216
- if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
217
- out.extend(impact[:-1])
218
- elif key == "MaximumNumberOfIterations":
219
- out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
220
- elif key == "NumberOfResolutions":
221
- out.append(f"(NumberOfResolutions {n})")
222
- elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
223
- out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
224
- elif key == "ImpactMode":
225
- out.append(f'(ImpactMode "{mode_clean}")')
226
- else:
227
- out.append(line)
228
- return "\n".join(out)
229
-
230
-
231
- class ChannelSelect(torch.nn.Module):
232
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
233
-
234
- def __init__(self, start: int, stop: int) -> None:
235
- super().__init__()
236
- self._start = start
237
- self._stop = stop
238
-
239
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
240
- return tensor[:, self._start : self._stop]
241
-
242
-
243
- class RegistrationNet(network.Network):
244
- """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
245
- moving mask = 3; masks restrict the metric, whole-image = no restriction).
246
-
247
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
248
- (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
249
- ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
250
- ``same_as_group: Volume_0:Fixed``.
251
- """
252
-
253
- def __init__(
254
- self,
255
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
256
- schedulers: dict[str, network.LRSchedulersLoader] = {
257
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
258
- },
259
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
260
- engine: str = "elastix",
261
- parameter_maps: list[str] = [],
262
- max_iterations: Annotated[int, Range(0, 100000)] = 0,
263
- final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
264
- subset_features: Annotated[int, Range(0, 1000)] = 0,
265
- spatial_samples: Annotated[int, Range(0, 100000)] = 0,
266
- parameter_overrides: list[str] = [],
267
- resolutions: dict[str, ResolutionSpec] = {},
268
- mode: Literal["Static", "Jacobian"] = "Static",
269
- ) -> None:
270
- # The registration is fully described by ``resolutions`` (config = source of truth): each resolution
271
- # lists its self-configured models; the download list is derived from the cells. Global knobs override
272
- # the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
273
- # NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
274
- # preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
275
- from elastix_engine import ElastixRegistration
276
-
277
- super().__init__(
278
- in_channels=1,
279
- optimizer=optimizer,
280
- schedulers=schedulers,
281
- outputs_criterions=outputs_criterions,
282
- dim=3,
283
- )
284
- self.add_module(
285
- "Registration",
286
- ElastixRegistration(
287
- engine,
288
- parameter_maps,
289
- max_iterations,
290
- final_grid_spacing,
291
- subset_features,
292
- spatial_samples,
293
- parameter_overrides,
294
- resolutions,
295
- mode,
296
- ),
297
- in_branch=[0, 1, 2, 3],
298
- out_branch=["registration"],
299
- )
300
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
301
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MR_CT_MRSeg/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.elastix:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
MR_CT_MRSeg/elastix_engine.py DELETED
@@ -1,386 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Elastix-IMPACT runtime for the registration bundle.
18
-
19
- ``ElastixEngine`` installs the elastix-IMPACT binary, downloads the TorchScript feature models, stages the
20
- parameter maps (generated from the model matrix or copied + overridden), runs the subprocess, and resamples.
21
- ``ElastixRegistration`` is the graph module ``RegistrationNet`` wires — it bridges KonfAI tensors <-> SITK
22
- images. The config -> parameter-map MAPPING lives in ``Model.py`` and is imported here.
23
- """
24
-
25
- import os
26
- import re
27
- import shutil
28
- import subprocess # nosec B404
29
- import tempfile
30
- from pathlib import Path
31
-
32
- import numpy as np
33
- import SimpleITK as sitk
34
- import torch
35
- import tqdm
36
- from huggingface_hub import hf_hub_download
37
- from install import get_elastix_bin, install_elastix_impact, try_elastix
38
- from konfai.utils.dataset import Attribute, data_to_image, image_to_data
39
-
40
- from Model import _sorted_specs, generate_impact_parameter_map, load_models_registry
41
-
42
- # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
43
- # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
44
- ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
45
-
46
-
47
- def _is_partial_mask(mask: "sitk.Image | None") -> bool:
48
- """True only for a mask that actually restricts the metric region — some voxels in, some out. An
49
- absent optional mask arrives as a whole-image (all-ones) default from KonfAI, and an all-zero mask
50
- is degenerate; both are treated as no mask, so elastix runs without ``-fMask`` / ``-mMask`` (i.e.
51
- the whole image) instead of paying for a mask that restricts nothing."""
52
- if mask is None:
53
- return False
54
- arr = sitk.GetArrayViewFromImage(mask)
55
- return bool((arr > 0).any()) and bool((arr == 0).any())
56
-
57
-
58
- class ElastixEngine:
59
- """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
60
-
61
- NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix does
62
- NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
63
- """
64
-
65
- def __init__(
66
- self,
67
- parameter_maps: list[str],
68
- max_iterations: int = 0,
69
- final_grid_spacing: float = 0.0,
70
- subset_features: int = 0,
71
- spatial_samples: int = 0,
72
- parameter_overrides: list[str] = [],
73
- resolutions: dict = {},
74
- mode: str = "Static",
75
- ) -> None:
76
- self._bundle_dir = Path(__file__).resolve().parent
77
- self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
78
- self._max_iterations = max_iterations
79
- self._final_grid_spacing = final_grid_spacing
80
- self._subset_features = subset_features
81
- self._spatial_samples = spatial_samples
82
- self._parameter_overrides = list(parameter_overrides)
83
- # ImpactMode: Static computes features once per level (PatchSize 0 0 0 = whole image); Jacobian
84
- # samples random FOV-sized patches each iteration. One mode per preset.
85
- self._mode = mode
86
- # Matrix mode: with ``resolutions`` the map is GENERATED from it. Empty ``resolutions`` = an
87
- # intensity preset (no IMPACT models): the fixed maps are staged with only the global overrides.
88
- self._resolutions = resolutions
89
- self._registry = load_models_registry() if resolutions else {}
90
- # Feature models are DERIVED — the unique refs across the matrix cells (no flat ``models`` param).
91
- models: list[str] = []
92
- for res in _sorted_specs(resolutions):
93
- for model in _sorted_specs(res.models):
94
- if model.ref not in models:
95
- models.append(model.ref)
96
- self._models = models
97
- # ``iterations`` (the progress-bar total) is DERIVED: the sum of per-resolution iteration budgets.
98
- self._iterations = self._total_iterations()
99
- self._elastix_bin = self._ensure_binary()
100
- self._local_models = self._download_models()
101
-
102
- def _total_iterations(self) -> int:
103
- """Total iterations across resolutions — the progress-bar budget, from the config (or the maps)."""
104
- if self._resolutions:
105
- return sum(int(res.max_iterations) for res in _sorted_specs(self._resolutions))
106
- total = 0
107
- for src in self._parameter_maps:
108
- match = re.search(r"\(MaximumNumberOfIterations\s+([^)]*)\)", src.read_text(encoding="utf-8"))
109
- if match:
110
- total += sum(int(token) for token in match.group(1).split())
111
- return total
112
-
113
- def _ensure_binary(self) -> Path:
114
- # Optional override: point at an existing elastix-IMPACT install (skips the download).
115
- override = os.environ.get("KONFAI_ELASTIX_DIR", "")
116
- if override:
117
- try_elastix(Path(override))
118
- return get_elastix_bin(Path(override)).resolve()
119
- ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
120
- try:
121
- try_elastix(ELASTIX_CACHE)
122
- except Exception:
123
- install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
124
- try_elastix(ELASTIX_CACHE)
125
- return get_elastix_bin(ELASTIX_CACHE).resolve()
126
-
127
- def _download_models(self) -> list[tuple[str, Path]]:
128
- """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
129
- models = []
130
- for ref in self._models:
131
- repo, filename = ref.split(":", 1)
132
- local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
133
- models.append((filename, local))
134
- return models
135
-
136
- def _parameter_map_overrides(self, global_only: bool = False) -> tuple[dict[str, str], list[tuple[str, str]]]:
137
- """The tuned knobs as parameter-map overrides: ``(per_token, exact)``.
138
-
139
- ``per_token`` maps an elastix key (or the ``ImpactSubsetFeatures`` prefix) to a value replacing
140
- **each** existing token, preserving per-resolution / per-model multiplicity. ``exact`` entries (from
141
- ``parameter_overrides``, ``Key=value text``) replace the whole value verbatim and win over the named
142
- knobs. Overrides only REPLACE keys already present — never inject. ``global_only`` (matrix mode) drops
143
- ``max_iterations`` / ``subset_features`` (the matrix already sets those per cell).
144
- """
145
- per_token: dict[str, str] = {}
146
- if not global_only and self._max_iterations > 0:
147
- per_token["MaximumNumberOfIterations"] = str(int(self._max_iterations))
148
- if self._final_grid_spacing > 0:
149
- per_token["FinalGridSpacingInPhysicalUnits"] = str(float(self._final_grid_spacing))
150
- if not global_only and self._subset_features > 0:
151
- per_token["ImpactSubsetFeatures"] = str(int(self._subset_features)) # prefix: indexed per metric
152
- if self._spatial_samples > 0:
153
- per_token["NumberOfSpatialSamples"] = str(int(self._spatial_samples))
154
- exact: list[tuple[str, str]] = []
155
- for entry in self._parameter_overrides:
156
- key, sep, value = entry.partition("=")
157
- if not sep or not key.strip():
158
- raise ValueError(f"Invalid parameter_overrides entry '{entry}': expected 'Key=value text'.")
159
- exact.append((key.strip(), value.strip()))
160
- return per_token, exact
161
-
162
- @staticmethod
163
- def _apply_map_overrides(
164
- text: str, per_token: dict[str, str], exact: list[tuple[str, str]], device_index: int
165
- ) -> str:
166
- """Patch a parameter map: set ImpactGPU to the device, apply exact key overrides, replace each token
167
- of a per-token knob (preserving multiplicity), and warn for a requested key absent from the map.
168
- """
169
- entry_pattern = re.compile(r"^(\s*)\((\S+)((?:\s+[^)]*)?)\)\s*$")
170
- requested = set(per_token) | {key for key, _ in exact}
171
- seen: set[str] = set()
172
- lines = []
173
- for line in text.splitlines():
174
- match = entry_pattern.match(line)
175
- if match:
176
- indent, key, values = match.group(1), match.group(2), match.group(3)
177
- if key == "ImpactGPU":
178
- line = f"{indent}(ImpactGPU {device_index})"
179
- else:
180
- exact_value = next((value for k, value in exact if k == key), None)
181
- if exact_value is not None:
182
- seen.add(key)
183
- line = f"{indent}({key} {exact_value})"
184
- else:
185
- token_key = "ImpactSubsetFeatures" if key.startswith("ImpactSubsetFeatures") else key
186
- if token_key in per_token:
187
- seen.add(token_key)
188
- replaced = " ".join(per_token[token_key] for _ in values.split())
189
- line = f"{indent}({key} {replaced})"
190
- lines.append(line)
191
- # Overrides never inject keys, so a knob set for a key absent from every map silently does nothing —
192
- # surface it (e.g. final_grid_spacing on a rigid-only preset).
193
- for key in sorted(requested - seen):
194
- print(f"[ImpactReg] note: override '{key}' matched no entry in the preset's parameter maps.")
195
- return "\n".join(lines)
196
-
197
- def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
198
- """Stage the parameter maps into ``work``.
199
-
200
- Matrix mode GENERATES each map from ``resolutions`` + the registry, then applies only the map-wide
201
- knobs (the matrix already sets iterations/features per cell). Legacy mode copies the preset's maps and
202
- applies every per-token / exact override. Both set the ImpactGPU device.
203
- """
204
- staged = []
205
- for src in self._parameter_maps:
206
- if self._resolutions:
207
- text = generate_impact_parameter_map(
208
- src.read_text(encoding="utf-8"), self._resolutions, self._registry, self._mode
209
- )
210
- per_token, exact = self._parameter_map_overrides(global_only=True)
211
- else:
212
- text = src.read_text(encoding="utf-8")
213
- per_token, exact = self._parameter_map_overrides()
214
- text = self._apply_map_overrides(text, per_token, exact, device_index)
215
- dst = work / src.name
216
- dst.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
217
- staged.append(dst)
218
- return staged
219
-
220
- def register(
221
- self,
222
- fixed: sitk.Image,
223
- moving: sitk.Image,
224
- device_index: int,
225
- fixed_mask: sitk.Image | None = None,
226
- moving_mask: sitk.Image | None = None,
227
- ) -> tuple[np.ndarray, np.ndarray]:
228
- """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
229
-
230
- Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region (elastix
231
- ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
232
- """
233
- work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
234
- try:
235
- fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
236
- sitk.WriteImage(fixed, str(fixed_path))
237
- sitk.WriteImage(moving, str(moving_path))
238
-
239
- # Stage the feature models at the relative path the maps reference (e.g. ImpactModelsPath0
240
- # "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
241
- for rel_name, model_path in self._local_models:
242
- dst = work / rel_name
243
- dst.parent.mkdir(parents=True, exist_ok=True)
244
- if not dst.exists():
245
- dst.symlink_to(model_path)
246
-
247
- args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
248
- for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
249
- if _is_partial_mask(mask):
250
- mask_path = work / name
251
- sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
252
- args += [flag, str(mask_path)]
253
- args += ["-out", str(work)]
254
- for pmap in self._stage_parameter_maps(work, device_index):
255
- args += ["-p", str(pmap)]
256
-
257
- # Make the elastix binary's bundled libs (libtorch under <install>/lib) and any extra
258
- # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
259
- env = os.environ.copy()
260
- extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
261
- env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
262
- proc = subprocess.Popen( # nosec B603
263
- args,
264
- cwd=str(work),
265
- stdout=subprocess.PIPE,
266
- stderr=subprocess.STDOUT,
267
- text=True,
268
- bufsize=1,
269
- env=env,
270
- )
271
- # Drive a tqdm bar over elastix's iteration lines so SlicerKonfAI (which parses the "N% done"
272
- # progress line) shows real progress. A tuned max_iterations makes the declared budget stale ->
273
- # open-ended bar. The description mirrors KonfAI's bars: resolution level + the metric value.
274
- captured: list[str] = []
275
- iteration_line = re.compile(r"^\d+\s")
276
- budget = None if self._max_iterations > 0 else (self._iterations or None)
277
- progress = tqdm.tqdm(total=budget, desc="Registration", ncols=0, leave=True)
278
- assert proc.stdout is not None
279
- resolution = 0
280
- for line in proc.stdout:
281
- captured.append(line)
282
- stripped = line.strip()
283
- if stripped.startswith("Resolution:"):
284
- try:
285
- resolution = int(stripped.split(":", 1)[1])
286
- except ValueError:
287
- pass
288
- elif iteration_line.match(line):
289
- progress.update(1)
290
- columns = line.split() # column 2 is the metric (header "1:ItNr 2:Metric ...")
291
- if len(columns) > 1:
292
- try:
293
- progress.set_description(
294
- f"Registration : res {resolution} | metric {float(columns[1]):.4f}"
295
- )
296
- except ValueError:
297
- pass
298
- progress.close()
299
- returncode = proc.wait()
300
- if returncode != 0:
301
- raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
302
-
303
- transforms = sorted(
304
- work.glob("TransformParameters.*-Composite.itk.txt"),
305
- key=lambda p: int(p.name.split(".")[1].split("-")[0]),
306
- )
307
- if not transforms:
308
- raise FileNotFoundError("elastix produced no composite transform file.")
309
- transform = sitk.ReadTransform(str(transforms[-1]))
310
-
311
- moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
312
- dvf = sitk.TransformToDisplacementField(
313
- transform,
314
- sitk.sitkVectorFloat64,
315
- fixed.GetSize(),
316
- fixed.GetOrigin(),
317
- fixed.GetSpacing(),
318
- fixed.GetDirection(),
319
- )
320
- moved_np, _ = image_to_data(moved)
321
- dvf_np, _ = image_to_data(dvf)
322
- return moved_np, dvf_np
323
- finally:
324
- shutil.rmtree(work, ignore_errors=True)
325
-
326
-
327
- class ElastixRegistration(torch.nn.Module):
328
- """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
329
-
330
- ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
331
- ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix needs
332
- the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
333
- """
334
-
335
- accepts_attributes = True
336
-
337
- def __init__(
338
- self,
339
- engine: str,
340
- parameter_maps: list[str],
341
- max_iterations: int = 0,
342
- final_grid_spacing: float = 0.0,
343
- subset_features: int = 0,
344
- spatial_samples: int = 0,
345
- parameter_overrides: list[str] = [],
346
- resolutions: dict = {},
347
- mode: str = "Static",
348
- ) -> None:
349
- super().__init__()
350
- if engine != "elastix":
351
- raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
352
- self._engine = ElastixEngine(
353
- parameter_maps,
354
- max_iterations,
355
- final_grid_spacing,
356
- subset_features,
357
- spatial_samples,
358
- parameter_overrides,
359
- resolutions,
360
- mode,
361
- )
362
-
363
- def forward(
364
- self,
365
- fixed: torch.Tensor,
366
- moving: torch.Tensor,
367
- fixed_mask: torch.Tensor,
368
- moving_mask: torch.Tensor,
369
- attributes: list[list[Attribute]],
370
- ) -> torch.Tensor:
371
- # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the
372
- # batch. Returns, per sample, the moved image (1 channel) stacked with the DVF (dim channels), both on
373
- # the fixed grid; downstream ChannelSelect splits them. A whole-image mask (the default) restricts nothing.
374
- fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
375
- device_index = fixed.device.index if fixed.device.type == "cuda" else -1
376
- combined = []
377
- for b in range(fixed.shape[0]):
378
- fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
379
- moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
380
- fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
381
- moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
382
- moved_np, dvf_np = self._engine.register(
383
- fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
384
- )
385
- combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
- return torch.stack(combined, dim=0).to(fixed.device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MR_CT_MRSeg/install.py DELETED
@@ -1,325 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- import argparse
18
- import platform
19
- import re
20
- import shutil
21
- import stat
22
- import subprocess # nosec B404
23
- import zipfile
24
- from pathlib import Path
25
-
26
- import requests
27
- from tqdm import tqdm
28
-
29
- # -----------------------------------------------------------------------------
30
- # Elastix + IMPACT binary assets hosted on GitHub Releases.
31
- #
32
- # Key format: (OS, ARCH, FLAVOR)
33
- # - OS : platform.system() -> "Linux", "Windows", "Darwin"
34
- # - ARCH : normalized architecture -> "x86_64"
35
- # - FLAVOR : "cpu" or "cu128"
36
- #
37
- # CPU assets are standalone (LibTorch CPU bundled).
38
- # CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
39
- # -----------------------------------------------------------------------------
40
- ELX_ASSET_TEMPLATE = {
41
- ("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
42
- ("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
43
- ("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
44
- ("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
45
- ("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
46
- }
47
-
48
- # -----------------------------------------------------------------------------
49
- # Official LibTorch downloads (PyTorch).
50
- #
51
- # IMPORTANT:
52
- # - Version MUST match the one used at build time (ABI compatibility).
53
- # - Using "shared-with-deps" ensures CUDA runtime libraries are included
54
- # (except the NVIDIA driver, which must be installed system-wide).
55
- # -----------------------------------------------------------------------------
56
-
57
- LIBTORCH_URL = {
58
- (
59
- "Linux",
60
- "x86_64",
61
- "cpu",
62
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
63
- (
64
- "Windows",
65
- "x86_64",
66
- "cpu",
67
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
68
- (
69
- "Darwin",
70
- "arm64",
71
- "cpu",
72
- ): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
73
- (
74
- "Linux",
75
- "x86_64",
76
- "cu128",
77
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
78
- (
79
- "Windows",
80
- "x86_64",
81
- "cu128",
82
- ): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
83
- }
84
-
85
- # -----------------------------------------------------------------------------
86
- # Minimum NVIDIA driver versions required for CUDA 12.8.
87
- #
88
- # The CUDA Toolkit itself is NOT required.
89
- # Only a sufficiently recent NVIDIA driver must be installed.
90
- # -----------------------------------------------------------------------------
91
- CUDA128_MIN_DRIVER_LINUX = (570, 26)
92
- CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
93
-
94
- GITHUB_OWNER = "vboussot"
95
- GITHUB_REPO = "ImpactElastix"
96
- GITHUB_TAG = "1.0.0"
97
-
98
-
99
- DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
100
-
101
-
102
- def run_cmd(cmd: list[str]) -> str:
103
- return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
104
-
105
-
106
- def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
107
- """
108
- Detect presence of an NVIDIA GPU and extract the driver version.
109
- Returns:
110
- (True, (major, minor)) if detected
111
- (False, None) if nvidia-smi is not available
112
- """
113
- try:
114
- nvidia_smi = shutil.which("nvidia-smi")
115
- if nvidia_smi is None:
116
- raise RuntimeError("nvidia-smi not found in PATH")
117
-
118
- out = (
119
- subprocess.check_output(
120
- [nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
121
- ) # nosec B603
122
- .decode("utf-8")
123
- .strip()
124
- )
125
- except Exception:
126
- return (False, None)
127
-
128
- # Exemple: "575.64"
129
- m = re.match(r"([0-9]+)\.([0-9]+)", out)
130
- if not m:
131
- return (True, None)
132
-
133
- return (True, (int(m.group(1)), int(m.group(2))))
134
-
135
-
136
- def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
137
- """
138
- Check whether the detected NVIDIA driver satisfies the minimum
139
- requirement for CUDA 12.8 on the given operating system.
140
- """
141
- if drv is None:
142
- return False
143
- if os_name == "Linux":
144
- return drv >= CUDA128_MIN_DRIVER_LINUX
145
- if os_name == "Windows":
146
- return drv >= CUDA128_MIN_DRIVER_WINDOWS
147
- return False
148
-
149
-
150
- def normalize_arch(machine: str) -> str:
151
- """
152
- Normalize platform.machine() output across operating systems.
153
-
154
- Examples:
155
- AMD64 -> x86_64
156
- aarch64 -> arm64
157
- """
158
- m = machine.lower()
159
- if m in ("x86_64", "amd64"):
160
- return "x86_64"
161
- if m in ("aarch64", "arm64"):
162
- return "arm64"
163
- return machine
164
-
165
-
166
- def download_file(url: str, dst: Path) -> None:
167
- """
168
- Download a file from the given URL with a progress indicator.
169
- """
170
- dst.parent.mkdir(parents=True, exist_ok=True)
171
- print(f"Downloading: {url}", flush=True)
172
- print(f" to: {dst}", flush=True)
173
-
174
- try:
175
- with requests.get(url, stream=True, timeout=60) as r:
176
- r.raise_for_status()
177
- total = int(r.headers.get("content-length", 0))
178
- with open(dst, "wb") as f:
179
- with tqdm(
180
- total=total,
181
- unit="B",
182
- unit_scale=True,
183
- desc=f"Downloading {dst.name}",
184
- ) as pbar:
185
- for chunk in r.iter_content(chunk_size=8192):
186
- f.write(chunk)
187
- pbar.update(len(chunk))
188
- except Exception as e:
189
- raise e
190
-
191
-
192
- def extract_archive(archive: Path, dst_dir: Path) -> None:
193
- """
194
- Extract a ZIP archive to the destination directory.
195
- """
196
- dst_dir.mkdir(parents=True, exist_ok=True)
197
- print(f"Extracting: {archive} -> {dst_dir}", flush=True)
198
- with zipfile.ZipFile(archive, "r") as z:
199
- z.extractall(dst_dir)
200
- archive.unlink()
201
-
202
-
203
- def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
204
- os_name = platform.system()
205
- arch = normalize_arch(platform.machine())
206
- has_nvidia, drv = detect_nvidia_driver()
207
-
208
- if os_name not in ("Linux", "Windows", "Darwin"):
209
- raise NameError(f"Unsupported OS: {os_name}")
210
-
211
- if arch not in ("x86_64", "arm64"):
212
- raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
213
-
214
- flavor = "cpu"
215
- if force_cuda:
216
- if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
217
- raise NameError(
218
- "CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
219
- )
220
-
221
- flavor = "cu128"
222
- elif not force_cpu:
223
- if has_nvidia and driver_ok_for_cuda(os_name, drv):
224
- flavor = "cu128"
225
- else:
226
- flavor = "cpu"
227
-
228
- print(f"System: {os_name} {arch}", flush=True)
229
- print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
230
- print(f"Selected flavor: {flavor}", flush=True)
231
-
232
- key = (os_name, arch, flavor)
233
- if key not in ELX_ASSET_TEMPLATE:
234
- raise NameError(f"No elastix asset configured for {key}")
235
- if flavor != "cpu" and key not in LIBTORCH_URL:
236
- raise NameError(f"No libtorch url configured for {key}")
237
-
238
- install_path = install_path.resolve()
239
- install_path.mkdir(parents=True, exist_ok=True)
240
-
241
- elx_asset = ELX_ASSET_TEMPLATE[key]
242
- elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
243
- elx_archive = install_path / elx_asset
244
- download_file(elx_url, elx_archive)
245
- extract_archive(elx_archive, install_path)
246
-
247
- # -------------------------------------------------------------------------
248
- # ZIP archives may drop executable permissions.
249
- # Ensure elastix and transformix are executable on Unix platforms.
250
- # -------------------------------------------------------------------------
251
- if os_name in ("Linux", "Darwin"):
252
- for exe in ("elastix", "transformix"):
253
- p = install_path / "bin" / exe
254
- if p.exists():
255
- p.chmod(p.stat().st_mode | stat.S_IEXEC)
256
-
257
- # ---------------------------------------------------------------------
258
- # - Download matching LibTorch 12.8 with or without CUDA 12.8
259
- # - Extract it
260
- # - Move runtime libraries to a location visible to the dynamic loader
261
- #
262
- # Linux / macOS : prefix/lib
263
- # Windows : prefix (next to elastix.exe)
264
- # ---------------------------------------------------------------------
265
- lt_url = LIBTORCH_URL[key]
266
- lt_archive = install_path / Path(lt_url).name
267
- download_file(lt_url, lt_archive)
268
- extract_archive(lt_archive, install_path)
269
- for p in (install_path / "libtorch" / "lib").iterdir():
270
- if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
271
- shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
272
-
273
- shutil.rmtree(install_path / "libtorch")
274
- if os_name == "Linux" and flavor == "cu128":
275
- shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
276
-
277
-
278
- def get_elastix_bin(install_path: Path) -> Path:
279
- return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
280
-
281
-
282
- def try_elastix(install_path: Path) -> None:
283
- try:
284
- subprocess.run(
285
- [str(get_elastix_bin(install_path)), "-h"],
286
- capture_output=True,
287
- text=True,
288
- check=True,
289
- ) # nosec B603
290
- except subprocess.CalledProcessError as e:
291
- msg = "Elastix execution failed.\n\n"
292
-
293
- msg += f"Command:\n{' '.join(e.cmd)}\n"
294
- msg += f"Return code: {e.returncode}\n\n"
295
-
296
- if e.stderr:
297
- msg += "Error output:\n"
298
- msg += e.stderr.strip()
299
- raise NameError(msg)
300
-
301
- except OSError as e:
302
- msg = (
303
- "Elastix could not be started.\n\n"
304
- "This is usually caused by missing shared libraries "
305
- "(e.g. LibTorch or CUDA runtime).\n\n"
306
- f"System error:\n{str(e)}"
307
- )
308
-
309
- raise NameError(msg)
310
-
311
-
312
- def main() -> None:
313
- ap = argparse.ArgumentParser()
314
- ap.add_argument(
315
- "--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
316
- )
317
- group = ap.add_mutually_exclusive_group()
318
- group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
319
- group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
320
- args = vars(ap.parse_args())
321
- install_elastix_impact(**args)
322
-
323
-
324
- if __name__ == "__main__":
325
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MR_CT_MRSeg/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ impact_reg_konfai>=1.6.1
MR_CT_TS/Model.py DELETED
@@ -1,301 +0,0 @@
1
- # Copyright (c) 2025 Valentin Boussot
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- #
15
- # SPDX-License-Identifier: Apache-2.0
16
-
17
- """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph.
18
-
19
- ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks =
20
- 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns
21
- the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and
22
- the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download,
23
- subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built.
24
-
25
- A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set),
26
- ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns).
27
-
28
- NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations
29
- (``get_origin``); PEP 563 stringized annotations break arg resolution.
30
- """
31
-
32
- import json
33
- import os
34
- import re
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Annotated, Literal
38
-
39
- import torch
40
- from huggingface_hub import hf_hub_download
41
- from konfai.network import network
42
- from konfai.utils.config import Choices, Range
43
-
44
- # IMPACT field docs: https://github.com/vboussot/ImpactLoss/tree/main/ParameterMaps
45
- # A model's FIXED props (dimension / channels / FOV formula) come from the registry (models.json on
46
- # VBoussot/impact-torchscript-models); the config carries the FREE knobs (models per resolution, voxel size,
47
- # iterations, per-model weights/mask/subset/pca/distance) and the global ``mode``.
48
- _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json"
49
-
50
- # ``2^l+3`` plateaus: segmenter layers 7-8 share layer 6's receptive field. Deeper configs should run
51
- # Static anyway; in Jacobian we clamp ``l`` to this plateau.
52
- _FOV_RAMP_MAX_LAYER = 6
53
-
54
-
55
- def registry_choices() -> list[str]:
56
- """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches
57
- (offline-first). A user may still point ``ref`` at a local model."""
58
- repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0]
59
- return [f"{repo}:{key}" for key in load_models_registry()]
60
-
61
-
62
- def _num(x: object) -> str:
63
- """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2')."""
64
- return "%g" % float(x)
65
-
66
-
67
- @dataclass
68
- class ModelSpec:
69
- """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the
70
- rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry
71
- (``models.json``) keyed by ``ref`` — never tuned."""
72
-
73
- ref: Annotated[str, Choices(registry_choices)]
74
- voxel_size: list[float] = field(default_factory=list)
75
- layers_weight: list[float] = field(default_factory=lambda: [1.0])
76
- subset_features: Annotated[int, Range(0, 1000)] = 0
77
- pca: Annotated[int, Range(0, 100)] = 0
78
- distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1"
79
- layers_mask: str = ""
80
-
81
-
82
- @dataclass
83
- class ResolutionSpec:
84
- """One elastix resolution level: its iteration budget and the (self-configured) models compared there."""
85
-
86
- max_iterations: Annotated[int, Range(1, 100000)]
87
- models: dict[str, ModelSpec]
88
-
89
-
90
- def _sorted_specs(mapping: dict) -> list:
91
- """dict keyed by string indices ('0','1',...) -> values in numeric order."""
92
- return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))]
93
-
94
-
95
- def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict:
96
- """Load models.json (the fixed params per model) from the model repo on Hugging Face.
97
-
98
- The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for
99
- dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference.
100
- """
101
- local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "")
102
- if local:
103
- path = Path(local)
104
- elif ":" in ref:
105
- repo, filename = ref.split(":", 1)
106
- path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
107
- else:
108
- raise ValueError(
109
- f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched "
110
- f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use."
111
- )
112
- return json.loads(path.read_text(encoding="utf-8"))
113
-
114
-
115
- def _model_key(ref: str) -> str:
116
- """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix)."""
117
- return ref.split(":", 1)[1] if ":" in ref else ref
118
-
119
-
120
- def _deepest_active_layer(layers_mask: str) -> int:
121
- """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index.
122
-
123
- A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i``
124
- == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's
125
- receptive field, so the FOV is governed by the rightmost ``'1'``.
126
- """
127
- mask = layers_mask.strip().strip('"')
128
- active = [i for i, char in enumerate(mask) if char == "1"]
129
- if not active:
130
- raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.")
131
- return max(active)
132
-
133
-
134
- def _fov_value(fov: dict, layers_mask: str) -> int:
135
- """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec.
136
-
137
- Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models):
138
- ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5);
139
- ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped
140
- to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6);
141
- a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14);
142
- ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error.
143
- An explicit ``value`` in the spec is honoured as a precomputed shortcut.
144
- """
145
- formula = str(fov.get("formula", "")).strip()
146
- key = re.sub(r"\s+", "", formula).lower()
147
- if key.isdigit():
148
- return int(key)
149
- if key == "2*r*d+1":
150
- return 2 * int(fov["r"]) * int(fov["d"]) + 1
151
- if key == "2^l+3":
152
- return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3
153
- if "global" in key:
154
- raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.")
155
- if fov.get("value") is not None:
156
- return int(fov["value"])
157
- raise ValueError(f"cannot evaluate model FOV formula '{formula}'.")
158
-
159
-
160
- def _patch_size(mode: str, entry: dict, layers_mask: str) -> str:
161
- """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole
162
- image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates,
163
- e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT."""
164
- dim = int(entry.get("dimension", 3))
165
- if mode.strip().strip('"').lower() != "jacobian":
166
- return " ".join(["0"] * dim)
167
- fov = _fov_value(entry.get("fov", {}), layers_mask)
168
- return " ".join([str(fov)] * dim)
169
-
170
-
171
- def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str:
172
- """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``.
173
-
174
- Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule,
175
- ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions)
176
- is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV
177
- from the registry formula and the cell's ``layers_mask``.
178
- """
179
- res = _sorted_specs(resolutions)
180
- n = len(res)
181
- mode_clean = mode.strip().strip('"') or "Static"
182
-
183
- impact: list[str] = []
184
- for k, r in enumerate(res):
185
- models = _sorted_specs(r.models)
186
- entries = [registry[_model_key(m.ref)] for m in models]
187
-
188
- def row(stem: str, values: list[str]) -> None:
189
- impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")")
190
-
191
- # From the registry ONLY the 3 truly model-fixed props (Dimension, NumberOfChannels, PatchSize = the
192
- # model FOV); everything else is a per-model knob taken straight from the cell.
193
- row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models])
194
- row("Dimension", [e["dimension"] for e in entries])
195
- row("NumberOfChannels", [e["numberofchannels"] for e in entries])
196
- row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)])
197
- row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models])
198
- row("LayersMask", [f'"{m.layers_mask}"' for m in models])
199
- row("SubsetFeatures", [str(m.subset_features) for m in models])
200
- row("PCA", [str(m.pca) for m in models])
201
- row("Distance", [f'"{m.distance}"' for m in models])
202
- row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models])
203
- impact.append("") # blank line between resolutions, mirroring the reference maps
204
-
205
- # The per-resolution block is the contiguous span from the first to the last ``Impact<name><k>`` line
206
- # (inner blanks fall inside it). Replace the whole span at its first line so reference blanks aren't kept.
207
- lines = template_text.splitlines()
208
- indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines]
209
- block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))]
210
- block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2)
211
-
212
- out: list[str] = []
213
- for i, (m, line) in enumerate(indexed):
214
- key = m.group(1) if m else None
215
- if block_lo <= i <= block_hi:
216
- if i == block_lo: # replace the whole span at its first line, drop the rest (incl. inner blanks)
217
- out.extend(impact[:-1])
218
- elif key == "MaximumNumberOfIterations":
219
- out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")")
220
- elif key == "NumberOfResolutions":
221
- out.append(f"(NumberOfResolutions {n})")
222
- elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"):
223
- out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")")
224
- elif key == "ImpactMode":
225
- out.append(f'(ImpactMode "{mode_clean}")')
226
- else:
227
- out.append(line)
228
- return "\n".join(out)
229
-
230
-
231
- class ChannelSelect(torch.nn.Module):
232
- """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
233
-
234
- def __init__(self, start: int, stop: int) -> None:
235
- super().__init__()
236
- self._start = start
237
- self._stop = stop
238
-
239
- def forward(self, tensor: torch.Tensor) -> torch.Tensor:
240
- return tensor[:, self._start : self._stop]
241
-
242
-
243
- class RegistrationNet(network.Network):
244
- """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2,
245
- moving mask = 3; masks restrict the metric, whole-image = no restriction).
246
-
247
- Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField``
248
- (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two
249
- ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via
250
- ``same_as_group: Volume_0:Fixed``.
251
- """
252
-
253
- def __init__(
254
- self,
255
- optimizer: network.OptimizerLoader = network.OptimizerLoader(),
256
- schedulers: dict[str, network.LRSchedulersLoader] = {
257
- "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
258
- },
259
- outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
260
- engine: str = "elastix",
261
- parameter_maps: list[str] = [],
262
- max_iterations: Annotated[int, Range(0, 100000)] = 0,
263
- final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0,
264
- subset_features: Annotated[int, Range(0, 1000)] = 0,
265
- spatial_samples: Annotated[int, Range(0, 100000)] = 0,
266
- parameter_overrides: list[str] = [],
267
- resolutions: dict[str, ResolutionSpec] = {},
268
- mode: Literal["Static", "Jacobian"] = "Static",
269
- ) -> None:
270
- # The registration is fully described by ``resolutions`` (config = source of truth): each resolution
271
- # lists its self-configured models; the download list is derived from the cells. Global knobs override
272
- # the generated map (final_grid_spacing -> FinalGridSpacingInPhysicalUnits mm, spatial_samples ->
273
- # NumberOfSpatialSamples, parameter_overrides 'Key=value'). Empty ``resolutions`` = an intensity-only
274
- # preset (fixed maps + overrides). The elastix runtime is imported here (heavy: torch/sitk/subprocess).
275
- from elastix_engine import ElastixRegistration
276
-
277
- super().__init__(
278
- in_channels=1,
279
- optimizer=optimizer,
280
- schedulers=schedulers,
281
- outputs_criterions=outputs_criterions,
282
- dim=3,
283
- )
284
- self.add_module(
285
- "Registration",
286
- ElastixRegistration(
287
- engine,
288
- parameter_maps,
289
- max_iterations,
290
- final_grid_spacing,
291
- subset_features,
292
- spatial_samples,
293
- parameter_overrides,
294
- resolutions,
295
- mode,
296
- ),
297
- in_branch=[0, 1, 2, 3],
298
- out_branch=["registration"],
299
- )
300
- self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
301
- self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MR_CT_TS/Prediction.yml CHANGED
@@ -1,6 +1,6 @@
1
  Predictor:
2
  Model:
3
- classpath: Model:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps:
 
1
  Predictor:
2
  Model:
3
+ classpath: impact_reg_konfai.models.elastix:RegistrationNet
4
  RegistrationNet:
5
  engine: elastix
6
  parameter_maps: