VBoussot commited on
Commit
c8cdf2d
·
1 Parent(s): 8e29033

Restructure IMPACT-Reg into per-preset KonfAI apps

Browse files

Each preset is now a self-contained app (app.json + Prediction.yml + Model.py +
install.py + model.pt + parameter maps + evaluation configs). The registration
model takes fixed/moving plus optional fixed/moving masks (whole-image mask when
omitted); evaluation is declared per modality (image / segmentation / landmarks).
Removes the legacy monolithic app and the flat parameter-map layout.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. {ImpactReg → CBCT_CT_HeadNeck}/Evaluation_with_fid.yml +0 -0
  2. {ImpactReg → CBCT_CT_HeadNeck}/Evaluation_with_images.yml +0 -0
  3. {ImpactReg → CBCT_CT_HeadNeck}/Evaluation_with_seg.yml +0 -0
  4. CBCT_CT_HeadNeck/Model.py +291 -0
  5. ParameterMap_CBCT_HN.txt → CBCT_CT_HeadNeck/ParameterMap_CBCT_HN.txt +0 -0
  6. CBCT_CT_HeadNeck/Prediction.yml +108 -0
  7. CBCT_CT_HeadNeck/app.json +94 -0
  8. CBCT_CT_HeadNeck/install.py +325 -0
  9. CBCT_CT_HeadNeck/model.pt +0 -0
  10. ImpactReg/Uncertainty.yml → CBCT_CT_MRSeg/Evaluation_with_fid.yml +9 -11
  11. CBCT_CT_MRSeg/Evaluation_with_images.yml +35 -0
  12. CBCT_CT_MRSeg/Evaluation_with_seg.yml +29 -0
  13. CBCT_CT_MRSeg/Model.py +291 -0
  14. ParameterMap_CBCT_generic_MRSeg.txt → CBCT_CT_MRSeg/ParameterMap_CBCT_generic_MRSeg.txt +0 -0
  15. CBCT_CT_MRSeg/Prediction.yml +107 -0
  16. CBCT_CT_MRSeg/app.json +94 -0
  17. CBCT_CT_MRSeg/install.py +325 -0
  18. CBCT_CT_MRSeg/model.pt +0 -0
  19. CBCT_CT_TS/Evaluation_with_fid.yml +22 -0
  20. CBCT_CT_TS/Evaluation_with_images.yml +35 -0
  21. CBCT_CT_TS/Evaluation_with_seg.yml +29 -0
  22. CBCT_CT_TS/Model.py +291 -0
  23. ParameterMap_CBCT_generic_TS.txt → CBCT_CT_TS/ParameterMap_CBCT_generic_TS.txt +0 -0
  24. CBCT_CT_TS/Prediction.yml +107 -0
  25. CBCT_CT_TS/app.json +94 -0
  26. CBCT_CT_TS/install.py +325 -0
  27. CBCT_CT_TS/model.pt +0 -0
  28. Generic_Rigid/Evaluation_with_fid.yml +22 -0
  29. Generic_Rigid/Evaluation_with_images.yml +35 -0
  30. Generic_Rigid/Evaluation_with_seg.yml +29 -0
  31. Generic_Rigid/Model.py +291 -0
  32. Parameters_Rigid.txt → Generic_Rigid/Parameters_Rigid.txt +0 -0
  33. Generic_Rigid/Prediction.yml +105 -0
  34. Generic_Rigid/app.json +94 -0
  35. Generic_Rigid/install.py +325 -0
  36. Generic_Rigid/model.pt +0 -0
  37. Generic_Rigid_BSpline/Evaluation_with_fid.yml +22 -0
  38. Generic_Rigid_BSpline/Evaluation_with_images.yml +35 -0
  39. Generic_Rigid_BSpline/Evaluation_with_seg.yml +29 -0
  40. Generic_Rigid_BSpline/Model.py +291 -0
  41. Parameters_BSpline.txt → Generic_Rigid_BSpline/Parameters_BSpline.txt +0 -0
  42. Generic_Rigid_BSpline/Parameters_Rigid.txt +147 -0
  43. Generic_Rigid_BSpline/Prediction.yml +106 -0
  44. Generic_Rigid_BSpline/app.json +94 -0
  45. Generic_Rigid_BSpline/install.py +325 -0
  46. Generic_Rigid_BSpline/model.pt +0 -0
  47. ImpactReg/UncertaintyDVF.py +0 -15
  48. ImpactReg/app.json +0 -7
  49. MR_CT_HeadNeck/Evaluation_with_fid.yml +22 -0
  50. MR_CT_HeadNeck/Evaluation_with_images.yml +35 -0
{ImpactReg → CBCT_CT_HeadNeck}/Evaluation_with_fid.yml RENAMED
File without changes
{ImpactReg → CBCT_CT_HeadNeck}/Evaluation_with_images.yml RENAMED
File without changes
{ImpactReg → CBCT_CT_HeadNeck}/Evaluation_with_seg.yml RENAMED
File without changes
CBCT_CT_HeadNeck/Model.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, built the idiomatic way (an ``add_module`` graph).
18
+
19
+ ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
+ (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
+ onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
+ (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
+ ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
+ needs to register in physical space.
25
+
26
+ Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
27
+
28
+ Patch.patch_size: None # one patch per case (registration is global)
29
+ batch_size: 1 # one fixed/moving pair at a time
30
+
31
+ NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
32
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
+ """
34
+
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess # nosec B404
39
+ import tempfile
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+ import SimpleITK as sitk
44
+ import torch
45
+ import tqdm
46
+ from huggingface_hub import hf_hub_download
47
+ from install import get_elastix_bin, install_elastix_impact, try_elastix
48
+ from konfai.network import network
49
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
50
+
51
+ # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
52
+ # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
+ ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
+
55
+
56
+ class ElastixEngine:
57
+ """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
58
+
59
+ NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
60
+ does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
+ """
62
+
63
+ def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
64
+ self._bundle_dir = Path(__file__).resolve().parent
65
+ self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
66
+ self._models = models
67
+ self._iterations = iterations
68
+ self._elastix_bin = self._ensure_binary()
69
+ self._local_models = self._download_models()
70
+
71
+ def _ensure_binary(self) -> Path:
72
+ # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
+ override = os.environ.get("KONFAI_ELASTIX_DIR", "")
74
+ if override:
75
+ try_elastix(Path(override))
76
+ return get_elastix_bin(Path(override)).resolve()
77
+ ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
78
+ try:
79
+ try_elastix(ELASTIX_CACHE)
80
+ except Exception:
81
+ install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
82
+ try_elastix(ELASTIX_CACHE)
83
+ return get_elastix_bin(ELASTIX_CACHE).resolve()
84
+
85
+ def _download_models(self) -> list[tuple[str, Path]]:
86
+ """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
87
+ models = []
88
+ for ref in self._models:
89
+ repo, filename = ref.split(":", 1)
90
+ local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
91
+ models.append((filename, local))
92
+ return models
93
+
94
+ def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
+ """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
96
+ staged = []
97
+ for src in self._parameter_maps:
98
+ dst = work / src.name
99
+ lines = []
100
+ for line in src.read_text(encoding="utf-8").splitlines():
101
+ if line.strip().startswith("(ImpactGPU"):
102
+ line = f"(ImpactGPU {device_index})"
103
+ lines.append(line)
104
+ dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
+ staged.append(dst)
106
+ return staged
107
+
108
+ def register(
109
+ self,
110
+ fixed: sitk.Image,
111
+ moving: sitk.Image,
112
+ device_index: int,
113
+ fixed_mask: sitk.Image | None = None,
114
+ moving_mask: sitk.Image | None = None,
115
+ ) -> tuple[np.ndarray, np.ndarray]:
116
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
117
+
118
+ Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
119
+ (elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
120
+ """
121
+ work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
122
+ try:
123
+ fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
124
+ sitk.WriteImage(fixed, str(fixed_path))
125
+ sitk.WriteImage(moving, str(moving_path))
126
+
127
+ # Stage the feature models at the relative path the parameter maps reference
128
+ # (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
129
+ for rel_name, model_path in self._local_models:
130
+ dst = work / rel_name
131
+ dst.parent.mkdir(parents=True, exist_ok=True)
132
+ if not dst.exists():
133
+ dst.symlink_to(model_path)
134
+
135
+ args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
136
+ for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
137
+ if mask is not None:
138
+ mask_path = work / name
139
+ sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
140
+ args += [flag, str(mask_path)]
141
+ args += ["-out", str(work)]
142
+ for pmap in self._stage_parameter_maps(work, device_index):
143
+ args += ["-p", str(pmap)]
144
+
145
+ # Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
146
+ # the "N% done/total" progress line) shows real progress during the long registration.
147
+ # Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
148
+ # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
149
+ env = os.environ.copy()
150
+ extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
151
+ env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
152
+ proc = subprocess.Popen( # nosec B603
153
+ args,
154
+ cwd=str(work),
155
+ stdout=subprocess.PIPE,
156
+ stderr=subprocess.STDOUT,
157
+ text=True,
158
+ bufsize=1,
159
+ env=env,
160
+ )
161
+ captured: list[str] = []
162
+ iteration_line = re.compile(r"^\d+\s")
163
+ # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
+ # chained parameter maps), so the bar spans the whole chain of registration stages.
165
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
166
+ assert proc.stdout is not None
167
+ for line in proc.stdout:
168
+ captured.append(line)
169
+ if iteration_line.match(line):
170
+ progress.update(1)
171
+ progress.close()
172
+ returncode = proc.wait()
173
+ if returncode != 0:
174
+ raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
175
+
176
+ transforms = sorted(
177
+ work.glob("TransformParameters.*-Composite.itk.txt"),
178
+ key=lambda p: int(p.name.split(".")[1].split("-")[0]),
179
+ )
180
+ if not transforms:
181
+ raise FileNotFoundError("elastix produced no composite transform file.")
182
+ transform = sitk.ReadTransform(str(transforms[-1]))
183
+
184
+ moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
185
+ dvf = sitk.TransformToDisplacementField(
186
+ transform,
187
+ sitk.sitkVectorFloat64,
188
+ fixed.GetSize(),
189
+ fixed.GetOrigin(),
190
+ fixed.GetSpacing(),
191
+ fixed.GetDirection(),
192
+ )
193
+ moved_np, _ = image_to_data(moved)
194
+ dvf_np, _ = image_to_data(dvf)
195
+ return moved_np, dvf_np
196
+ finally:
197
+ shutil.rmtree(work, ignore_errors=True)
198
+
199
+
200
+ class ElastixRegistration(torch.nn.Module):
201
+ """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
202
+
203
+ ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
204
+ ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
205
+ needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
206
+ """
207
+
208
+ accepts_attributes = True
209
+
210
+ def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
211
+ super().__init__()
212
+ if engine != "elastix":
213
+ raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
214
+ self._engine = ElastixEngine(parameter_maps, models, iterations)
215
+
216
+ def forward(
217
+ self,
218
+ fixed: torch.Tensor,
219
+ moving: torch.Tensor,
220
+ fixed_mask: torch.Tensor,
221
+ moving_mask: torch.Tensor,
222
+ attributes: list[list[Attribute]],
223
+ ) -> torch.Tensor:
224
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
225
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
226
+ # (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
227
+ # the whole image (the auto-filled default when the user supplies none) restricts nothing.
228
+ fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
229
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
230
+ combined = []
231
+ for b in range(fixed.shape[0]):
232
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
233
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
234
+ fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
235
+ moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
236
+ moved_np, dvf_np = self._engine.register(
237
+ fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
238
+ )
239
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
240
+ return torch.stack(combined, dim=0).to(fixed.device)
241
+
242
+
243
+ class ChannelSelect(torch.nn.Module):
244
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
245
+
246
+ def __init__(self, start: int, stop: int) -> None:
247
+ super().__init__()
248
+ self._start = start
249
+ self._stop = stop
250
+
251
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
252
+ return tensor[:, self._start : self._stop]
253
+
254
+
255
+ class RegistrationNet(network.Network):
256
+ """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
257
+ fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
258
+
259
+ Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
260
+ ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
261
+ both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
262
+ ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
268
+ schedulers: dict[str, network.LRSchedulersLoader] = {
269
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
270
+ },
271
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
272
+ engine: str = "elastix",
273
+ parameter_maps: list[str] = [],
274
+ models: list[str] = [],
275
+ iterations: int = 1000,
276
+ ) -> None:
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(engine, parameter_maps, models, iterations),
287
+ in_branch=[0, 1, 2, 3],
288
+ out_branch=["registration"],
289
+ )
290
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
291
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
ParameterMap_CBCT_HN.txt → CBCT_CT_HeadNeck/ParameterMap_CBCT_HN.txt RENAMED
File without changes
CBCT_CT_HeadNeck/Prediction.yml ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ engine: elastix
6
+ parameter_maps:
7
+ - ParameterMap_CBCT_HN.txt
8
+ models:
9
+ - VBoussot/impact-torchscript-models:TS/M732.pt
10
+ - VBoussot/impact-torchscript-models:TS/M731.pt
11
+ - VBoussot/impact-torchscript-models:TS/M730.pt
12
+ iterations: 1150
13
+ outputs_criterions: None
14
+ Dataset:
15
+ groups_src:
16
+ Volume_0:
17
+ groups_dest:
18
+ Fixed:
19
+ transforms:
20
+ TensorCast:
21
+ dtype: float32
22
+ inverse: false
23
+ patch_transforms: None
24
+ is_input: true
25
+ Volume_1:
26
+ groups_dest:
27
+ Moving:
28
+ transforms:
29
+ TensorCast:
30
+ dtype: float32
31
+ inverse: false
32
+ patch_transforms: None
33
+ is_input: true
34
+ Volume_2:
35
+ groups_dest:
36
+ FixedMask:
37
+ transforms:
38
+ TensorCast:
39
+ dtype: float32
40
+ inverse: false
41
+ patch_transforms: None
42
+ is_input: true
43
+ Volume_3:
44
+ groups_dest:
45
+ MovingMask:
46
+ transforms:
47
+ TensorCast:
48
+ dtype: float32
49
+ inverse: false
50
+ patch_transforms: None
51
+ is_input: true
52
+ augmentations: None
53
+ Patch:
54
+ patch_size: None
55
+ overlap: None
56
+ mask: None
57
+ pad_value: None
58
+ extend_slice: 0
59
+ subset: None
60
+ filter: None
61
+ dataset_filenames:
62
+ - ./Dataset/:mha
63
+ use_cache: false
64
+ batch_size: 1
65
+ num_workers: None
66
+ pin_memory: false
67
+ prefetch_factor: None
68
+ persistent_workers: None
69
+ outputs_dataset:
70
+ MovedImage:
71
+ OutputDataset:
72
+ name_class: OutSameAsGroupDataset
73
+ before_reduction_transforms: None
74
+ after_reduction_transforms: None
75
+ final_transforms:
76
+ TensorCast:
77
+ dtype: float32
78
+ inverse: false
79
+ dataset_filename: Moved:mha
80
+ group: Moved
81
+ same_as_group: Volume_0:Fixed
82
+ patch_combine: None
83
+ inverse_transform: false
84
+ reduction: Mean
85
+ Mean: {}
86
+ DisplacementField:
87
+ OutputDataset:
88
+ name_class: OutSameAsGroupDataset
89
+ before_reduction_transforms: None
90
+ after_reduction_transforms: None
91
+ final_transforms:
92
+ TensorCast:
93
+ dtype: float32
94
+ inverse: false
95
+ dataset_filename: DVF:mha
96
+ group: DVF
97
+ same_as_group: Volume_0:Fixed
98
+ patch_combine: None
99
+ inverse_transform: false
100
+ reduction: Mean
101
+ Mean: {}
102
+ train_name: ImpactReg-CBCT-CT-HeadNeck
103
+ manual_seed: 42
104
+ gpu_checkpoints: None
105
+ images_log: None
106
+ combine: Mean
107
+ autocast: false
108
+ data_log: None
CBCT_CT_HeadNeck/app.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "CBCT/CT Head&Neck",
3
+ "short_description": "Optimized preset for CBCT/CT registration on head & neck",
4
+ "description": "A five-level recursive B-spline deformable registration optimized for CBCT/CT head-and-neck alignment, driven by the IMPACT metric using semantic features extracted from pretrained TotalSegmentator TorchScript models. The optimization follows a multi-resolution ASGD scheme with up to 300, 300, 200, 200, and 150 iterations and 2000 stochastic spatial samples per level. Features are extracted at progressively finer voxel scales (6 mm, 3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm) using L1 distances on selected internal layers of the network. A composite objective (IMPACT + mutual information + bending energy penalty, with increased MI weight) ensures robust cross-modality alignment in complex head-and-neck anatomy while enforcing smooth, physically plausible deformations.",
5
+ "task": "registration",
6
+ "tta": 0,
7
+ "mc_dropout": 0,
8
+ "models": [
9
+ "model.pt"
10
+ ],
11
+ "inputs": {
12
+ "Fixed": {
13
+ "display_name": "Fixed image",
14
+ "volume_type": "VOLUME",
15
+ "required": true
16
+ },
17
+ "Moving": {
18
+ "display_name": "Moving image",
19
+ "volume_type": "VOLUME",
20
+ "required": true
21
+ },
22
+ "FixedMask": {
23
+ "display_name": "Fixed mask (optional)",
24
+ "volume_type": "SEGMENTATION",
25
+ "required": false
26
+ },
27
+ "MovingMask": {
28
+ "display_name": "Moving mask (optional)",
29
+ "volume_type": "SEGMENTATION",
30
+ "required": false
31
+ }
32
+ },
33
+ "outputs": {
34
+ "MovedImage": {
35
+ "display_name": "Moved image",
36
+ "volume_type": "VOLUME",
37
+ "required": true
38
+ },
39
+ "DisplacementField": {
40
+ "display_name": "Displacement field",
41
+ "volume_type": "VOLUME",
42
+ "required": false
43
+ }
44
+ },
45
+ "inputs_evaluations": {
46
+ "Image": {
47
+ "Evaluation_with_images.yml": {
48
+ "FixedImage": {
49
+ "display_name": "Fixed image",
50
+ "volume_type": "VOLUME",
51
+ "required": true
52
+ },
53
+ "MovingImage": {
54
+ "display_name": "Moving image",
55
+ "volume_type": "VOLUME",
56
+ "required": true
57
+ },
58
+ "Mask": {
59
+ "display_name": "Evaluation mask",
60
+ "volume_type": "SEGMENTATION",
61
+ "required": false
62
+ }
63
+ }
64
+ },
65
+ "Segmentation": {
66
+ "Evaluation_with_seg.yml": {
67
+ "FixedSeg": {
68
+ "display_name": "Fixed segmentation",
69
+ "volume_type": "SEGMENTATION",
70
+ "required": true
71
+ },
72
+ "MovingSeg": {
73
+ "display_name": "Moving segmentation",
74
+ "volume_type": "SEGMENTATION",
75
+ "required": true
76
+ }
77
+ }
78
+ },
79
+ "Landmarks": {
80
+ "Evaluation_with_fid.yml": {
81
+ "FixedFid": {
82
+ "display_name": "Fixed landmarks",
83
+ "volume_type": "FIDUCIALS",
84
+ "required": true
85
+ },
86
+ "MovingFid": {
87
+ "display_name": "Moving landmarks",
88
+ "volume_type": "FIDUCIALS",
89
+ "required": true
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
CBCT_CT_HeadNeck/install.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/model.pt ADDED
Binary file (1.25 kB). View file
 
ImpactReg/Uncertainty.yml → CBCT_CT_MRSeg/Evaluation_with_fid.yml RENAMED
@@ -1,22 +1,20 @@
1
  Evaluator:
2
  metrics:
3
- Uncertainty:
4
  targets_criterions:
5
- None:
6
  criterions_loader:
7
- Mean:
8
- name: Uncertainty
9
  Dataset:
10
  groups_src:
11
  Volume_0:
12
  groups_dest:
13
- Uncertainty:
14
- transforms:
15
- UncertaintyDVF:DVFToNorm: {}
16
- Variance: {}
17
- Save:
18
- dataset: ./Uncertainties/ImpactReg/Output:mha
19
- group: None
20
  subset: None
21
  dataset_filenames:
22
  - ./Dataset:mha
 
1
  Evaluator:
2
  metrics:
3
+ FixedFid:
4
  targets_criterions:
5
+ MovingFid:
6
  criterions_loader:
7
+ TRE: {}
 
8
  Dataset:
9
  groups_src:
10
  Volume_0:
11
  groups_dest:
12
+ FixedFid:
13
+ transforms: None
14
+ Reference_0:
15
+ groups_dest:
16
+ MovingFid:
17
+ transforms: None
 
18
  subset: None
19
  dataset_filenames:
20
  - ./Dataset:mha
CBCT_CT_MRSeg/Evaluation_with_images.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedImage:
4
+ targets_criterions:
5
+ MovingImage;Mask:
6
+ criterions_loader:
7
+ MAESaveMap:
8
+ reduction: mean
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedImage:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: float32
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingImage:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: float32
25
+ Mask_0:
26
+ groups_dest:
27
+ Mask:
28
+ transforms:
29
+ TensorCast:
30
+ dtype: uint8
31
+ subset: None
32
+ dataset_filenames:
33
+ - ./Dataset:mha
34
+ validation: None
35
+ train_name: ImpactReg
CBCT_CT_MRSeg/Evaluation_with_seg.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedSeg:
4
+ targets_criterions:
5
+ MovingSeg:
6
+ criterions_loader:
7
+ DiceSaveMap:
8
+ labels: None
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: Seg_MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedSeg:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: uint8
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingSeg:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: uint8
25
+ subset: None
26
+ dataset_filenames:
27
+ - ./Dataset:mha
28
+ validation: None
29
+ train_name: ImpactReg
CBCT_CT_MRSeg/Model.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, built the idiomatic way (an ``add_module`` graph).
18
+
19
+ ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
+ (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
+ onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
+ (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
+ ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
+ needs to register in physical space.
25
+
26
+ Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
27
+
28
+ Patch.patch_size: None # one patch per case (registration is global)
29
+ batch_size: 1 # one fixed/moving pair at a time
30
+
31
+ NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
32
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
+ """
34
+
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess # nosec B404
39
+ import tempfile
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+ import SimpleITK as sitk
44
+ import torch
45
+ import tqdm
46
+ from huggingface_hub import hf_hub_download
47
+ from install import get_elastix_bin, install_elastix_impact, try_elastix
48
+ from konfai.network import network
49
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
50
+
51
+ # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
52
+ # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
+ ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
+
55
+
56
+ class ElastixEngine:
57
+ """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
58
+
59
+ NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
60
+ does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
+ """
62
+
63
+ def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
64
+ self._bundle_dir = Path(__file__).resolve().parent
65
+ self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
66
+ self._models = models
67
+ self._iterations = iterations
68
+ self._elastix_bin = self._ensure_binary()
69
+ self._local_models = self._download_models()
70
+
71
+ def _ensure_binary(self) -> Path:
72
+ # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
+ override = os.environ.get("KONFAI_ELASTIX_DIR", "")
74
+ if override:
75
+ try_elastix(Path(override))
76
+ return get_elastix_bin(Path(override)).resolve()
77
+ ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
78
+ try:
79
+ try_elastix(ELASTIX_CACHE)
80
+ except Exception:
81
+ install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
82
+ try_elastix(ELASTIX_CACHE)
83
+ return get_elastix_bin(ELASTIX_CACHE).resolve()
84
+
85
+ def _download_models(self) -> list[tuple[str, Path]]:
86
+ """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
87
+ models = []
88
+ for ref in self._models:
89
+ repo, filename = ref.split(":", 1)
90
+ local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
91
+ models.append((filename, local))
92
+ return models
93
+
94
+ def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
+ """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
96
+ staged = []
97
+ for src in self._parameter_maps:
98
+ dst = work / src.name
99
+ lines = []
100
+ for line in src.read_text(encoding="utf-8").splitlines():
101
+ if line.strip().startswith("(ImpactGPU"):
102
+ line = f"(ImpactGPU {device_index})"
103
+ lines.append(line)
104
+ dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
+ staged.append(dst)
106
+ return staged
107
+
108
+ def register(
109
+ self,
110
+ fixed: sitk.Image,
111
+ moving: sitk.Image,
112
+ device_index: int,
113
+ fixed_mask: sitk.Image | None = None,
114
+ moving_mask: sitk.Image | None = None,
115
+ ) -> tuple[np.ndarray, np.ndarray]:
116
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
117
+
118
+ Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
119
+ (elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
120
+ """
121
+ work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
122
+ try:
123
+ fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
124
+ sitk.WriteImage(fixed, str(fixed_path))
125
+ sitk.WriteImage(moving, str(moving_path))
126
+
127
+ # Stage the feature models at the relative path the parameter maps reference
128
+ # (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
129
+ for rel_name, model_path in self._local_models:
130
+ dst = work / rel_name
131
+ dst.parent.mkdir(parents=True, exist_ok=True)
132
+ if not dst.exists():
133
+ dst.symlink_to(model_path)
134
+
135
+ args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
136
+ for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
137
+ if mask is not None:
138
+ mask_path = work / name
139
+ sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
140
+ args += [flag, str(mask_path)]
141
+ args += ["-out", str(work)]
142
+ for pmap in self._stage_parameter_maps(work, device_index):
143
+ args += ["-p", str(pmap)]
144
+
145
+ # Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
146
+ # the "N% done/total" progress line) shows real progress during the long registration.
147
+ # Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
148
+ # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
149
+ env = os.environ.copy()
150
+ extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
151
+ env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
152
+ proc = subprocess.Popen( # nosec B603
153
+ args,
154
+ cwd=str(work),
155
+ stdout=subprocess.PIPE,
156
+ stderr=subprocess.STDOUT,
157
+ text=True,
158
+ bufsize=1,
159
+ env=env,
160
+ )
161
+ captured: list[str] = []
162
+ iteration_line = re.compile(r"^\d+\s")
163
+ # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
+ # chained parameter maps), so the bar spans the whole chain of registration stages.
165
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
166
+ assert proc.stdout is not None
167
+ for line in proc.stdout:
168
+ captured.append(line)
169
+ if iteration_line.match(line):
170
+ progress.update(1)
171
+ progress.close()
172
+ returncode = proc.wait()
173
+ if returncode != 0:
174
+ raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
175
+
176
+ transforms = sorted(
177
+ work.glob("TransformParameters.*-Composite.itk.txt"),
178
+ key=lambda p: int(p.name.split(".")[1].split("-")[0]),
179
+ )
180
+ if not transforms:
181
+ raise FileNotFoundError("elastix produced no composite transform file.")
182
+ transform = sitk.ReadTransform(str(transforms[-1]))
183
+
184
+ moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
185
+ dvf = sitk.TransformToDisplacementField(
186
+ transform,
187
+ sitk.sitkVectorFloat64,
188
+ fixed.GetSize(),
189
+ fixed.GetOrigin(),
190
+ fixed.GetSpacing(),
191
+ fixed.GetDirection(),
192
+ )
193
+ moved_np, _ = image_to_data(moved)
194
+ dvf_np, _ = image_to_data(dvf)
195
+ return moved_np, dvf_np
196
+ finally:
197
+ shutil.rmtree(work, ignore_errors=True)
198
+
199
+
200
+ class ElastixRegistration(torch.nn.Module):
201
+ """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
202
+
203
+ ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
204
+ ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
205
+ needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
206
+ """
207
+
208
+ accepts_attributes = True
209
+
210
+ def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
211
+ super().__init__()
212
+ if engine != "elastix":
213
+ raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
214
+ self._engine = ElastixEngine(parameter_maps, models, iterations)
215
+
216
+ def forward(
217
+ self,
218
+ fixed: torch.Tensor,
219
+ moving: torch.Tensor,
220
+ fixed_mask: torch.Tensor,
221
+ moving_mask: torch.Tensor,
222
+ attributes: list[list[Attribute]],
223
+ ) -> torch.Tensor:
224
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
225
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
226
+ # (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
227
+ # the whole image (the auto-filled default when the user supplies none) restricts nothing.
228
+ fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
229
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
230
+ combined = []
231
+ for b in range(fixed.shape[0]):
232
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
233
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
234
+ fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
235
+ moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
236
+ moved_np, dvf_np = self._engine.register(
237
+ fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
238
+ )
239
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
240
+ return torch.stack(combined, dim=0).to(fixed.device)
241
+
242
+
243
+ class ChannelSelect(torch.nn.Module):
244
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
245
+
246
+ def __init__(self, start: int, stop: int) -> None:
247
+ super().__init__()
248
+ self._start = start
249
+ self._stop = stop
250
+
251
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
252
+ return tensor[:, self._start : self._stop]
253
+
254
+
255
+ class RegistrationNet(network.Network):
256
+ """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
257
+ fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
258
+
259
+ Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
260
+ ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
261
+ both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
262
+ ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
268
+ schedulers: dict[str, network.LRSchedulersLoader] = {
269
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
270
+ },
271
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
272
+ engine: str = "elastix",
273
+ parameter_maps: list[str] = [],
274
+ models: list[str] = [],
275
+ iterations: int = 1000,
276
+ ) -> None:
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(engine, parameter_maps, models, iterations),
287
+ in_branch=[0, 1, 2, 3],
288
+ out_branch=["registration"],
289
+ )
290
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
291
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
ParameterMap_CBCT_generic_MRSeg.txt → CBCT_CT_MRSeg/ParameterMap_CBCT_generic_MRSeg.txt RENAMED
File without changes
CBCT_CT_MRSeg/Prediction.yml ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ engine: elastix
6
+ parameter_maps:
7
+ - ParameterMap_CBCT_generic_MRSeg.txt
8
+ models:
9
+ - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
10
+ - VBoussot/impact-torchscript-models:MRSeg/MRSeg.pt
11
+ iterations: 1100
12
+ outputs_criterions: None
13
+ Dataset:
14
+ groups_src:
15
+ Volume_0:
16
+ groups_dest:
17
+ Fixed:
18
+ transforms:
19
+ TensorCast:
20
+ dtype: float32
21
+ inverse: false
22
+ patch_transforms: None
23
+ is_input: true
24
+ Volume_1:
25
+ groups_dest:
26
+ Moving:
27
+ transforms:
28
+ TensorCast:
29
+ dtype: float32
30
+ inverse: false
31
+ patch_transforms: None
32
+ is_input: true
33
+ Volume_2:
34
+ groups_dest:
35
+ FixedMask:
36
+ transforms:
37
+ TensorCast:
38
+ dtype: float32
39
+ inverse: false
40
+ patch_transforms: None
41
+ is_input: true
42
+ Volume_3:
43
+ groups_dest:
44
+ MovingMask:
45
+ transforms:
46
+ TensorCast:
47
+ dtype: float32
48
+ inverse: false
49
+ patch_transforms: None
50
+ is_input: true
51
+ augmentations: None
52
+ Patch:
53
+ patch_size: None
54
+ overlap: None
55
+ mask: None
56
+ pad_value: None
57
+ extend_slice: 0
58
+ subset: None
59
+ filter: None
60
+ dataset_filenames:
61
+ - ./Dataset/:mha
62
+ use_cache: false
63
+ batch_size: 1
64
+ num_workers: None
65
+ pin_memory: false
66
+ prefetch_factor: None
67
+ persistent_workers: None
68
+ outputs_dataset:
69
+ MovedImage:
70
+ OutputDataset:
71
+ name_class: OutSameAsGroupDataset
72
+ before_reduction_transforms: None
73
+ after_reduction_transforms: None
74
+ final_transforms:
75
+ TensorCast:
76
+ dtype: float32
77
+ inverse: false
78
+ dataset_filename: Moved:mha
79
+ group: Moved
80
+ same_as_group: Volume_0:Fixed
81
+ patch_combine: None
82
+ inverse_transform: false
83
+ reduction: Mean
84
+ Mean: {}
85
+ DisplacementField:
86
+ OutputDataset:
87
+ name_class: OutSameAsGroupDataset
88
+ before_reduction_transforms: None
89
+ after_reduction_transforms: None
90
+ final_transforms:
91
+ TensorCast:
92
+ dtype: float32
93
+ inverse: false
94
+ dataset_filename: DVF:mha
95
+ group: DVF
96
+ same_as_group: Volume_0:Fixed
97
+ patch_combine: None
98
+ inverse_transform: false
99
+ reduction: Mean
100
+ Mean: {}
101
+ train_name: ImpactReg-CBCT-CT-MRSeg
102
+ manual_seed: 42
103
+ gpu_checkpoints: None
104
+ images_log: None
105
+ combine: Mean
106
+ autocast: false
107
+ data_log: None
CBCT_CT_MRSeg/app.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "CBCT/CT preset with MRSegmentator",
3
+ "short_description": "Generic CBCT/CT deformable registration using MRSegmentator features",
4
+ "description": "A four-level recursive B-spline deformable registration optimized for generic CBCT/CT alignment, driven by the IMPACT metric using semantic features extracted from the pretrained MRSegmentator model. The scheme follows a multi-resolution strategy with up to 300, 300, 250, and 200 ASGD iterations and 2000 stochastic spatial samples per level. Features are extracted at progressively finer voxel scales (3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm), with a level-dependent combination of Dice-based segmentation overlap and L1 feature distances on selected internal layers of MRSegmentator. Early levels rely on pure Dice supervision, while finer stages progressively integrate feature-level alignment with increasing L1 contribution (0.3/0.7, 0.5/0.5) and a final purely feature-based stage. The optimization minimizes a composite objective (IMPACT + mutual information + bending energy penalty), enabling robust cross-modality alignment between CBCT and CT while enforcing smooth, physically plausible deformations.",
5
+ "task": "registration",
6
+ "tta": 0,
7
+ "mc_dropout": 0,
8
+ "models": [
9
+ "model.pt"
10
+ ],
11
+ "inputs": {
12
+ "Fixed": {
13
+ "display_name": "Fixed image",
14
+ "volume_type": "VOLUME",
15
+ "required": true
16
+ },
17
+ "Moving": {
18
+ "display_name": "Moving image",
19
+ "volume_type": "VOLUME",
20
+ "required": true
21
+ },
22
+ "FixedMask": {
23
+ "display_name": "Fixed mask (optional)",
24
+ "volume_type": "SEGMENTATION",
25
+ "required": false
26
+ },
27
+ "MovingMask": {
28
+ "display_name": "Moving mask (optional)",
29
+ "volume_type": "SEGMENTATION",
30
+ "required": false
31
+ }
32
+ },
33
+ "outputs": {
34
+ "MovedImage": {
35
+ "display_name": "Moved image",
36
+ "volume_type": "VOLUME",
37
+ "required": true
38
+ },
39
+ "DisplacementField": {
40
+ "display_name": "Displacement field",
41
+ "volume_type": "VOLUME",
42
+ "required": false
43
+ }
44
+ },
45
+ "inputs_evaluations": {
46
+ "Image": {
47
+ "Evaluation_with_images.yml": {
48
+ "FixedImage": {
49
+ "display_name": "Fixed image",
50
+ "volume_type": "VOLUME",
51
+ "required": true
52
+ },
53
+ "MovingImage": {
54
+ "display_name": "Moving image",
55
+ "volume_type": "VOLUME",
56
+ "required": true
57
+ },
58
+ "Mask": {
59
+ "display_name": "Evaluation mask",
60
+ "volume_type": "SEGMENTATION",
61
+ "required": false
62
+ }
63
+ }
64
+ },
65
+ "Segmentation": {
66
+ "Evaluation_with_seg.yml": {
67
+ "FixedSeg": {
68
+ "display_name": "Fixed segmentation",
69
+ "volume_type": "SEGMENTATION",
70
+ "required": true
71
+ },
72
+ "MovingSeg": {
73
+ "display_name": "Moving segmentation",
74
+ "volume_type": "SEGMENTATION",
75
+ "required": true
76
+ }
77
+ }
78
+ },
79
+ "Landmarks": {
80
+ "Evaluation_with_fid.yml": {
81
+ "FixedFid": {
82
+ "display_name": "Fixed landmarks",
83
+ "volume_type": "FIDUCIALS",
84
+ "required": true
85
+ },
86
+ "MovingFid": {
87
+ "display_name": "Moving landmarks",
88
+ "volume_type": "FIDUCIALS",
89
+ "required": true
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
CBCT_CT_MRSeg/install.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/model.pt ADDED
Binary file (1.25 kB). View file
 
CBCT_CT_TS/Evaluation_with_fid.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedFid:
4
+ targets_criterions:
5
+ MovingFid:
6
+ criterions_loader:
7
+ TRE: {}
8
+ Dataset:
9
+ groups_src:
10
+ Volume_0:
11
+ groups_dest:
12
+ FixedFid:
13
+ transforms: None
14
+ Reference_0:
15
+ groups_dest:
16
+ MovingFid:
17
+ transforms: None
18
+ subset: None
19
+ dataset_filenames:
20
+ - ./Dataset:mha
21
+ validation: None
22
+ train_name: ImpactReg
CBCT_CT_TS/Evaluation_with_images.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedImage:
4
+ targets_criterions:
5
+ MovingImage;Mask:
6
+ criterions_loader:
7
+ MAESaveMap:
8
+ reduction: mean
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedImage:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: float32
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingImage:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: float32
25
+ Mask_0:
26
+ groups_dest:
27
+ Mask:
28
+ transforms:
29
+ TensorCast:
30
+ dtype: uint8
31
+ subset: None
32
+ dataset_filenames:
33
+ - ./Dataset:mha
34
+ validation: None
35
+ train_name: ImpactReg
CBCT_CT_TS/Evaluation_with_seg.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedSeg:
4
+ targets_criterions:
5
+ MovingSeg:
6
+ criterions_loader:
7
+ DiceSaveMap:
8
+ labels: None
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: Seg_MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedSeg:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: uint8
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingSeg:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: uint8
25
+ subset: None
26
+ dataset_filenames:
27
+ - ./Dataset:mha
28
+ validation: None
29
+ train_name: ImpactReg
CBCT_CT_TS/Model.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, built the idiomatic way (an ``add_module`` graph).
18
+
19
+ ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
+ (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
+ onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
+ (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
+ ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
+ needs to register in physical space.
25
+
26
+ Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
27
+
28
+ Patch.patch_size: None # one patch per case (registration is global)
29
+ batch_size: 1 # one fixed/moving pair at a time
30
+
31
+ NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
32
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
+ """
34
+
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess # nosec B404
39
+ import tempfile
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+ import SimpleITK as sitk
44
+ import torch
45
+ import tqdm
46
+ from huggingface_hub import hf_hub_download
47
+ from install import get_elastix_bin, install_elastix_impact, try_elastix
48
+ from konfai.network import network
49
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
50
+
51
+ # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
52
+ # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
+ ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
+
55
+
56
+ class ElastixEngine:
57
+ """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
58
+
59
+ NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
60
+ does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
+ """
62
+
63
+ def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
64
+ self._bundle_dir = Path(__file__).resolve().parent
65
+ self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
66
+ self._models = models
67
+ self._iterations = iterations
68
+ self._elastix_bin = self._ensure_binary()
69
+ self._local_models = self._download_models()
70
+
71
+ def _ensure_binary(self) -> Path:
72
+ # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
+ override = os.environ.get("KONFAI_ELASTIX_DIR", "")
74
+ if override:
75
+ try_elastix(Path(override))
76
+ return get_elastix_bin(Path(override)).resolve()
77
+ ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
78
+ try:
79
+ try_elastix(ELASTIX_CACHE)
80
+ except Exception:
81
+ install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
82
+ try_elastix(ELASTIX_CACHE)
83
+ return get_elastix_bin(ELASTIX_CACHE).resolve()
84
+
85
+ def _download_models(self) -> list[tuple[str, Path]]:
86
+ """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
87
+ models = []
88
+ for ref in self._models:
89
+ repo, filename = ref.split(":", 1)
90
+ local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
91
+ models.append((filename, local))
92
+ return models
93
+
94
+ def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
+ """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
96
+ staged = []
97
+ for src in self._parameter_maps:
98
+ dst = work / src.name
99
+ lines = []
100
+ for line in src.read_text(encoding="utf-8").splitlines():
101
+ if line.strip().startswith("(ImpactGPU"):
102
+ line = f"(ImpactGPU {device_index})"
103
+ lines.append(line)
104
+ dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
+ staged.append(dst)
106
+ return staged
107
+
108
+ def register(
109
+ self,
110
+ fixed: sitk.Image,
111
+ moving: sitk.Image,
112
+ device_index: int,
113
+ fixed_mask: sitk.Image | None = None,
114
+ moving_mask: sitk.Image | None = None,
115
+ ) -> tuple[np.ndarray, np.ndarray]:
116
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
117
+
118
+ Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
119
+ (elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
120
+ """
121
+ work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
122
+ try:
123
+ fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
124
+ sitk.WriteImage(fixed, str(fixed_path))
125
+ sitk.WriteImage(moving, str(moving_path))
126
+
127
+ # Stage the feature models at the relative path the parameter maps reference
128
+ # (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
129
+ for rel_name, model_path in self._local_models:
130
+ dst = work / rel_name
131
+ dst.parent.mkdir(parents=True, exist_ok=True)
132
+ if not dst.exists():
133
+ dst.symlink_to(model_path)
134
+
135
+ args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
136
+ for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
137
+ if mask is not None:
138
+ mask_path = work / name
139
+ sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
140
+ args += [flag, str(mask_path)]
141
+ args += ["-out", str(work)]
142
+ for pmap in self._stage_parameter_maps(work, device_index):
143
+ args += ["-p", str(pmap)]
144
+
145
+ # Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
146
+ # the "N% done/total" progress line) shows real progress during the long registration.
147
+ # Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
148
+ # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
149
+ env = os.environ.copy()
150
+ extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
151
+ env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
152
+ proc = subprocess.Popen( # nosec B603
153
+ args,
154
+ cwd=str(work),
155
+ stdout=subprocess.PIPE,
156
+ stderr=subprocess.STDOUT,
157
+ text=True,
158
+ bufsize=1,
159
+ env=env,
160
+ )
161
+ captured: list[str] = []
162
+ iteration_line = re.compile(r"^\d+\s")
163
+ # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
+ # chained parameter maps), so the bar spans the whole chain of registration stages.
165
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
166
+ assert proc.stdout is not None
167
+ for line in proc.stdout:
168
+ captured.append(line)
169
+ if iteration_line.match(line):
170
+ progress.update(1)
171
+ progress.close()
172
+ returncode = proc.wait()
173
+ if returncode != 0:
174
+ raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
175
+
176
+ transforms = sorted(
177
+ work.glob("TransformParameters.*-Composite.itk.txt"),
178
+ key=lambda p: int(p.name.split(".")[1].split("-")[0]),
179
+ )
180
+ if not transforms:
181
+ raise FileNotFoundError("elastix produced no composite transform file.")
182
+ transform = sitk.ReadTransform(str(transforms[-1]))
183
+
184
+ moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
185
+ dvf = sitk.TransformToDisplacementField(
186
+ transform,
187
+ sitk.sitkVectorFloat64,
188
+ fixed.GetSize(),
189
+ fixed.GetOrigin(),
190
+ fixed.GetSpacing(),
191
+ fixed.GetDirection(),
192
+ )
193
+ moved_np, _ = image_to_data(moved)
194
+ dvf_np, _ = image_to_data(dvf)
195
+ return moved_np, dvf_np
196
+ finally:
197
+ shutil.rmtree(work, ignore_errors=True)
198
+
199
+
200
+ class ElastixRegistration(torch.nn.Module):
201
+ """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
202
+
203
+ ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
204
+ ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
205
+ needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
206
+ """
207
+
208
+ accepts_attributes = True
209
+
210
+ def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
211
+ super().__init__()
212
+ if engine != "elastix":
213
+ raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
214
+ self._engine = ElastixEngine(parameter_maps, models, iterations)
215
+
216
+ def forward(
217
+ self,
218
+ fixed: torch.Tensor,
219
+ moving: torch.Tensor,
220
+ fixed_mask: torch.Tensor,
221
+ moving_mask: torch.Tensor,
222
+ attributes: list[list[Attribute]],
223
+ ) -> torch.Tensor:
224
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
225
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
226
+ # (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
227
+ # the whole image (the auto-filled default when the user supplies none) restricts nothing.
228
+ fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
229
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
230
+ combined = []
231
+ for b in range(fixed.shape[0]):
232
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
233
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
234
+ fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
235
+ moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
236
+ moved_np, dvf_np = self._engine.register(
237
+ fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
238
+ )
239
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
240
+ return torch.stack(combined, dim=0).to(fixed.device)
241
+
242
+
243
+ class ChannelSelect(torch.nn.Module):
244
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
245
+
246
+ def __init__(self, start: int, stop: int) -> None:
247
+ super().__init__()
248
+ self._start = start
249
+ self._stop = stop
250
+
251
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
252
+ return tensor[:, self._start : self._stop]
253
+
254
+
255
+ class RegistrationNet(network.Network):
256
+ """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
257
+ fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
258
+
259
+ Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
260
+ ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
261
+ both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
262
+ ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
268
+ schedulers: dict[str, network.LRSchedulersLoader] = {
269
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
270
+ },
271
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
272
+ engine: str = "elastix",
273
+ parameter_maps: list[str] = [],
274
+ models: list[str] = [],
275
+ iterations: int = 1000,
276
+ ) -> None:
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(engine, parameter_maps, models, iterations),
287
+ in_branch=[0, 1, 2, 3],
288
+ out_branch=["registration"],
289
+ )
290
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
291
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
ParameterMap_CBCT_generic_TS.txt → CBCT_CT_TS/ParameterMap_CBCT_generic_TS.txt RENAMED
File without changes
CBCT_CT_TS/Prediction.yml ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ engine: elastix
6
+ parameter_maps:
7
+ - ParameterMap_CBCT_generic_TS.txt
8
+ models:
9
+ - VBoussot/impact-torchscript-models:TS/M852.pt
10
+ - VBoussot/impact-torchscript-models:TS/M850.pt
11
+ iterations: 1050
12
+ outputs_criterions: None
13
+ Dataset:
14
+ groups_src:
15
+ Volume_0:
16
+ groups_dest:
17
+ Fixed:
18
+ transforms:
19
+ TensorCast:
20
+ dtype: float32
21
+ inverse: false
22
+ patch_transforms: None
23
+ is_input: true
24
+ Volume_1:
25
+ groups_dest:
26
+ Moving:
27
+ transforms:
28
+ TensorCast:
29
+ dtype: float32
30
+ inverse: false
31
+ patch_transforms: None
32
+ is_input: true
33
+ Volume_2:
34
+ groups_dest:
35
+ FixedMask:
36
+ transforms:
37
+ TensorCast:
38
+ dtype: float32
39
+ inverse: false
40
+ patch_transforms: None
41
+ is_input: true
42
+ Volume_3:
43
+ groups_dest:
44
+ MovingMask:
45
+ transforms:
46
+ TensorCast:
47
+ dtype: float32
48
+ inverse: false
49
+ patch_transforms: None
50
+ is_input: true
51
+ augmentations: None
52
+ Patch:
53
+ patch_size: None
54
+ overlap: None
55
+ mask: None
56
+ pad_value: None
57
+ extend_slice: 0
58
+ subset: None
59
+ filter: None
60
+ dataset_filenames:
61
+ - ./Dataset/:mha
62
+ use_cache: false
63
+ batch_size: 1
64
+ num_workers: None
65
+ pin_memory: false
66
+ prefetch_factor: None
67
+ persistent_workers: None
68
+ outputs_dataset:
69
+ MovedImage:
70
+ OutputDataset:
71
+ name_class: OutSameAsGroupDataset
72
+ before_reduction_transforms: None
73
+ after_reduction_transforms: None
74
+ final_transforms:
75
+ TensorCast:
76
+ dtype: float32
77
+ inverse: false
78
+ dataset_filename: Moved:mha
79
+ group: Moved
80
+ same_as_group: Volume_0:Fixed
81
+ patch_combine: None
82
+ inverse_transform: false
83
+ reduction: Mean
84
+ Mean: {}
85
+ DisplacementField:
86
+ OutputDataset:
87
+ name_class: OutSameAsGroupDataset
88
+ before_reduction_transforms: None
89
+ after_reduction_transforms: None
90
+ final_transforms:
91
+ TensorCast:
92
+ dtype: float32
93
+ inverse: false
94
+ dataset_filename: DVF:mha
95
+ group: DVF
96
+ same_as_group: Volume_0:Fixed
97
+ patch_combine: None
98
+ inverse_transform: false
99
+ reduction: Mean
100
+ Mean: {}
101
+ train_name: ImpactReg-CBCT-CT-TS
102
+ manual_seed: 42
103
+ gpu_checkpoints: None
104
+ images_log: None
105
+ combine: Mean
106
+ autocast: false
107
+ data_log: None
CBCT_CT_TS/app.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "CBCT/CT preset with TotalSegmentator",
3
+ "short_description": "Generic CBCT/CT deformable registration using TotalSegmentator features",
4
+ "description": "A four-level recursive B-spline deformable registration optimized for generic CBCT/CT alignment, driven by the IMPACT metric using semantic features extracted from pretrained TotalSegmentator TorchScript models. The optimization follows a multi-resolution ASGD scheme with up to 300, 300, 250, and 200 iterations using 2000 random spatial samples per level. Features are extracted at progressively finer voxel scales (3 mm, 3 mm, 2×2×3 mm, 2×2×3 mm), starting with Dice-based overlap on segmentation outputs and progressively integrating feature-level alignment via L1 distances on selected internal layers (0.3/0.7 then 0.5/0.5 L1/Dice), ending with a final purely feature-based stage. A composite objective (IMPACT + mutual information + bending energy penalty) ensures robust cross-modality alignment while enforcing smooth, physically plausible deformations.",
5
+ "task": "registration",
6
+ "tta": 0,
7
+ "mc_dropout": 0,
8
+ "models": [
9
+ "model.pt"
10
+ ],
11
+ "inputs": {
12
+ "Fixed": {
13
+ "display_name": "Fixed image",
14
+ "volume_type": "VOLUME",
15
+ "required": true
16
+ },
17
+ "Moving": {
18
+ "display_name": "Moving image",
19
+ "volume_type": "VOLUME",
20
+ "required": true
21
+ },
22
+ "FixedMask": {
23
+ "display_name": "Fixed mask (optional)",
24
+ "volume_type": "SEGMENTATION",
25
+ "required": false
26
+ },
27
+ "MovingMask": {
28
+ "display_name": "Moving mask (optional)",
29
+ "volume_type": "SEGMENTATION",
30
+ "required": false
31
+ }
32
+ },
33
+ "outputs": {
34
+ "MovedImage": {
35
+ "display_name": "Moved image",
36
+ "volume_type": "VOLUME",
37
+ "required": true
38
+ },
39
+ "DisplacementField": {
40
+ "display_name": "Displacement field",
41
+ "volume_type": "VOLUME",
42
+ "required": false
43
+ }
44
+ },
45
+ "inputs_evaluations": {
46
+ "Image": {
47
+ "Evaluation_with_images.yml": {
48
+ "FixedImage": {
49
+ "display_name": "Fixed image",
50
+ "volume_type": "VOLUME",
51
+ "required": true
52
+ },
53
+ "MovingImage": {
54
+ "display_name": "Moving image",
55
+ "volume_type": "VOLUME",
56
+ "required": true
57
+ },
58
+ "Mask": {
59
+ "display_name": "Evaluation mask",
60
+ "volume_type": "SEGMENTATION",
61
+ "required": false
62
+ }
63
+ }
64
+ },
65
+ "Segmentation": {
66
+ "Evaluation_with_seg.yml": {
67
+ "FixedSeg": {
68
+ "display_name": "Fixed segmentation",
69
+ "volume_type": "SEGMENTATION",
70
+ "required": true
71
+ },
72
+ "MovingSeg": {
73
+ "display_name": "Moving segmentation",
74
+ "volume_type": "SEGMENTATION",
75
+ "required": true
76
+ }
77
+ }
78
+ },
79
+ "Landmarks": {
80
+ "Evaluation_with_fid.yml": {
81
+ "FixedFid": {
82
+ "display_name": "Fixed landmarks",
83
+ "volume_type": "FIDUCIALS",
84
+ "required": true
85
+ },
86
+ "MovingFid": {
87
+ "display_name": "Moving landmarks",
88
+ "volume_type": "FIDUCIALS",
89
+ "required": true
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
CBCT_CT_TS/install.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_TS/model.pt ADDED
Binary file (1.25 kB). View file
 
Generic_Rigid/Evaluation_with_fid.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedFid:
4
+ targets_criterions:
5
+ MovingFid:
6
+ criterions_loader:
7
+ TRE: {}
8
+ Dataset:
9
+ groups_src:
10
+ Volume_0:
11
+ groups_dest:
12
+ FixedFid:
13
+ transforms: None
14
+ Reference_0:
15
+ groups_dest:
16
+ MovingFid:
17
+ transforms: None
18
+ subset: None
19
+ dataset_filenames:
20
+ - ./Dataset:mha
21
+ validation: None
22
+ train_name: ImpactReg
Generic_Rigid/Evaluation_with_images.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedImage:
4
+ targets_criterions:
5
+ MovingImage;Mask:
6
+ criterions_loader:
7
+ MAESaveMap:
8
+ reduction: mean
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedImage:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: float32
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingImage:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: float32
25
+ Mask_0:
26
+ groups_dest:
27
+ Mask:
28
+ transforms:
29
+ TensorCast:
30
+ dtype: uint8
31
+ subset: None
32
+ dataset_filenames:
33
+ - ./Dataset:mha
34
+ validation: None
35
+ train_name: ImpactReg
Generic_Rigid/Evaluation_with_seg.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedSeg:
4
+ targets_criterions:
5
+ MovingSeg:
6
+ criterions_loader:
7
+ DiceSaveMap:
8
+ labels: None
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: Seg_MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedSeg:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: uint8
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingSeg:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: uint8
25
+ subset: None
26
+ dataset_filenames:
27
+ - ./Dataset:mha
28
+ validation: None
29
+ train_name: ImpactReg
Generic_Rigid/Model.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, built the idiomatic way (an ``add_module`` graph).
18
+
19
+ ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
+ (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
+ onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
+ (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
+ ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
+ needs to register in physical space.
25
+
26
+ Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
27
+
28
+ Patch.patch_size: None # one patch per case (registration is global)
29
+ batch_size: 1 # one fixed/moving pair at a time
30
+
31
+ NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
32
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
+ """
34
+
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess # nosec B404
39
+ import tempfile
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+ import SimpleITK as sitk
44
+ import torch
45
+ import tqdm
46
+ from huggingface_hub import hf_hub_download
47
+ from install import get_elastix_bin, install_elastix_impact, try_elastix
48
+ from konfai.network import network
49
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
50
+
51
+ # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
52
+ # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
+ ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
+
55
+
56
+ class ElastixEngine:
57
+ """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
58
+
59
+ NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
60
+ does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
+ """
62
+
63
+ def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
64
+ self._bundle_dir = Path(__file__).resolve().parent
65
+ self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
66
+ self._models = models
67
+ self._iterations = iterations
68
+ self._elastix_bin = self._ensure_binary()
69
+ self._local_models = self._download_models()
70
+
71
+ def _ensure_binary(self) -> Path:
72
+ # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
+ override = os.environ.get("KONFAI_ELASTIX_DIR", "")
74
+ if override:
75
+ try_elastix(Path(override))
76
+ return get_elastix_bin(Path(override)).resolve()
77
+ ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
78
+ try:
79
+ try_elastix(ELASTIX_CACHE)
80
+ except Exception:
81
+ install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
82
+ try_elastix(ELASTIX_CACHE)
83
+ return get_elastix_bin(ELASTIX_CACHE).resolve()
84
+
85
+ def _download_models(self) -> list[tuple[str, Path]]:
86
+ """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
87
+ models = []
88
+ for ref in self._models:
89
+ repo, filename = ref.split(":", 1)
90
+ local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
91
+ models.append((filename, local))
92
+ return models
93
+
94
+ def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
+ """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
96
+ staged = []
97
+ for src in self._parameter_maps:
98
+ dst = work / src.name
99
+ lines = []
100
+ for line in src.read_text(encoding="utf-8").splitlines():
101
+ if line.strip().startswith("(ImpactGPU"):
102
+ line = f"(ImpactGPU {device_index})"
103
+ lines.append(line)
104
+ dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
+ staged.append(dst)
106
+ return staged
107
+
108
+ def register(
109
+ self,
110
+ fixed: sitk.Image,
111
+ moving: sitk.Image,
112
+ device_index: int,
113
+ fixed_mask: sitk.Image | None = None,
114
+ moving_mask: sitk.Image | None = None,
115
+ ) -> tuple[np.ndarray, np.ndarray]:
116
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
117
+
118
+ Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
119
+ (elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
120
+ """
121
+ work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
122
+ try:
123
+ fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
124
+ sitk.WriteImage(fixed, str(fixed_path))
125
+ sitk.WriteImage(moving, str(moving_path))
126
+
127
+ # Stage the feature models at the relative path the parameter maps reference
128
+ # (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
129
+ for rel_name, model_path in self._local_models:
130
+ dst = work / rel_name
131
+ dst.parent.mkdir(parents=True, exist_ok=True)
132
+ if not dst.exists():
133
+ dst.symlink_to(model_path)
134
+
135
+ args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
136
+ for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
137
+ if mask is not None:
138
+ mask_path = work / name
139
+ sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
140
+ args += [flag, str(mask_path)]
141
+ args += ["-out", str(work)]
142
+ for pmap in self._stage_parameter_maps(work, device_index):
143
+ args += ["-p", str(pmap)]
144
+
145
+ # Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
146
+ # the "N% done/total" progress line) shows real progress during the long registration.
147
+ # Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
148
+ # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
149
+ env = os.environ.copy()
150
+ extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
151
+ env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
152
+ proc = subprocess.Popen( # nosec B603
153
+ args,
154
+ cwd=str(work),
155
+ stdout=subprocess.PIPE,
156
+ stderr=subprocess.STDOUT,
157
+ text=True,
158
+ bufsize=1,
159
+ env=env,
160
+ )
161
+ captured: list[str] = []
162
+ iteration_line = re.compile(r"^\d+\s")
163
+ # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
+ # chained parameter maps), so the bar spans the whole chain of registration stages.
165
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
166
+ assert proc.stdout is not None
167
+ for line in proc.stdout:
168
+ captured.append(line)
169
+ if iteration_line.match(line):
170
+ progress.update(1)
171
+ progress.close()
172
+ returncode = proc.wait()
173
+ if returncode != 0:
174
+ raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
175
+
176
+ transforms = sorted(
177
+ work.glob("TransformParameters.*-Composite.itk.txt"),
178
+ key=lambda p: int(p.name.split(".")[1].split("-")[0]),
179
+ )
180
+ if not transforms:
181
+ raise FileNotFoundError("elastix produced no composite transform file.")
182
+ transform = sitk.ReadTransform(str(transforms[-1]))
183
+
184
+ moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
185
+ dvf = sitk.TransformToDisplacementField(
186
+ transform,
187
+ sitk.sitkVectorFloat64,
188
+ fixed.GetSize(),
189
+ fixed.GetOrigin(),
190
+ fixed.GetSpacing(),
191
+ fixed.GetDirection(),
192
+ )
193
+ moved_np, _ = image_to_data(moved)
194
+ dvf_np, _ = image_to_data(dvf)
195
+ return moved_np, dvf_np
196
+ finally:
197
+ shutil.rmtree(work, ignore_errors=True)
198
+
199
+
200
+ class ElastixRegistration(torch.nn.Module):
201
+ """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
202
+
203
+ ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
204
+ ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
205
+ needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
206
+ """
207
+
208
+ accepts_attributes = True
209
+
210
+ def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
211
+ super().__init__()
212
+ if engine != "elastix":
213
+ raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
214
+ self._engine = ElastixEngine(parameter_maps, models, iterations)
215
+
216
+ def forward(
217
+ self,
218
+ fixed: torch.Tensor,
219
+ moving: torch.Tensor,
220
+ fixed_mask: torch.Tensor,
221
+ moving_mask: torch.Tensor,
222
+ attributes: list[list[Attribute]],
223
+ ) -> torch.Tensor:
224
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
225
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
226
+ # (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
227
+ # the whole image (the auto-filled default when the user supplies none) restricts nothing.
228
+ fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
229
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
230
+ combined = []
231
+ for b in range(fixed.shape[0]):
232
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
233
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
234
+ fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
235
+ moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
236
+ moved_np, dvf_np = self._engine.register(
237
+ fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
238
+ )
239
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
240
+ return torch.stack(combined, dim=0).to(fixed.device)
241
+
242
+
243
+ class ChannelSelect(torch.nn.Module):
244
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
245
+
246
+ def __init__(self, start: int, stop: int) -> None:
247
+ super().__init__()
248
+ self._start = start
249
+ self._stop = stop
250
+
251
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
252
+ return tensor[:, self._start : self._stop]
253
+
254
+
255
+ class RegistrationNet(network.Network):
256
+ """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
257
+ fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
258
+
259
+ Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
260
+ ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
261
+ both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
262
+ ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
268
+ schedulers: dict[str, network.LRSchedulersLoader] = {
269
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
270
+ },
271
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
272
+ engine: str = "elastix",
273
+ parameter_maps: list[str] = [],
274
+ models: list[str] = [],
275
+ iterations: int = 1000,
276
+ ) -> None:
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(engine, parameter_maps, models, iterations),
287
+ in_branch=[0, 1, 2, 3],
288
+ out_branch=["registration"],
289
+ )
290
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
291
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
Parameters_Rigid.txt → Generic_Rigid/Parameters_Rigid.txt RENAMED
File without changes
Generic_Rigid/Prediction.yml ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ engine: elastix
6
+ parameter_maps:
7
+ - Parameters_Rigid.txt
8
+ models: []
9
+ iterations: 250
10
+ outputs_criterions: None
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ Fixed:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: float32
19
+ inverse: false
20
+ patch_transforms: None
21
+ is_input: true
22
+ Volume_1:
23
+ groups_dest:
24
+ Moving:
25
+ transforms:
26
+ TensorCast:
27
+ dtype: float32
28
+ inverse: false
29
+ patch_transforms: None
30
+ is_input: true
31
+ Volume_2:
32
+ groups_dest:
33
+ FixedMask:
34
+ transforms:
35
+ TensorCast:
36
+ dtype: float32
37
+ inverse: false
38
+ patch_transforms: None
39
+ is_input: true
40
+ Volume_3:
41
+ groups_dest:
42
+ MovingMask:
43
+ transforms:
44
+ TensorCast:
45
+ dtype: float32
46
+ inverse: false
47
+ patch_transforms: None
48
+ is_input: true
49
+ augmentations: None
50
+ Patch:
51
+ patch_size: None
52
+ overlap: None
53
+ mask: None
54
+ pad_value: None
55
+ extend_slice: 0
56
+ subset: None
57
+ filter: None
58
+ dataset_filenames:
59
+ - ./Dataset/:mha
60
+ use_cache: false
61
+ batch_size: 1
62
+ num_workers: None
63
+ pin_memory: false
64
+ prefetch_factor: None
65
+ persistent_workers: None
66
+ outputs_dataset:
67
+ MovedImage:
68
+ OutputDataset:
69
+ name_class: OutSameAsGroupDataset
70
+ before_reduction_transforms: None
71
+ after_reduction_transforms: None
72
+ final_transforms:
73
+ TensorCast:
74
+ dtype: float32
75
+ inverse: false
76
+ dataset_filename: Moved:mha
77
+ group: Moved
78
+ same_as_group: Volume_0:Fixed
79
+ patch_combine: None
80
+ inverse_transform: false
81
+ reduction: Mean
82
+ Mean: {}
83
+ DisplacementField:
84
+ OutputDataset:
85
+ name_class: OutSameAsGroupDataset
86
+ before_reduction_transforms: None
87
+ after_reduction_transforms: None
88
+ final_transforms:
89
+ TensorCast:
90
+ dtype: float32
91
+ inverse: false
92
+ dataset_filename: DVF:mha
93
+ group: DVF
94
+ same_as_group: Volume_0:Fixed
95
+ patch_combine: None
96
+ inverse_transform: false
97
+ reduction: Mean
98
+ Mean: {}
99
+ train_name: ImpactReg-Generic-Rigid
100
+ manual_seed: 42
101
+ gpu_checkpoints: None
102
+ images_log: None
103
+ combine: Mean
104
+ autocast: false
105
+ data_log: None
Generic_Rigid/app.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "Generic Rigid",
3
+ "short_description": "Rigid registration using mutual information and a multi-resolution pyramid.",
4
+ "description": "This preset performs rigid alignment using an Euler transform optimized with Adaptive Stochastic Gradient Descent. It uses a 4-level multi-resolution strategy and Mattes mutual information as similarity metric. Initial alignment based on image centers are enabled to ensure robust convergence for multimodal images.",
5
+ "task": "registration",
6
+ "tta": 0,
7
+ "mc_dropout": 0,
8
+ "models": [
9
+ "model.pt"
10
+ ],
11
+ "inputs": {
12
+ "Fixed": {
13
+ "display_name": "Fixed image",
14
+ "volume_type": "VOLUME",
15
+ "required": true
16
+ },
17
+ "Moving": {
18
+ "display_name": "Moving image",
19
+ "volume_type": "VOLUME",
20
+ "required": true
21
+ },
22
+ "FixedMask": {
23
+ "display_name": "Fixed mask (optional)",
24
+ "volume_type": "SEGMENTATION",
25
+ "required": false
26
+ },
27
+ "MovingMask": {
28
+ "display_name": "Moving mask (optional)",
29
+ "volume_type": "SEGMENTATION",
30
+ "required": false
31
+ }
32
+ },
33
+ "outputs": {
34
+ "MovedImage": {
35
+ "display_name": "Moved image",
36
+ "volume_type": "VOLUME",
37
+ "required": true
38
+ },
39
+ "DisplacementField": {
40
+ "display_name": "Displacement field",
41
+ "volume_type": "VOLUME",
42
+ "required": false
43
+ }
44
+ },
45
+ "inputs_evaluations": {
46
+ "Image": {
47
+ "Evaluation_with_images.yml": {
48
+ "FixedImage": {
49
+ "display_name": "Fixed image",
50
+ "volume_type": "VOLUME",
51
+ "required": true
52
+ },
53
+ "MovingImage": {
54
+ "display_name": "Moving image",
55
+ "volume_type": "VOLUME",
56
+ "required": true
57
+ },
58
+ "Mask": {
59
+ "display_name": "Evaluation mask",
60
+ "volume_type": "SEGMENTATION",
61
+ "required": false
62
+ }
63
+ }
64
+ },
65
+ "Segmentation": {
66
+ "Evaluation_with_seg.yml": {
67
+ "FixedSeg": {
68
+ "display_name": "Fixed segmentation",
69
+ "volume_type": "SEGMENTATION",
70
+ "required": true
71
+ },
72
+ "MovingSeg": {
73
+ "display_name": "Moving segmentation",
74
+ "volume_type": "SEGMENTATION",
75
+ "required": true
76
+ }
77
+ }
78
+ },
79
+ "Landmarks": {
80
+ "Evaluation_with_fid.yml": {
81
+ "FixedFid": {
82
+ "display_name": "Fixed landmarks",
83
+ "volume_type": "FIDUCIALS",
84
+ "required": true
85
+ },
86
+ "MovingFid": {
87
+ "display_name": "Moving landmarks",
88
+ "volume_type": "FIDUCIALS",
89
+ "required": true
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
Generic_Rigid/install.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/model.pt ADDED
Binary file (1.25 kB). View file
 
Generic_Rigid_BSpline/Evaluation_with_fid.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedFid:
4
+ targets_criterions:
5
+ MovingFid:
6
+ criterions_loader:
7
+ TRE: {}
8
+ Dataset:
9
+ groups_src:
10
+ Volume_0:
11
+ groups_dest:
12
+ FixedFid:
13
+ transforms: None
14
+ Reference_0:
15
+ groups_dest:
16
+ MovingFid:
17
+ transforms: None
18
+ subset: None
19
+ dataset_filenames:
20
+ - ./Dataset:mha
21
+ validation: None
22
+ train_name: ImpactReg
Generic_Rigid_BSpline/Evaluation_with_images.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedImage:
4
+ targets_criterions:
5
+ MovingImage;Mask:
6
+ criterions_loader:
7
+ MAESaveMap:
8
+ reduction: mean
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedImage:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: float32
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingImage:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: float32
25
+ Mask_0:
26
+ groups_dest:
27
+ Mask:
28
+ transforms:
29
+ TensorCast:
30
+ dtype: uint8
31
+ subset: None
32
+ dataset_filenames:
33
+ - ./Dataset:mha
34
+ validation: None
35
+ train_name: ImpactReg
Generic_Rigid_BSpline/Evaluation_with_seg.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedSeg:
4
+ targets_criterions:
5
+ MovingSeg:
6
+ criterions_loader:
7
+ DiceSaveMap:
8
+ labels: None
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: Seg_MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedSeg:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: uint8
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingSeg:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: uint8
25
+ subset: None
26
+ dataset_filenames:
27
+ - ./Dataset:mha
28
+ validation: None
29
+ train_name: ImpactReg
Generic_Rigid_BSpline/Model.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, built the idiomatic way (an ``add_module`` graph).
18
+
19
+ ``RegistrationNet`` wires a single custom module ``ElastixRegistration`` that takes the fixed image
20
+ (input branch ``0``) and the moving image (input branch ``1``) and returns the moving image resampled
21
+ onto the FIXED grid. The module opts into receiving the per-input geometry via ``accepts_attributes``
22
+ (mirroring KonfAI's ``CriterionWithAttribute`` loss convention): the KonfAI graph then hands it the
23
+ ``Attribute`` (Origin/Spacing/Direction) of each branch alongside the tensors, which the elastix engine
24
+ needs to register in physical space.
25
+
26
+ Runs through the standard ``konfai.predictor.predict`` path in whole-volume mode:
27
+
28
+ Patch.patch_size: None # one patch per case (registration is global)
29
+ batch_size: 1 # one fixed/moving pair at a time
30
+
31
+ NOTE: do NOT add ``from __future__ import annotations`` here — KonfAI's config engine relies on
32
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break arg resolution.
33
+ """
34
+
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess # nosec B404
39
+ import tempfile
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+ import SimpleITK as sitk
44
+ import torch
45
+ import tqdm
46
+ from huggingface_hub import hf_hub_download
47
+ from install import get_elastix_bin, install_elastix_impact, try_elastix
48
+ from konfai.network import network
49
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
50
+
51
+ # Elastix + IMPACT binary is cached once here (heavy: binary + LibTorch) and reused across runs.
52
+ # Set KONFAI_ELASTIX_DIR to point at an existing install and skip the download.
53
+ ELASTIX_CACHE = Path.home() / ".cache" / "konfai" / "elastix-impact"
54
+
55
+
56
+ class ElastixEngine:
57
+ """Run the elastix-IMPACT binary on a fixed/moving pair; return (moved, dvf) on the fixed grid.
58
+
59
+ NOTE: the elastix-IMPACT metric lives only in the custom ``elastix-impact`` binary (SimpleElastix
60
+ does NOT ship it), so registration is a subprocess call, not ``sitk.ElastixImageFilter``.
61
+ """
62
+
63
+ def __init__(self, parameter_maps: list[str], models: list[str], iterations: int) -> None:
64
+ self._bundle_dir = Path(__file__).resolve().parent
65
+ self._parameter_maps = [self._bundle_dir / Path(p).name for p in parameter_maps]
66
+ self._models = models
67
+ self._iterations = iterations
68
+ self._elastix_bin = self._ensure_binary()
69
+ self._local_models = self._download_models()
70
+
71
+ def _ensure_binary(self) -> Path:
72
+ # Optional override: point at an existing elastix-IMPACT install (skips the download).
73
+ override = os.environ.get("KONFAI_ELASTIX_DIR", "")
74
+ if override:
75
+ try_elastix(Path(override))
76
+ return get_elastix_bin(Path(override)).resolve()
77
+ ELASTIX_CACHE.mkdir(parents=True, exist_ok=True)
78
+ try:
79
+ try_elastix(ELASTIX_CACHE)
80
+ except Exception:
81
+ install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
82
+ try_elastix(ELASTIX_CACHE)
83
+ return get_elastix_bin(ELASTIX_CACHE).resolve()
84
+
85
+ def _download_models(self) -> list[tuple[str, Path]]:
86
+ """Fetch the TorchScript feature models (``repo:filename``); keep (relative_name, local_path)."""
87
+ models = []
88
+ for ref in self._models:
89
+ repo, filename = ref.split(":", 1)
90
+ local = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) # nosec B615
91
+ models.append((filename, local))
92
+ return models
93
+
94
+ def _stage_parameter_maps(self, work: Path, device_index: int) -> list[Path]:
95
+ """Copy parameter maps into the work dir, patching the ImpactGPU device field when present."""
96
+ staged = []
97
+ for src in self._parameter_maps:
98
+ dst = work / src.name
99
+ lines = []
100
+ for line in src.read_text(encoding="utf-8").splitlines():
101
+ if line.strip().startswith("(ImpactGPU"):
102
+ line = f"(ImpactGPU {device_index})"
103
+ lines.append(line)
104
+ dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
105
+ staged.append(dst)
106
+ return staged
107
+
108
+ def register(
109
+ self,
110
+ fixed: sitk.Image,
111
+ moving: sitk.Image,
112
+ device_index: int,
113
+ fixed_mask: sitk.Image | None = None,
114
+ moving_mask: sitk.Image | None = None,
115
+ ) -> tuple[np.ndarray, np.ndarray]:
116
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid.
117
+
118
+ Optional ``fixed_mask`` / ``moving_mask`` restrict the similarity metric to a region
119
+ (elastix ``-fMask`` / ``-mMask``); a mask covering the whole image is equivalent to passing none.
120
+ """
121
+ work = Path(tempfile.mkdtemp(prefix="konfai_reg_"))
122
+ try:
123
+ fixed_path, moving_path = work / "Fixed.mha", work / "Moving.mha"
124
+ sitk.WriteImage(fixed, str(fixed_path))
125
+ sitk.WriteImage(moving, str(moving_path))
126
+
127
+ # Stage the feature models at the relative path the parameter maps reference
128
+ # (e.g. ImpactModelsPath0 "MIND/R1D2_3D.pt"), resolved from the elastix working directory.
129
+ for rel_name, model_path in self._local_models:
130
+ dst = work / rel_name
131
+ dst.parent.mkdir(parents=True, exist_ok=True)
132
+ if not dst.exists():
133
+ dst.symlink_to(model_path)
134
+
135
+ args = [str(self._elastix_bin), "-f", str(fixed_path), "-m", str(moving_path)]
136
+ for flag, mask, name in (("-fMask", fixed_mask, "FixedMask.mha"), ("-mMask", moving_mask, "MovingMask.mha")):
137
+ if mask is not None:
138
+ mask_path = work / name
139
+ sitk.WriteImage(sitk.Cast(mask, sitk.sitkUInt8), str(mask_path))
140
+ args += [flag, str(mask_path)]
141
+ args += ["-out", str(work)]
142
+ for pmap in self._stage_parameter_maps(work, device_index):
143
+ args += ["-p", str(pmap)]
144
+
145
+ # Stream elastix stdout and drive a tqdm bar over its iterations so SlicerKonfAI (which parses
146
+ # the "N% done/total" progress line) shows real progress during the long registration.
147
+ # Make the elastix binary's own libs (bundled libtorch under <install>/lib) and any extra
148
+ # libtorch/CUDA dirs (KONFAI_ELASTIX_EXTRA_LIB) findable so the IMPACT metric plugin loads.
149
+ env = os.environ.copy()
150
+ extra_libs = [str(self._elastix_bin.parent.parent / "lib"), os.environ.get("KONFAI_ELASTIX_EXTRA_LIB", "")]
151
+ env["LD_LIBRARY_PATH"] = os.pathsep.join(p for p in [*extra_libs, env.get("LD_LIBRARY_PATH", "")] if p)
152
+ proc = subprocess.Popen( # nosec B603
153
+ args,
154
+ cwd=str(work),
155
+ stdout=subprocess.PIPE,
156
+ stderr=subprocess.STDOUT,
157
+ text=True,
158
+ bufsize=1,
159
+ env=env,
160
+ )
161
+ captured: list[str] = []
162
+ iteration_line = re.compile(r"^\d+\s")
163
+ # ``iterations`` is the total iteration budget declared for the preset (summed over the
164
+ # chained parameter maps), so the bar spans the whole chain of registration stages.
165
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
166
+ assert proc.stdout is not None
167
+ for line in proc.stdout:
168
+ captured.append(line)
169
+ if iteration_line.match(line):
170
+ progress.update(1)
171
+ progress.close()
172
+ returncode = proc.wait()
173
+ if returncode != 0:
174
+ raise RuntimeError(f"elastix failed (code {returncode}):\n{''.join(captured[-40:])}")
175
+
176
+ transforms = sorted(
177
+ work.glob("TransformParameters.*-Composite.itk.txt"),
178
+ key=lambda p: int(p.name.split(".")[1].split("-")[0]),
179
+ )
180
+ if not transforms:
181
+ raise FileNotFoundError("elastix produced no composite transform file.")
182
+ transform = sitk.ReadTransform(str(transforms[-1]))
183
+
184
+ moved = sitk.Resample(moving, fixed, transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
185
+ dvf = sitk.TransformToDisplacementField(
186
+ transform,
187
+ sitk.sitkVectorFloat64,
188
+ fixed.GetSize(),
189
+ fixed.GetOrigin(),
190
+ fixed.GetSpacing(),
191
+ fixed.GetDirection(),
192
+ )
193
+ moved_np, _ = image_to_data(moved)
194
+ dvf_np, _ = image_to_data(dvf)
195
+ return moved_np, dvf_np
196
+ finally:
197
+ shutil.rmtree(work, ignore_errors=True)
198
+
199
+
200
+ class ElastixRegistration(torch.nn.Module):
201
+ """Custom graph module: (fixed, moving) tensors + their geometry -> moved image on the fixed grid.
202
+
203
+ ``accepts_attributes = True`` opts this module into receiving, from the KonfAI graph, the per-branch
204
+ ``Attribute`` list alongside the tensors (same convention as ``CriterionWithAttribute``). elastix
205
+ needs the physical geometry (Origin/Spacing/Direction), which raw tensors do not carry.
206
+ """
207
+
208
+ accepts_attributes = True
209
+
210
+ def __init__(self, engine: str, parameter_maps: list[str], models: list[str], iterations: int) -> None:
211
+ super().__init__()
212
+ if engine != "elastix":
213
+ raise NotImplementedError(f"ElastixRegistration engine '{engine}' is not implemented yet.")
214
+ self._engine = ElastixEngine(parameter_maps, models, iterations)
215
+
216
+ def forward(
217
+ self,
218
+ fixed: torch.Tensor,
219
+ moving: torch.Tensor,
220
+ fixed_mask: torch.Tensor,
221
+ moving_mask: torch.Tensor,
222
+ attributes: list[list[Attribute]],
223
+ ) -> torch.Tensor:
224
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each is a list[Attribute] over the batch.
225
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field
226
+ # (dim channels), both on the fixed grid; downstream ChannelSelect modules split them. A mask covering
227
+ # the whole image (the auto-filled default when the user supplies none) restricts nothing.
228
+ fixed_attrs, moving_attrs, fmask_attrs, mmask_attrs = attributes
229
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
230
+ combined = []
231
+ for b in range(fixed.shape[0]):
232
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
233
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
234
+ fixed_mask_img = data_to_image(fixed_mask[b].detach().cpu().numpy(), fmask_attrs[b])
235
+ moving_mask_img = data_to_image(moving_mask[b].detach().cpu().numpy(), mmask_attrs[b])
236
+ moved_np, dvf_np = self._engine.register(
237
+ fixed_img, moving_img, device_index, fixed_mask_img, moving_mask_img
238
+ )
239
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
240
+ return torch.stack(combined, dim=0).to(fixed.device)
241
+
242
+
243
+ class ChannelSelect(torch.nn.Module):
244
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
245
+
246
+ def __init__(self, start: int, stop: int) -> None:
247
+ super().__init__()
248
+ self._start = start
249
+ self._stop = stop
250
+
251
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
252
+ return tensor[:, self._start : self._stop]
253
+
254
+
255
+ class RegistrationNet(network.Network):
256
+ """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1,
257
+ fixed mask = branch 2, moving mask = branch 3; masks restrict the metric, whole-image = no restriction).
258
+
259
+ Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and
260
+ ``DisplacementField`` (the dim-component displacement field, in mm). ``ElastixRegistration`` produces
261
+ both channel-stacked; two ``ChannelSelect`` modules split them into the named outputs referenced by
262
+ ``Prediction.yml``. Output geometry is attached by the predictor via ``same_as_group: Volume_0:Fixed``.
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
268
+ schedulers: dict[str, network.LRSchedulersLoader] = {
269
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
270
+ },
271
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
272
+ engine: str = "elastix",
273
+ parameter_maps: list[str] = [],
274
+ models: list[str] = [],
275
+ iterations: int = 1000,
276
+ ) -> None:
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(engine, parameter_maps, models, iterations),
287
+ in_branch=[0, 1, 2, 3],
288
+ out_branch=["registration"],
289
+ )
290
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
291
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
Parameters_BSpline.txt → Generic_Rigid_BSpline/Parameters_BSpline.txt RENAMED
File without changes
Generic_Rigid_BSpline/Parameters_Rigid.txt ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Example parameter file for rotation registration
2
+ // C-style comments: //
3
+
4
+ // The internal pixel type, used for internal computations
5
+ // Leave to float in general.
6
+ // NB: this is not the type of the input images! The pixel
7
+ // type of the input images is automatically read from the
8
+ // images themselves.
9
+ // This setting can be changed to "short" to save some memory
10
+ // in case of very large 3D images.
11
+ (FixedInternalImagePixelType "float")
12
+ (MovingInternalImagePixelType "float")
13
+
14
+ // **************** Main Components **************************
15
+
16
+ // The following components should usually be left as they are:
17
+ (Registration "MultiResolutionRegistration")
18
+ (Interpolator "BSplineInterpolator")
19
+ (ResampleInterpolator "FinalBSplineInterpolator")
20
+ (Resampler "DefaultResampler")
21
+
22
+ // These may be changed to Fixed/MovingSmoothingImagePyramid.
23
+ // See the manual.
24
+ (FixedImagePyramid "FixedRecursiveImagePyramid")
25
+ (MovingImagePyramid "MovingRecursiveImagePyramid")
26
+
27
+ // The following components are most important:
28
+ // The optimizer AdaptiveStochasticGradientDescent (ASGD) works
29
+ // quite ok in general. The Transform and Metric are important
30
+ // and need to be chosen careful for each application. See manual.
31
+ (Optimizer "AdaptiveStochasticGradientDescent")
32
+ (Transform "EulerTransform")
33
+ (Metric "AdvancedMattesMutualInformation")
34
+
35
+ // ***************** Transformation **************************
36
+
37
+ // Scales the rotations compared to the translations, to make
38
+ // sure they are in the same range. In general, it's best to
39
+ // use automatic scales estimation:
40
+ (AutomaticScalesEstimation "true")
41
+
42
+ // Automatically guess an initial translation by aligning the
43
+ // geometric centers of the fixed and moving.
44
+ (AutomaticTransformInitialization "true")
45
+
46
+ // Whether transforms are combined by composition or by addition.
47
+ // In generally, Compose is the best option in most cases.
48
+ // It does not influence the results very much.
49
+ (HowToCombineTransforms "Compose")
50
+
51
+ //Save composite ITK transform
52
+ (ITKTransformOutputFileNameExtension "itk.txt")
53
+ (WriteITKCompositeTransform "true")
54
+
55
+ // ******************* Similarity measure *********************
56
+
57
+ // Number of grey level bins in each resolution level,
58
+ // for the mutual information. 16 or 32 usually works fine.
59
+ // You could also employ a hierarchical strategy:
60
+ //(NumberOfHistogramBins 16 32 64)
61
+ (NumberOfHistogramBins 32)
62
+
63
+ // If you use a mask, this option is important.
64
+ // If the mask serves as region of interest, set it to false.
65
+ // If the mask indicates which pixels are valid, then set it to true.
66
+ // If you do not use a mask, the option doesn't matter.
67
+ (ErodeMask "false")
68
+
69
+ // ******************** Multiresolution **********************
70
+
71
+ // The number of resolutions. 1 Is only enough if the expected
72
+ // deformations are small. 3 or 4 mostly works fine. For large
73
+ // images and large deformations, 5 or 6 may even be useful.
74
+ (NumberOfResolutions 4)
75
+
76
+ // The downsampling/blurring factors for the image pyramids.
77
+ // By default, the images are downsampled by a factor of 2
78
+ // compared to the next resolution.
79
+ // So, in 2D, with 4 resolutions, the following schedule is used:
80
+ //(ImagePyramidSchedule 8 8 4 4 2 2 1 1 )
81
+ // And in 3D:
82
+ //(ImagePyramidSchedule 8 8 8 4 4 4 2 2 2 1 1 1 )
83
+ // You can specify any schedule, for example:
84
+ //(ImagePyramidSchedule 4 4 4 3 2 1 1 1 )
85
+ // Make sure that the number of elements equals the number
86
+ // of resolutions times the image dimension.
87
+
88
+ // ******************* Optimizer ****************************
89
+
90
+ // Maximum number of iterations in each resolution level:
91
+ // 200-500 works usually fine for rigid registration.
92
+ // For more robustness, you may increase this to 1000-2000.
93
+ (MaximumNumberOfIterations 250)
94
+
95
+ // The step size of the optimizer, in mm. By default the voxel size is used.
96
+ // which usually works well. In case of unusual high-resolution images
97
+ // (eg histology) it is necessary to increase this value a bit, to the size
98
+ // of the "smallest visible structure" in the image:
99
+ //(MaximumStepLength 1.0)
100
+
101
+ // **************** Image sampling **********************
102
+
103
+ // Number of spatial samples used to compute the mutual
104
+ // information (and its derivative) in each iteration.
105
+ // With an AdaptiveStochasticGradientDescent optimizer,
106
+ // in combination with the two options below, around 2000
107
+ // samples may already suffice.
108
+ (NumberOfSpatialSamples 2048)
109
+
110
+ // Refresh these spatial samples in every iteration, and select
111
+ // them randomly. See the manual for information on other sampling
112
+ // strategies.
113
+ (NewSamplesEveryIteration "true")
114
+ (ImageSampler "Random")
115
+
116
+ // ************* Interpolation and Resampling ****************
117
+
118
+ // Order of B-Spline interpolation used during registration/optimisation.
119
+ // It may improve accuracy if you set this to 3. Never use 0.
120
+ // An order of 1 gives linear interpolation. This is in most
121
+ // applications a good choice.
122
+ (BSplineInterpolationOrder 1)
123
+
124
+ // Order of B-Spline interpolation used for applying the final
125
+ // deformation.
126
+ // 3 gives good accuracy; recommended in most cases.
127
+ // 1 gives worse accuracy (linear interpolation)
128
+ // 0 gives worst accuracy, but is appropriate for binary images
129
+ // (masks, segmentations); equivalent to nearest neighbor interpolation.
130
+ (FinalBSplineInterpolationOrder 3)
131
+
132
+ //Default pixel value for pixels that come from outside the picture:
133
+ (DefaultPixelValue 0)
134
+
135
+ // Choose whether to generate the deformed moving image.
136
+ // You can save some time by setting this to false, if you are
137
+ // only interested in the final (nonrigidly) deformed moving image
138
+ // for example.
139
+ (WriteResultImage "false")
140
+
141
+ // The pixel type and format of the resulting deformed moving image
142
+ (ResultImagePixelType "short")
143
+ (ResultImageFormat "mha")
144
+ (WriteTransformParametersEachIteration "false")
145
+ (WriteTransformParametersEachResolution "false")
146
+ (WriteIterationInfo "false")
147
+
Generic_Rigid_BSpline/Prediction.yml ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ engine: elastix
6
+ parameter_maps:
7
+ - Parameters_Rigid.txt
8
+ - Parameters_BSpline.txt
9
+ models: []
10
+ iterations: 3000
11
+ outputs_criterions: None
12
+ Dataset:
13
+ groups_src:
14
+ Volume_0:
15
+ groups_dest:
16
+ Fixed:
17
+ transforms:
18
+ TensorCast:
19
+ dtype: float32
20
+ inverse: false
21
+ patch_transforms: None
22
+ is_input: true
23
+ Volume_1:
24
+ groups_dest:
25
+ Moving:
26
+ transforms:
27
+ TensorCast:
28
+ dtype: float32
29
+ inverse: false
30
+ patch_transforms: None
31
+ is_input: true
32
+ Volume_2:
33
+ groups_dest:
34
+ FixedMask:
35
+ transforms:
36
+ TensorCast:
37
+ dtype: float32
38
+ inverse: false
39
+ patch_transforms: None
40
+ is_input: true
41
+ Volume_3:
42
+ groups_dest:
43
+ MovingMask:
44
+ transforms:
45
+ TensorCast:
46
+ dtype: float32
47
+ inverse: false
48
+ patch_transforms: None
49
+ is_input: true
50
+ augmentations: None
51
+ Patch:
52
+ patch_size: None
53
+ overlap: None
54
+ mask: None
55
+ pad_value: None
56
+ extend_slice: 0
57
+ subset: None
58
+ filter: None
59
+ dataset_filenames:
60
+ - ./Dataset/:mha
61
+ use_cache: false
62
+ batch_size: 1
63
+ num_workers: None
64
+ pin_memory: false
65
+ prefetch_factor: None
66
+ persistent_workers: None
67
+ outputs_dataset:
68
+ MovedImage:
69
+ OutputDataset:
70
+ name_class: OutSameAsGroupDataset
71
+ before_reduction_transforms: None
72
+ after_reduction_transforms: None
73
+ final_transforms:
74
+ TensorCast:
75
+ dtype: float32
76
+ inverse: false
77
+ dataset_filename: Moved:mha
78
+ group: Moved
79
+ same_as_group: Volume_0:Fixed
80
+ patch_combine: None
81
+ inverse_transform: false
82
+ reduction: Mean
83
+ Mean: {}
84
+ DisplacementField:
85
+ OutputDataset:
86
+ name_class: OutSameAsGroupDataset
87
+ before_reduction_transforms: None
88
+ after_reduction_transforms: None
89
+ final_transforms:
90
+ TensorCast:
91
+ dtype: float32
92
+ inverse: false
93
+ dataset_filename: DVF:mha
94
+ group: DVF
95
+ same_as_group: Volume_0:Fixed
96
+ patch_combine: None
97
+ inverse_transform: false
98
+ reduction: Mean
99
+ Mean: {}
100
+ train_name: ImpactReg-Generic-Rigid-BSpline
101
+ manual_seed: 42
102
+ gpu_checkpoints: None
103
+ images_log: None
104
+ combine: Mean
105
+ autocast: false
106
+ data_log: None
Generic_Rigid_BSpline/app.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "Generic Rigid + BSpline",
3
+ "short_description": "Two-stage registration: rigid alignment followed by BSpline refinement.",
4
+ "description": "A combined registration strategy: first a rigid Euler transform corrects global misalignment, then a BSpline model captures localized anatomical deformations. Both stages use a multi-resolution pyramid, mutual information, and stochastic optimization for robust performance across a wide range of multimodal imaging scenarios.",
5
+ "task": "registration",
6
+ "tta": 0,
7
+ "mc_dropout": 0,
8
+ "models": [
9
+ "model.pt"
10
+ ],
11
+ "inputs": {
12
+ "Fixed": {
13
+ "display_name": "Fixed image",
14
+ "volume_type": "VOLUME",
15
+ "required": true
16
+ },
17
+ "Moving": {
18
+ "display_name": "Moving image",
19
+ "volume_type": "VOLUME",
20
+ "required": true
21
+ },
22
+ "FixedMask": {
23
+ "display_name": "Fixed mask (optional)",
24
+ "volume_type": "SEGMENTATION",
25
+ "required": false
26
+ },
27
+ "MovingMask": {
28
+ "display_name": "Moving mask (optional)",
29
+ "volume_type": "SEGMENTATION",
30
+ "required": false
31
+ }
32
+ },
33
+ "outputs": {
34
+ "MovedImage": {
35
+ "display_name": "Moved image",
36
+ "volume_type": "VOLUME",
37
+ "required": true
38
+ },
39
+ "DisplacementField": {
40
+ "display_name": "Displacement field",
41
+ "volume_type": "VOLUME",
42
+ "required": false
43
+ }
44
+ },
45
+ "inputs_evaluations": {
46
+ "Image": {
47
+ "Evaluation_with_images.yml": {
48
+ "FixedImage": {
49
+ "display_name": "Fixed image",
50
+ "volume_type": "VOLUME",
51
+ "required": true
52
+ },
53
+ "MovingImage": {
54
+ "display_name": "Moving image",
55
+ "volume_type": "VOLUME",
56
+ "required": true
57
+ },
58
+ "Mask": {
59
+ "display_name": "Evaluation mask",
60
+ "volume_type": "SEGMENTATION",
61
+ "required": false
62
+ }
63
+ }
64
+ },
65
+ "Segmentation": {
66
+ "Evaluation_with_seg.yml": {
67
+ "FixedSeg": {
68
+ "display_name": "Fixed segmentation",
69
+ "volume_type": "SEGMENTATION",
70
+ "required": true
71
+ },
72
+ "MovingSeg": {
73
+ "display_name": "Moving segmentation",
74
+ "volume_type": "SEGMENTATION",
75
+ "required": true
76
+ }
77
+ }
78
+ },
79
+ "Landmarks": {
80
+ "Evaluation_with_fid.yml": {
81
+ "FixedFid": {
82
+ "display_name": "Fixed landmarks",
83
+ "volume_type": "FIDUCIALS",
84
+ "required": true
85
+ },
86
+ "MovingFid": {
87
+ "display_name": "Moving landmarks",
88
+ "volume_type": "FIDUCIALS",
89
+ "required": true
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
Generic_Rigid_BSpline/install.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/model.pt ADDED
Binary file (1.25 kB). View file
 
ImpactReg/UncertaintyDVF.py DELETED
@@ -1,15 +0,0 @@
1
- from konfai.data.transform import Transform
2
- from konfai.utils.dataset import Attribute
3
- import torch
4
-
5
- class DVFToNorm(Transform):
6
-
7
- def __init__(self) -> None:
8
- super().__init__()
9
-
10
- def __call__(self, name: str, tensors: torch.Tensor, cache_attribute: Attribute) -> torch.Tensor:
11
- cache_attribute["Origin"] = cache_attribute.pop_np_array("Origin")[:tensors.dim()-2]
12
- cache_attribute["Spacing"] = cache_attribute.pop_np_array("Spacing")[:tensors.dim()-2]
13
- cache_attribute.pop("Direction")
14
- cache_attribute["Direction"] = torch.eye(tensors.dim()-2).flatten()
15
- return torch.linalg.norm(tensors, dim=-1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ImpactReg/app.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "display_name": "ImpactReg",
3
- "short_description": "",
4
- "description": "",
5
- "tta": 2,
6
- "mc_dropout": false
7
- }
 
 
 
 
 
 
 
 
MR_CT_HeadNeck/Evaluation_with_fid.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedFid:
4
+ targets_criterions:
5
+ MovingFid:
6
+ criterions_loader:
7
+ TRE: {}
8
+ Dataset:
9
+ groups_src:
10
+ Volume_0:
11
+ groups_dest:
12
+ FixedFid:
13
+ transforms: None
14
+ Reference_0:
15
+ groups_dest:
16
+ MovingFid:
17
+ transforms: None
18
+ subset: None
19
+ dataset_filenames:
20
+ - ./Dataset:mha
21
+ validation: None
22
+ train_name: ImpactReg
MR_CT_HeadNeck/Evaluation_with_images.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Evaluator:
2
+ metrics:
3
+ FixedImage:
4
+ targets_criterions:
5
+ MovingImage;Mask:
6
+ criterions_loader:
7
+ MAESaveMap:
8
+ reduction: mean
9
+ dataset: ./Evaluations/ImpactReg/Output:mha
10
+ group: MAE_map
11
+ Dataset:
12
+ groups_src:
13
+ Volume_0:
14
+ groups_dest:
15
+ FixedImage:
16
+ transforms:
17
+ TensorCast:
18
+ dtype: float32
19
+ Reference_0:
20
+ groups_dest:
21
+ MovingImage:
22
+ transforms:
23
+ TensorCast:
24
+ dtype: float32
25
+ Mask_0:
26
+ groups_dest:
27
+ Mask:
28
+ transforms:
29
+ TensorCast:
30
+ dtype: uint8
31
+ subset: None
32
+ dataset_filenames:
33
+ - ./Dataset:mha
34
+ validation: None
35
+ train_name: ImpactReg