VBoussot commited on
Commit
fd85e78
·
verified ·
1 Parent(s): 7874a3b

Add itk-impact ConvexAdam presets (coarse, fine, composite)

Browse files
ConvexAdam_Coarse/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
ConvexAdam_Coarse/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
ConvexAdam_Coarse/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
ConvexAdam_Coarse/Model.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 Valentin Boussot
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # SPDX-License-Identifier: Apache-2.0
16
+
17
+ """ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
18
+
19
+ Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
20
+ (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
21
+ so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
22
+ the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
23
+ the elastix binary:
24
+
25
+ (optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
26
+ -> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
27
+ -> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
28
+
29
+ The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
30
+ and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
31
+
32
+ NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
33
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
34
+ """
35
+
36
+ import itk
37
+ import numpy as np
38
+ import SimpleITK as sitk
39
+ import torch
40
+ import tqdm
41
+ from huggingface_hub import hf_hub_download
42
+ from konfai.network import network
43
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
44
+
45
+ DIM = 3
46
+ _IMAGE_F = itk.Image[itk.F, DIM]
47
+
48
+
49
+ def _coarse_registration_type():
50
+ """The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
51
+ cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
52
+ if cls is None:
53
+ raise RuntimeError(
54
+ "itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
55
+ "install a build with the ConvexAdam registration filters."
56
+ )
57
+ return cls[_IMAGE_F, _IMAGE_F]
58
+
59
+
60
+ def _fine_registration_type():
61
+ """The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
62
+ cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
63
+ if cls is None:
64
+ raise RuntimeError(
65
+ "itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
66
+ "install a build with the ConvexAdam registration filters."
67
+ )
68
+ return cls[_IMAGE_F, _IMAGE_F]
69
+
70
+
71
+ def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
72
+ """Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
73
+ itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
74
+ itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
75
+ itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
76
+ itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
77
+ return itk_image
78
+
79
+
80
+ def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
81
+ """Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
82
+ array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
83
+ sitk_field = sitk.GetImageFromArray(array, isVector=True)
84
+ sitk_field.CopyInformation(reference)
85
+ return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
86
+
87
+
88
+ def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
89
+ """Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
90
+ sitk_affine = sitk.AffineTransform(DIM)
91
+ sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
92
+ sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
93
+ sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
94
+ return sitk_affine
95
+
96
+
97
+ class ConvexAdamEngine:
98
+ """Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
99
+
100
+ The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
101
+ Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
102
+ filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ models: list[str],
108
+ voxel_size: list[float],
109
+ num_channels: int,
110
+ overlap: int,
111
+ layers_mask: list[bool],
112
+ mixed_precision: bool,
113
+ grid_spacing: int,
114
+ displacement_half_width: int,
115
+ iterations: int,
116
+ learning_rate: float,
117
+ regularization_weight: float,
118
+ grid_shrink: int,
119
+ distance: list[str],
120
+ layers_weight: list[float],
121
+ subset_features: list[int],
122
+ pca: list[int],
123
+ stages: list[str],
124
+ linear: bool,
125
+ linear_iterations: int,
126
+ seed: int,
127
+ ) -> None:
128
+ self._stages = stages
129
+ self._model_paths = self._download_models(models)
130
+ # Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
131
+ # from disk in C++, so build the list once and reuse it across both stages and every case.
132
+ self._configurations: "list[itk.ModelConfiguration] | None" = None
133
+ self._voxel_size = voxel_size
134
+ self._num_channels = num_channels
135
+ self._overlap = overlap
136
+ self._layers_mask = layers_mask
137
+ self._mixed_precision = mixed_precision
138
+ self._grid_spacing = grid_spacing
139
+ self._displacement_half_width = displacement_half_width
140
+ self._iterations = iterations
141
+ self._learning_rate = learning_rate
142
+ self._regularization_weight = regularization_weight
143
+ self._grid_shrink = grid_shrink
144
+ self._distance = distance
145
+ self._layers_weight = layers_weight
146
+ self._subset_features = subset_features
147
+ self._pca = pca
148
+ self._linear = linear
149
+ self._linear_iterations = linear_iterations
150
+ self._seed = seed
151
+
152
+ @staticmethod
153
+ def _download_models(models: list[str]) -> list[str]:
154
+ """Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
155
+ paths = []
156
+ for ref in models:
157
+ repo, filename = ref.split(":", 1)
158
+ paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
159
+ return paths
160
+
161
+ def _model_configurations(self) -> list["itk.ModelConfiguration"]:
162
+ """Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
163
+
164
+ Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
165
+ it is built lazily and cached. The coarse/fine filters copy each configuration by value in
166
+ ``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
167
+ ``shared_ptr`` — so a single build is reused everywhere without any reload.
168
+ """
169
+ if self._configurations is None:
170
+ self._configurations = [
171
+ itk.ModelConfiguration(
172
+ path,
173
+ DIM,
174
+ self._num_channels,
175
+ [0, 0, 0],
176
+ [float(v) for v in self._voxel_size],
177
+ self._overlap,
178
+ list(self._layers_mask),
179
+ self._mixed_precision,
180
+ )
181
+ for path in self._model_paths
182
+ ]
183
+ return self._configurations
184
+
185
+ def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
186
+ """Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
187
+ rigid = itk.VersorRigid3DTransform[itk.D].New()
188
+ initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
189
+ Transform=rigid, FixedImage=fixed, MovingImage=moving
190
+ )
191
+ initializer.MomentsOn()
192
+ initializer.InitializeTransform()
193
+
194
+ affine = itk.AffineTransform[itk.D, DIM].New()
195
+ affine.SetCenter(rigid.GetCenter())
196
+ affine.SetMatrix(rigid.GetMatrix())
197
+ affine.SetOffset(rigid.GetOffset())
198
+ levels = 3
199
+ metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
200
+ metric = metric_type.New()
201
+ metric.SetNumberOfHistogramBins(32)
202
+ optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
203
+ optimizer.SetNumberOfIterations(self._linear_iterations)
204
+ optimizer.SetLearningRate(1.0)
205
+ optimizer.SetMinimumStepLength(1e-5)
206
+ optimizer.SetRelaxationFactor(0.6)
207
+ scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
208
+ scales.SetMetric(metric)
209
+ optimizer.SetScalesEstimator(scales)
210
+ registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
211
+ FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
212
+ )
213
+ registration.SetNumberOfLevels(levels)
214
+ registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
215
+ registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
216
+ registration.InPlaceOn()
217
+ registration.Update()
218
+ return affine
219
+
220
+ def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
221
+ """ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
222
+ coarse = _coarse_registration_type().New()
223
+ coarse.SetFixedImage(fixed)
224
+ coarse.SetMovingImage(moving)
225
+ for configuration in self._model_configurations():
226
+ coarse.AddModelConfiguration(configuration)
227
+ coarse.SetGridSpacing(self._grid_spacing)
228
+ coarse.SetDisplacementHalfWidth(self._displacement_half_width)
229
+ coarse.SetDevice(device)
230
+ coarse.SetSeed(self._seed)
231
+ coarse.Update()
232
+ field = coarse.GetOutput()
233
+ field.DisconnectPipeline()
234
+ return field
235
+
236
+ def _fine(
237
+ self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
238
+ ) -> "itk.Image":
239
+ """Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
240
+ fine = _fine_registration_type().New()
241
+ fine.SetFixedImage(fixed)
242
+ fine.SetMovingImage(moving)
243
+ fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
244
+ for configuration in self._model_configurations():
245
+ fine.AddModelConfiguration(configuration)
246
+ fine.SetDistance(list(self._distance))
247
+ fine.SetLayersWeight([float(v) for v in self._layers_weight])
248
+ fine.SetSubsetFeatures([int(v) for v in self._subset_features])
249
+ fine.SetPCA([int(v) for v in self._pca])
250
+ fine.SetNumberOfIterations(self._iterations)
251
+ fine.SetLearningRate(self._learning_rate)
252
+ fine.SetRegularizationWeight(self._regularization_weight)
253
+ fine.SetGridShrinkFactor(self._grid_shrink)
254
+ fine.SetDevice(device)
255
+ fine.SetSeed(self._seed)
256
+
257
+ # Mirror KonfAI's informative bars: drive a tqdm over the Adam iterations from the metric trace so
258
+ # SlicerKonfAI (which parses the "N% done/total" progress line) shows real progress. The observer is
259
+ # best-effort — if the filter does not emit IterationEvent the bar simply fills on completion.
260
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
261
+
262
+ def _update(*_: object) -> None:
263
+ values = list(fine.GetMetricValuesPerIteration())
264
+ progress.n = min(len(values), self._iterations)
265
+ if values:
266
+ progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
267
+ progress.refresh()
268
+
269
+ try:
270
+ fine.AddObserver(itk.IterationEvent(), _update)
271
+ except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
272
+ pass
273
+ fine.Update()
274
+ progress.close()
275
+ field = fine.GetDisplacementField()
276
+ field.DisconnectPipeline()
277
+ return field
278
+
279
+ @staticmethod
280
+ def _zero_field(reference: "itk.Image") -> "itk.Image":
281
+ """An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
282
+ field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
283
+ field.CopyInformation(reference)
284
+ field.SetRegions(reference.GetLargestPossibleRegion())
285
+ field.Allocate()
286
+ zero = itk.Vector[itk.F, DIM]()
287
+ zero.Fill(0) # itk::Vector default ctor does not zero-initialise
288
+ field.FillBuffer(zero)
289
+ return field
290
+
291
+ def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
292
+ """Run the configured coarse/fine chain; each fine warm-starts from the running field.
293
+
294
+ ``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
295
+ coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
296
+ (the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
297
+ """
298
+ field: "itk.Image | None" = None
299
+ for stage in self._stages:
300
+ if stage == "coarse":
301
+ field = self._coarse(fixed, moving, device)
302
+ elif stage == "fine":
303
+ field = self._fine(fixed, moving, field, device)
304
+ else:
305
+ raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
306
+ return field
307
+
308
+ def register(
309
+ self,
310
+ fixed: sitk.Image,
311
+ moving: sitk.Image,
312
+ device_index: int,
313
+ fixed_mask: sitk.Image | None = None,
314
+ moving_mask: sitk.Image | None = None,
315
+ ) -> tuple[np.ndarray, np.ndarray]:
316
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
317
+ device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
318
+ fixed_itk = _sitk_to_itk(fixed)
319
+ moving_itk = _sitk_to_itk(moving)
320
+
321
+ # Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
322
+ affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
323
+ resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
324
+ Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
325
+ )
326
+ resampler.UseReferenceImageOn()
327
+ resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
328
+ resampler.Update()
329
+ moving_linear = resampler.GetOutput()
330
+
331
+ field = self._run_stages(fixed_itk, moving_linear, device)
332
+
333
+ # One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
334
+ # ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
335
+ # moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
336
+ chain = [_itk_affine_to_sitk(affine)]
337
+ if field is not None:
338
+ chain.append(_itk_field_to_sitk_transform(field, fixed))
339
+ composite = sitk.CompositeTransform(chain)
340
+ moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
341
+ dvf = sitk.TransformToDisplacementField(
342
+ composite,
343
+ sitk.sitkVectorFloat64,
344
+ fixed.GetSize(),
345
+ fixed.GetOrigin(),
346
+ fixed.GetSpacing(),
347
+ fixed.GetDirection(),
348
+ )
349
+ moved_np, _ = image_to_data(moved)
350
+ dvf_np, _ = image_to_data(dvf)
351
+ return moved_np, dvf_np
352
+
353
+
354
+ class ConvexAdamRegistration(torch.nn.Module):
355
+ """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
356
+
357
+ ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
358
+ the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
359
+ """
360
+
361
+ accepts_attributes = True
362
+
363
+ def __init__(self, engine: ConvexAdamEngine) -> None:
364
+ super().__init__()
365
+ self._engine = engine
366
+
367
+ def forward(
368
+ self,
369
+ fixed: torch.Tensor,
370
+ moving: torch.Tensor,
371
+ fixed_mask: torch.Tensor,
372
+ moving_mask: torch.Tensor,
373
+ attributes: list[list[Attribute]],
374
+ ) -> torch.Tensor:
375
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
376
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
377
+ # channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
378
+ fixed_attrs, moving_attrs, _, _ = attributes
379
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
380
+ combined = []
381
+ for b in range(fixed.shape[0]):
382
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
383
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
384
+ moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
385
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
+ return torch.stack(combined, dim=0).to(fixed.device)
387
+
388
+
389
+ class ChannelSelect(torch.nn.Module):
390
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
391
+
392
+ def __init__(self, start: int, stop: int) -> None:
393
+ super().__init__()
394
+ self._start = start
395
+ self._stop = stop
396
+
397
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
398
+ return tensor[:, self._start : self._stop]
399
+
400
+
401
+ class RegistrationNet(network.Network):
402
+ """Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
403
+ the mask branches 2/3 are accepted but unused by this engine).
404
+
405
+ Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
406
+ DIM-component displacement field, in mm). Geometry is attached by the predictor via
407
+ ``same_as_group: Volume_0:Fixed``.
408
+ """
409
+
410
+ def __init__(
411
+ self,
412
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
413
+ schedulers: dict[str, network.LRSchedulersLoader] = {
414
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
415
+ },
416
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
417
+ models: list[str] = [],
418
+ voxel_size: list[float] = [3.0, 3.0, 3.0],
419
+ num_channels: int = 1,
420
+ overlap: int = 2,
421
+ layers_mask: list[bool] = [True],
422
+ mixed_precision: bool = False,
423
+ grid_spacing: int = 4,
424
+ displacement_half_width: int = 6,
425
+ iterations: int = 150,
426
+ learning_rate: float = 0.2,
427
+ regularization_weight: float = 1.0,
428
+ grid_shrink: int = 4,
429
+ distance: list[str] = ["L1"],
430
+ layers_weight: list[float] = [1.0],
431
+ subset_features: list[int] = [32],
432
+ pca: list[int] = [0],
433
+ stages: list[str] = ["coarse", "fine"],
434
+ linear: bool = True,
435
+ linear_iterations: int = 200,
436
+ seed: int = 42,
437
+ ) -> None:
438
+ super().__init__(
439
+ in_channels=1,
440
+ optimizer=optimizer,
441
+ schedulers=schedulers,
442
+ outputs_criterions=outputs_criterions,
443
+ dim=3,
444
+ )
445
+ engine = ConvexAdamEngine(
446
+ models,
447
+ voxel_size,
448
+ num_channels,
449
+ overlap,
450
+ layers_mask,
451
+ mixed_precision,
452
+ grid_spacing,
453
+ displacement_half_width,
454
+ iterations,
455
+ learning_rate,
456
+ regularization_weight,
457
+ grid_shrink,
458
+ distance,
459
+ layers_weight,
460
+ subset_features,
461
+ pca,
462
+ stages,
463
+ linear,
464
+ linear_iterations,
465
+ seed,
466
+ )
467
+ self.add_module(
468
+ "Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
469
+ )
470
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
471
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
ConvexAdam_Coarse/Prediction.yml ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ models:
6
+ - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
7
+ voxel_size:
8
+ - 3.0
9
+ - 3.0
10
+ - 3.0
11
+ num_channels: 1
12
+ overlap: 2
13
+ layers_mask:
14
+ - true
15
+ mixed_precision: false
16
+ grid_spacing: 4
17
+ displacement_half_width: 6
18
+ iterations: 150
19
+ learning_rate: 0.2
20
+ regularization_weight: 1.0
21
+ grid_shrink: 4
22
+ distance:
23
+ - L1
24
+ layers_weight:
25
+ - 1.0
26
+ subset_features:
27
+ - 32
28
+ pca:
29
+ - 0
30
+ linear: true
31
+ linear_iterations: 200
32
+ seed: 42
33
+ outputs_criterions: None
34
+ stages:
35
+ - coarse
36
+ Dataset:
37
+ groups_src:
38
+ Volume_0:
39
+ groups_dest:
40
+ Fixed:
41
+ transforms:
42
+ TensorCast:
43
+ dtype: float32
44
+ inverse: false
45
+ patch_transforms: None
46
+ is_input: true
47
+ Volume_1:
48
+ groups_dest:
49
+ Moving:
50
+ transforms:
51
+ TensorCast:
52
+ dtype: float32
53
+ inverse: false
54
+ patch_transforms: None
55
+ is_input: true
56
+ Volume_2:
57
+ groups_dest:
58
+ FixedMask:
59
+ transforms:
60
+ TensorCast:
61
+ dtype: float32
62
+ inverse: false
63
+ patch_transforms: None
64
+ is_input: true
65
+ Volume_3:
66
+ groups_dest:
67
+ MovingMask:
68
+ transforms:
69
+ TensorCast:
70
+ dtype: float32
71
+ inverse: false
72
+ patch_transforms: None
73
+ is_input: true
74
+ augmentations:
75
+ DataAugmentation_0:
76
+ data_augmentations:
77
+ Flip:
78
+ f_prob:
79
+ - 0
80
+ - 0.5
81
+ - 0.5
82
+ vector_field: true
83
+ prob: 1
84
+ nb: 2
85
+ Patch:
86
+ patch_size: None
87
+ overlap: None
88
+ mask: None
89
+ pad_value: None
90
+ extend_slice: 0
91
+ subset: None
92
+ filter: None
93
+ dataset_filenames:
94
+ - ./Dataset/:mha
95
+ use_cache: false
96
+ batch_size: 1
97
+ num_workers: None
98
+ pin_memory: false
99
+ prefetch_factor: None
100
+ persistent_workers: None
101
+ outputs_dataset:
102
+ MovedImage:
103
+ OutputDataset:
104
+ name_class: OutSameAsGroupDataset
105
+ before_reduction_transforms: None
106
+ after_reduction_transforms: None
107
+ final_transforms:
108
+ TensorCast:
109
+ dtype: float32
110
+ inverse: false
111
+ dataset_filename: Moved:mha
112
+ group: Moved
113
+ same_as_group: Volume_0:Fixed
114
+ patch_combine: None
115
+ inverse_transform: false
116
+ reduction: Mean
117
+ Mean: {}
118
+ DisplacementField:
119
+ OutputDataset:
120
+ name_class: OutSameAsGroupDataset
121
+ before_reduction_transforms: None
122
+ after_reduction_transforms: None
123
+ final_transforms:
124
+ TensorCast:
125
+ dtype: float32
126
+ inverse: false
127
+ dataset_filename: DVF:mha
128
+ group: DVF
129
+ same_as_group: Volume_0:Fixed
130
+ patch_combine: None
131
+ inverse_transform: false
132
+ reduction: Mean
133
+ Mean: {}
134
+ train_name: ImpactReg-ConvexAdam-Coarse
135
+ manual_seed: 42
136
+ gpu_checkpoints: None
137
+ images_log: None
138
+ combine: Mean
139
+ autocast: false
140
+ data_log: None
ConvexAdam_Coarse/Uncertainty.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Norm: {}
16
+ StandardDeviation: {}
17
+ Save:
18
+ dataset: ./Uncertainties/ImpactReg/Output:mha
19
+ group: None
20
+ subset: None
21
+ dataset_filenames:
22
+ - ./Dataset:mha
23
+ validation: None
24
+ train_name: ImpactReg
ConvexAdam_Coarse/__pycache__/Model.cpython-314.pyc ADDED
Binary file (32.8 kB). View file
 
ConvexAdam_Coarse/app.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "ConvexAdam Coarse (MIND)",
3
+ "short_description": "Global coarse ConvexAdam initialization (whole-volume, IMPACT/MIND features).",
4
+ "description": "First stage of the ConvexAdam pipeline: an optional moments+affine linear pre-align followed by the ConvexAdam coarse coupled-convex initialization on the whole volume, driven by the IMPACT metric on MIND features. Produces a robust low-resolution displacement field on the fixed grid, meant to warm-start (and pre-resample the moving for) the fine stage.",
5
+ "task": "registration",
6
+ "tta": 3,
7
+ "mc_dropout": 0,
8
+ "models": [
9
+ "model.pt"
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
+ }
ConvexAdam_Coarse/model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:de99fbc36331ce674639acc774f52b4a2d0027f2f312d9d28669e831a0c4fd7e
3
+ size 1249
ConvexAdam_Coarse/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ itk-impact
ConvexAdam_Composite/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
ConvexAdam_Composite/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
ConvexAdam_Composite/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
ConvexAdam_Composite/Model.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 Valentin Boussot
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # SPDX-License-Identifier: Apache-2.0
16
+
17
+ """ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
18
+
19
+ Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
20
+ (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
21
+ so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
22
+ the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
23
+ the elastix binary:
24
+
25
+ (optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
26
+ -> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
27
+ -> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
28
+
29
+ The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
30
+ and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
31
+
32
+ NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
33
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
34
+ """
35
+
36
+ import itk
37
+ import numpy as np
38
+ import SimpleITK as sitk
39
+ import torch
40
+ import tqdm
41
+ from huggingface_hub import hf_hub_download
42
+ from konfai.network import network
43
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
44
+
45
+ DIM = 3
46
+ _IMAGE_F = itk.Image[itk.F, DIM]
47
+
48
+
49
+ def _coarse_registration_type():
50
+ """The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
51
+ cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
52
+ if cls is None:
53
+ raise RuntimeError(
54
+ "itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
55
+ "install a build with the ConvexAdam registration filters."
56
+ )
57
+ return cls[_IMAGE_F, _IMAGE_F]
58
+
59
+
60
+ def _fine_registration_type():
61
+ """The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
62
+ cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
63
+ if cls is None:
64
+ raise RuntimeError(
65
+ "itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
66
+ "install a build with the ConvexAdam registration filters."
67
+ )
68
+ return cls[_IMAGE_F, _IMAGE_F]
69
+
70
+
71
+ def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
72
+ """Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
73
+ itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
74
+ itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
75
+ itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
76
+ itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
77
+ return itk_image
78
+
79
+
80
+ def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
81
+ """Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
82
+ array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
83
+ sitk_field = sitk.GetImageFromArray(array, isVector=True)
84
+ sitk_field.CopyInformation(reference)
85
+ return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
86
+
87
+
88
+ def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
89
+ """Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
90
+ sitk_affine = sitk.AffineTransform(DIM)
91
+ sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
92
+ sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
93
+ sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
94
+ return sitk_affine
95
+
96
+
97
+ class ConvexAdamEngine:
98
+ """Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
99
+
100
+ The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
101
+ Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
102
+ filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ models: list[str],
108
+ voxel_size: list[float],
109
+ num_channels: int,
110
+ overlap: int,
111
+ layers_mask: list[bool],
112
+ mixed_precision: bool,
113
+ grid_spacing: int,
114
+ displacement_half_width: int,
115
+ iterations: int,
116
+ learning_rate: float,
117
+ regularization_weight: float,
118
+ grid_shrink: int,
119
+ distance: list[str],
120
+ layers_weight: list[float],
121
+ subset_features: list[int],
122
+ pca: list[int],
123
+ stages: list[str],
124
+ linear: bool,
125
+ linear_iterations: int,
126
+ seed: int,
127
+ ) -> None:
128
+ self._stages = stages
129
+ self._model_paths = self._download_models(models)
130
+ # Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
131
+ # from disk in C++, so build the list once and reuse it across both stages and every case.
132
+ self._configurations: "list[itk.ModelConfiguration] | None" = None
133
+ self._voxel_size = voxel_size
134
+ self._num_channels = num_channels
135
+ self._overlap = overlap
136
+ self._layers_mask = layers_mask
137
+ self._mixed_precision = mixed_precision
138
+ self._grid_spacing = grid_spacing
139
+ self._displacement_half_width = displacement_half_width
140
+ self._iterations = iterations
141
+ self._learning_rate = learning_rate
142
+ self._regularization_weight = regularization_weight
143
+ self._grid_shrink = grid_shrink
144
+ self._distance = distance
145
+ self._layers_weight = layers_weight
146
+ self._subset_features = subset_features
147
+ self._pca = pca
148
+ self._linear = linear
149
+ self._linear_iterations = linear_iterations
150
+ self._seed = seed
151
+
152
+ @staticmethod
153
+ def _download_models(models: list[str]) -> list[str]:
154
+ """Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
155
+ paths = []
156
+ for ref in models:
157
+ repo, filename = ref.split(":", 1)
158
+ paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
159
+ return paths
160
+
161
+ def _model_configurations(self) -> list["itk.ModelConfiguration"]:
162
+ """Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
163
+
164
+ Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
165
+ it is built lazily and cached. The coarse/fine filters copy each configuration by value in
166
+ ``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
167
+ ``shared_ptr`` — so a single build is reused everywhere without any reload.
168
+ """
169
+ if self._configurations is None:
170
+ self._configurations = [
171
+ itk.ModelConfiguration(
172
+ path,
173
+ DIM,
174
+ self._num_channels,
175
+ [0, 0, 0],
176
+ [float(v) for v in self._voxel_size],
177
+ self._overlap,
178
+ list(self._layers_mask),
179
+ self._mixed_precision,
180
+ )
181
+ for path in self._model_paths
182
+ ]
183
+ return self._configurations
184
+
185
+ def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
186
+ """Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
187
+ rigid = itk.VersorRigid3DTransform[itk.D].New()
188
+ initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
189
+ Transform=rigid, FixedImage=fixed, MovingImage=moving
190
+ )
191
+ initializer.MomentsOn()
192
+ initializer.InitializeTransform()
193
+
194
+ affine = itk.AffineTransform[itk.D, DIM].New()
195
+ affine.SetCenter(rigid.GetCenter())
196
+ affine.SetMatrix(rigid.GetMatrix())
197
+ affine.SetOffset(rigid.GetOffset())
198
+ levels = 3
199
+ metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
200
+ metric = metric_type.New()
201
+ metric.SetNumberOfHistogramBins(32)
202
+ optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
203
+ optimizer.SetNumberOfIterations(self._linear_iterations)
204
+ optimizer.SetLearningRate(1.0)
205
+ optimizer.SetMinimumStepLength(1e-5)
206
+ optimizer.SetRelaxationFactor(0.6)
207
+ scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
208
+ scales.SetMetric(metric)
209
+ optimizer.SetScalesEstimator(scales)
210
+ registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
211
+ FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
212
+ )
213
+ registration.SetNumberOfLevels(levels)
214
+ registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
215
+ registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
216
+ registration.InPlaceOn()
217
+ registration.Update()
218
+ return affine
219
+
220
+ def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
221
+ """ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
222
+ coarse = _coarse_registration_type().New()
223
+ coarse.SetFixedImage(fixed)
224
+ coarse.SetMovingImage(moving)
225
+ for configuration in self._model_configurations():
226
+ coarse.AddModelConfiguration(configuration)
227
+ coarse.SetGridSpacing(self._grid_spacing)
228
+ coarse.SetDisplacementHalfWidth(self._displacement_half_width)
229
+ coarse.SetDevice(device)
230
+ coarse.SetSeed(self._seed)
231
+ coarse.Update()
232
+ field = coarse.GetOutput()
233
+ field.DisconnectPipeline()
234
+ return field
235
+
236
+ def _fine(
237
+ self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
238
+ ) -> "itk.Image":
239
+ """Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
240
+ fine = _fine_registration_type().New()
241
+ fine.SetFixedImage(fixed)
242
+ fine.SetMovingImage(moving)
243
+ fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
244
+ for configuration in self._model_configurations():
245
+ fine.AddModelConfiguration(configuration)
246
+ fine.SetDistance(list(self._distance))
247
+ fine.SetLayersWeight([float(v) for v in self._layers_weight])
248
+ fine.SetSubsetFeatures([int(v) for v in self._subset_features])
249
+ fine.SetPCA([int(v) for v in self._pca])
250
+ fine.SetNumberOfIterations(self._iterations)
251
+ fine.SetLearningRate(self._learning_rate)
252
+ fine.SetRegularizationWeight(self._regularization_weight)
253
+ fine.SetGridShrinkFactor(self._grid_shrink)
254
+ fine.SetDevice(device)
255
+ fine.SetSeed(self._seed)
256
+
257
+ # Mirror KonfAI's informative bars: drive a tqdm over the Adam iterations from the metric trace so
258
+ # SlicerKonfAI (which parses the "N% done/total" progress line) shows real progress. The observer is
259
+ # best-effort — if the filter does not emit IterationEvent the bar simply fills on completion.
260
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
261
+
262
+ def _update(*_: object) -> None:
263
+ values = list(fine.GetMetricValuesPerIteration())
264
+ progress.n = min(len(values), self._iterations)
265
+ if values:
266
+ progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
267
+ progress.refresh()
268
+
269
+ try:
270
+ fine.AddObserver(itk.IterationEvent(), _update)
271
+ except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
272
+ pass
273
+ fine.Update()
274
+ progress.close()
275
+ field = fine.GetDisplacementField()
276
+ field.DisconnectPipeline()
277
+ return field
278
+
279
+ @staticmethod
280
+ def _zero_field(reference: "itk.Image") -> "itk.Image":
281
+ """An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
282
+ field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
283
+ field.CopyInformation(reference)
284
+ field.SetRegions(reference.GetLargestPossibleRegion())
285
+ field.Allocate()
286
+ zero = itk.Vector[itk.F, DIM]()
287
+ zero.Fill(0) # itk::Vector default ctor does not zero-initialise
288
+ field.FillBuffer(zero)
289
+ return field
290
+
291
+ def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
292
+ """Run the configured coarse/fine chain; each fine warm-starts from the running field.
293
+
294
+ ``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
295
+ coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
296
+ (the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
297
+ """
298
+ field: "itk.Image | None" = None
299
+ for stage in self._stages:
300
+ if stage == "coarse":
301
+ field = self._coarse(fixed, moving, device)
302
+ elif stage == "fine":
303
+ field = self._fine(fixed, moving, field, device)
304
+ else:
305
+ raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
306
+ return field
307
+
308
+ def register(
309
+ self,
310
+ fixed: sitk.Image,
311
+ moving: sitk.Image,
312
+ device_index: int,
313
+ fixed_mask: sitk.Image | None = None,
314
+ moving_mask: sitk.Image | None = None,
315
+ ) -> tuple[np.ndarray, np.ndarray]:
316
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
317
+ device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
318
+ fixed_itk = _sitk_to_itk(fixed)
319
+ moving_itk = _sitk_to_itk(moving)
320
+
321
+ # Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
322
+ affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
323
+ resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
324
+ Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
325
+ )
326
+ resampler.UseReferenceImageOn()
327
+ resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
328
+ resampler.Update()
329
+ moving_linear = resampler.GetOutput()
330
+
331
+ field = self._run_stages(fixed_itk, moving_linear, device)
332
+
333
+ # One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
334
+ # ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
335
+ # moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
336
+ chain = [_itk_affine_to_sitk(affine)]
337
+ if field is not None:
338
+ chain.append(_itk_field_to_sitk_transform(field, fixed))
339
+ composite = sitk.CompositeTransform(chain)
340
+ moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
341
+ dvf = sitk.TransformToDisplacementField(
342
+ composite,
343
+ sitk.sitkVectorFloat64,
344
+ fixed.GetSize(),
345
+ fixed.GetOrigin(),
346
+ fixed.GetSpacing(),
347
+ fixed.GetDirection(),
348
+ )
349
+ moved_np, _ = image_to_data(moved)
350
+ dvf_np, _ = image_to_data(dvf)
351
+ return moved_np, dvf_np
352
+
353
+
354
+ class ConvexAdamRegistration(torch.nn.Module):
355
+ """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
356
+
357
+ ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
358
+ the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
359
+ """
360
+
361
+ accepts_attributes = True
362
+
363
+ def __init__(self, engine: ConvexAdamEngine) -> None:
364
+ super().__init__()
365
+ self._engine = engine
366
+
367
+ def forward(
368
+ self,
369
+ fixed: torch.Tensor,
370
+ moving: torch.Tensor,
371
+ fixed_mask: torch.Tensor,
372
+ moving_mask: torch.Tensor,
373
+ attributes: list[list[Attribute]],
374
+ ) -> torch.Tensor:
375
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
376
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
377
+ # channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
378
+ fixed_attrs, moving_attrs, _, _ = attributes
379
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
380
+ combined = []
381
+ for b in range(fixed.shape[0]):
382
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
383
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
384
+ moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
385
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
+ return torch.stack(combined, dim=0).to(fixed.device)
387
+
388
+
389
+ class ChannelSelect(torch.nn.Module):
390
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
391
+
392
+ def __init__(self, start: int, stop: int) -> None:
393
+ super().__init__()
394
+ self._start = start
395
+ self._stop = stop
396
+
397
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
398
+ return tensor[:, self._start : self._stop]
399
+
400
+
401
+ class RegistrationNet(network.Network):
402
+ """Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
403
+ the mask branches 2/3 are accepted but unused by this engine).
404
+
405
+ Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
406
+ DIM-component displacement field, in mm). Geometry is attached by the predictor via
407
+ ``same_as_group: Volume_0:Fixed``.
408
+ """
409
+
410
+ def __init__(
411
+ self,
412
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
413
+ schedulers: dict[str, network.LRSchedulersLoader] = {
414
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
415
+ },
416
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
417
+ models: list[str] = [],
418
+ voxel_size: list[float] = [3.0, 3.0, 3.0],
419
+ num_channels: int = 1,
420
+ overlap: int = 2,
421
+ layers_mask: list[bool] = [True],
422
+ mixed_precision: bool = False,
423
+ grid_spacing: int = 4,
424
+ displacement_half_width: int = 6,
425
+ iterations: int = 150,
426
+ learning_rate: float = 0.2,
427
+ regularization_weight: float = 1.0,
428
+ grid_shrink: int = 4,
429
+ distance: list[str] = ["L1"],
430
+ layers_weight: list[float] = [1.0],
431
+ subset_features: list[int] = [32],
432
+ pca: list[int] = [0],
433
+ stages: list[str] = ["coarse", "fine"],
434
+ linear: bool = True,
435
+ linear_iterations: int = 200,
436
+ seed: int = 42,
437
+ ) -> None:
438
+ super().__init__(
439
+ in_channels=1,
440
+ optimizer=optimizer,
441
+ schedulers=schedulers,
442
+ outputs_criterions=outputs_criterions,
443
+ dim=3,
444
+ )
445
+ engine = ConvexAdamEngine(
446
+ models,
447
+ voxel_size,
448
+ num_channels,
449
+ overlap,
450
+ layers_mask,
451
+ mixed_precision,
452
+ grid_spacing,
453
+ displacement_half_width,
454
+ iterations,
455
+ learning_rate,
456
+ regularization_weight,
457
+ grid_shrink,
458
+ distance,
459
+ layers_weight,
460
+ subset_features,
461
+ pca,
462
+ stages,
463
+ linear,
464
+ linear_iterations,
465
+ seed,
466
+ )
467
+ self.add_module(
468
+ "Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
469
+ )
470
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
471
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
ConvexAdam_Composite/Prediction.yml ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ models:
6
+ - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
7
+ voxel_size:
8
+ - 3.0
9
+ - 3.0
10
+ - 3.0
11
+ num_channels: 1
12
+ overlap: 2
13
+ layers_mask:
14
+ - true
15
+ mixed_precision: false
16
+ grid_spacing: 4
17
+ displacement_half_width: 6
18
+ iterations: 150
19
+ learning_rate: 0.2
20
+ regularization_weight: 1.0
21
+ grid_shrink: 4
22
+ distance:
23
+ - L1
24
+ layers_weight:
25
+ - 1.0
26
+ subset_features:
27
+ - 32
28
+ pca:
29
+ - 0
30
+ linear: true
31
+ linear_iterations: 200
32
+ seed: 42
33
+ outputs_criterions: None
34
+ stages:
35
+ - coarse
36
+ - fine
37
+ Dataset:
38
+ groups_src:
39
+ Volume_0:
40
+ groups_dest:
41
+ Fixed:
42
+ transforms:
43
+ TensorCast:
44
+ dtype: float32
45
+ inverse: false
46
+ patch_transforms: None
47
+ is_input: true
48
+ Volume_1:
49
+ groups_dest:
50
+ Moving:
51
+ transforms:
52
+ TensorCast:
53
+ dtype: float32
54
+ inverse: false
55
+ patch_transforms: None
56
+ is_input: true
57
+ Volume_2:
58
+ groups_dest:
59
+ FixedMask:
60
+ transforms:
61
+ TensorCast:
62
+ dtype: float32
63
+ inverse: false
64
+ patch_transforms: None
65
+ is_input: true
66
+ Volume_3:
67
+ groups_dest:
68
+ MovingMask:
69
+ transforms:
70
+ TensorCast:
71
+ dtype: float32
72
+ inverse: false
73
+ patch_transforms: None
74
+ is_input: true
75
+ augmentations:
76
+ DataAugmentation_0:
77
+ data_augmentations:
78
+ Flip:
79
+ f_prob:
80
+ - 0
81
+ - 0.5
82
+ - 0.5
83
+ vector_field: true
84
+ prob: 1
85
+ nb: 2
86
+ Patch:
87
+ patch_size: None
88
+ overlap: None
89
+ mask: None
90
+ pad_value: None
91
+ extend_slice: 0
92
+ subset: None
93
+ filter: None
94
+ dataset_filenames:
95
+ - ./Dataset/:mha
96
+ use_cache: false
97
+ batch_size: 1
98
+ num_workers: None
99
+ pin_memory: false
100
+ prefetch_factor: None
101
+ persistent_workers: None
102
+ outputs_dataset:
103
+ MovedImage:
104
+ OutputDataset:
105
+ name_class: OutSameAsGroupDataset
106
+ before_reduction_transforms: None
107
+ after_reduction_transforms: None
108
+ final_transforms:
109
+ TensorCast:
110
+ dtype: float32
111
+ inverse: false
112
+ dataset_filename: Moved:mha
113
+ group: Moved
114
+ same_as_group: Volume_0:Fixed
115
+ patch_combine: None
116
+ inverse_transform: false
117
+ reduction: Mean
118
+ Mean: {}
119
+ DisplacementField:
120
+ OutputDataset:
121
+ name_class: OutSameAsGroupDataset
122
+ before_reduction_transforms: None
123
+ after_reduction_transforms: None
124
+ final_transforms:
125
+ TensorCast:
126
+ dtype: float32
127
+ inverse: false
128
+ dataset_filename: DVF:mha
129
+ group: DVF
130
+ same_as_group: Volume_0:Fixed
131
+ patch_combine: None
132
+ inverse_transform: false
133
+ reduction: Mean
134
+ Mean: {}
135
+ train_name: ImpactReg-ConvexAdam-Composite
136
+ manual_seed: 42
137
+ gpu_checkpoints: None
138
+ images_log: None
139
+ combine: Mean
140
+ autocast: false
141
+ data_log: None
ConvexAdam_Composite/Uncertainty.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Norm: {}
16
+ StandardDeviation: {}
17
+ Save:
18
+ dataset: ./Uncertainties/ImpactReg/Output:mha
19
+ group: None
20
+ subset: None
21
+ dataset_filenames:
22
+ - ./Dataset:mha
23
+ validation: None
24
+ train_name: ImpactReg
ConvexAdam_Composite/__pycache__/Model.cpython-314.pyc ADDED
Binary file (32.8 kB). View file
 
ConvexAdam_Composite/app.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "ConvexAdam (MIND)",
3
+ "short_description": "Coarse + fine ConvexAdam registration in one app (IMPACT/MIND features).",
4
+ "description": "The full ConvexAdam pipeline chained in one app: optional moments+affine linear pre-align, then a ConvexAdam coarse coupled-convex initialization, then an Adam instance-optimisation refinement, all driven by the IMPACT metric on MIND features. Produces the moved image and the displacement field on the fixed grid.",
5
+ "task": "registration",
6
+ "tta": 3,
7
+ "mc_dropout": 0,
8
+ "models": [
9
+ "model.pt"
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
+ }
ConvexAdam_Composite/model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:de99fbc36331ce674639acc774f52b4a2d0027f2f312d9d28669e831a0c4fd7e
3
+ size 1249
ConvexAdam_Composite/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ itk-impact
ConvexAdam_Fine/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
ConvexAdam_Fine/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
ConvexAdam_Fine/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
ConvexAdam_Fine/Model.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 Valentin Boussot
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # SPDX-License-Identifier: Apache-2.0
16
+
17
+ """ConvexAdam (itk-impact) registration as a self-contained KonfAI model.
18
+
19
+ Same idiomatic ``add_module`` graph and the same output contract as the elastix preset
20
+ (``MovedImage`` + ``DisplacementField`` on the FIXED grid, split by two ``ChannelSelect``),
21
+ so the orchestrator / app.json / ensemble / uncertainty are unchanged. The engine here is
22
+ the native, in-memory itk-impact ConvexAdam pipeline (``pip install itk-impact``) instead of
23
+ the elastix binary:
24
+
25
+ (optional) moments + affine Mattes-MI [ITKv4 linear pre-align]
26
+ -> ImpactCoarseRegistration [coupled-convex init, IMPACT features]
27
+ -> ImpactFineRegistration [Adam instance optimisation, IMPACT features]
28
+
29
+ The IMPACT feature models (e.g. MIND) are TorchScript ``.pt`` files fetched from Hugging Face
30
+ and wrapped as ``itk.ModelConfiguration`` — the same models the elastix presets use.
31
+
32
+ NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine relies on
33
+ runtime-evaluated annotations (``get_origin``); PEP 563 stringized annotations break binding.
34
+ """
35
+
36
+ import itk
37
+ import numpy as np
38
+ import SimpleITK as sitk
39
+ import torch
40
+ import tqdm
41
+ from huggingface_hub import hf_hub_download
42
+ from konfai.network import network
43
+ from konfai.utils.dataset import Attribute, data_to_image, image_to_data
44
+
45
+ DIM = 3
46
+ _IMAGE_F = itk.Image[itk.F, DIM]
47
+
48
+
49
+ def _coarse_registration_type():
50
+ """The coupled-convex initializer, tolerant to the two names the wrapping has shipped under."""
51
+ cls = getattr(itk, "ImpactCoarseRegistration", None) or getattr(itk, "ImpactConvexAdamInitializer", None)
52
+ if cls is None:
53
+ raise RuntimeError(
54
+ "itk-impact does not expose ImpactCoarseRegistration / ImpactConvexAdamInitializer; "
55
+ "install a build with the ConvexAdam registration filters."
56
+ )
57
+ return cls[_IMAGE_F, _IMAGE_F]
58
+
59
+
60
+ def _fine_registration_type():
61
+ """The Adam instance-optimisation stage, tolerant to the two names the wrapping has shipped under."""
62
+ cls = getattr(itk, "ImpactFineRegistration", None) or getattr(itk, "ImpactTorchAdamRegistration", None)
63
+ if cls is None:
64
+ raise RuntimeError(
65
+ "itk-impact does not expose ImpactFineRegistration / ImpactTorchAdamRegistration; "
66
+ "install a build with the ConvexAdam registration filters."
67
+ )
68
+ return cls[_IMAGE_F, _IMAGE_F]
69
+
70
+
71
+ def _sitk_to_itk(image: sitk.Image) -> "itk.Image":
72
+ """Copy a scalar SimpleITK image (with its geometry) into an ``itk.Image[F, 3]``."""
73
+ itk_image = itk.image_from_array(sitk.GetArrayFromImage(image).astype(np.float32))
74
+ itk_image.SetOrigin([float(v) for v in image.GetOrigin()])
75
+ itk_image.SetSpacing([float(v) for v in image.GetSpacing()])
76
+ itk_image.SetDirection(itk.matrix_from_array(np.asarray(image.GetDirection(), dtype=float).reshape(DIM, DIM)))
77
+ return itk_image
78
+
79
+
80
+ def _itk_field_to_sitk_transform(field: "itk.Image", reference: sitk.Image) -> sitk.Transform:
81
+ """Wrap an itk displacement field (on the fixed grid) as a SimpleITK ``DisplacementFieldTransform``."""
82
+ array = itk.array_from_image(field).astype(np.float64) # [Z, Y, X, 3]
83
+ sitk_field = sitk.GetImageFromArray(array, isVector=True)
84
+ sitk_field.CopyInformation(reference)
85
+ return sitk.DisplacementFieldTransform(sitk.Cast(sitk_field, sitk.sitkVectorFloat64))
86
+
87
+
88
+ def _itk_affine_to_sitk(affine: "itk.AffineTransform") -> sitk.AffineTransform:
89
+ """Convert an ``itk.AffineTransform[D, 3]`` into a SimpleITK ``AffineTransform`` (same LPS convention)."""
90
+ sitk_affine = sitk.AffineTransform(DIM)
91
+ sitk_affine.SetMatrix([float(v) for v in itk.array_from_matrix(affine.GetMatrix()).flatten()])
92
+ sitk_affine.SetTranslation([float(v) for v in affine.GetTranslation()])
93
+ sitk_affine.SetCenter([float(v) for v in affine.GetCenter()])
94
+ return sitk_affine
95
+
96
+
97
+ class ConvexAdamEngine:
98
+ """Register a fixed/moving pair with the itk-impact ConvexAdam pipeline; return (moved, dvf) on the fixed grid.
99
+
100
+ The IMPACT feature models are downloaded once (``repo:filename`` on Hugging Face) and reused across cases.
101
+ Masks are accepted for signature compatibility with the elastix engine but ignored: the ConvexAdam
102
+ filters optimise over the whole image (no mask API is exposed by the coarse/fine stages).
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ models: list[str],
108
+ voxel_size: list[float],
109
+ num_channels: int,
110
+ overlap: int,
111
+ layers_mask: list[bool],
112
+ mixed_precision: bool,
113
+ grid_spacing: int,
114
+ displacement_half_width: int,
115
+ iterations: int,
116
+ learning_rate: float,
117
+ regularization_weight: float,
118
+ grid_shrink: int,
119
+ distance: list[str],
120
+ layers_weight: list[float],
121
+ subset_features: list[int],
122
+ pca: list[int],
123
+ stages: list[str],
124
+ linear: bool,
125
+ linear_iterations: int,
126
+ seed: int,
127
+ ) -> None:
128
+ self._stages = stages
129
+ self._model_paths = self._download_models(models)
130
+ # Built lazily and cached: constructing an itk.ModelConfiguration loads the TorchScript model
131
+ # from disk in C++, so build the list once and reuse it across both stages and every case.
132
+ self._configurations: "list[itk.ModelConfiguration] | None" = None
133
+ self._voxel_size = voxel_size
134
+ self._num_channels = num_channels
135
+ self._overlap = overlap
136
+ self._layers_mask = layers_mask
137
+ self._mixed_precision = mixed_precision
138
+ self._grid_spacing = grid_spacing
139
+ self._displacement_half_width = displacement_half_width
140
+ self._iterations = iterations
141
+ self._learning_rate = learning_rate
142
+ self._regularization_weight = regularization_weight
143
+ self._grid_shrink = grid_shrink
144
+ self._distance = distance
145
+ self._layers_weight = layers_weight
146
+ self._subset_features = subset_features
147
+ self._pca = pca
148
+ self._linear = linear
149
+ self._linear_iterations = linear_iterations
150
+ self._seed = seed
151
+
152
+ @staticmethod
153
+ def _download_models(models: list[str]) -> list[str]:
154
+ """Fetch the TorchScript feature models (``repo:filename``); return their local paths."""
155
+ paths = []
156
+ for ref in models:
157
+ repo, filename = ref.split(":", 1)
158
+ paths.append(str(hf_hub_download(repo_id=repo, filename=filename, repo_type="model"))) # nosec B615
159
+ return paths
160
+
161
+ def _model_configurations(self) -> list["itk.ModelConfiguration"]:
162
+ """Build one ``ModelConfiguration`` per feature model once, then reuse it across stages and cases.
163
+
164
+ Constructing an ``itk.ModelConfiguration`` loads the TorchScript module from disk on the C++ side, so
165
+ it is built lazily and cached. The coarse/fine filters copy each configuration by value in
166
+ ``AddModelConfiguration`` and the copy shares the loaded module through the configuration's internal
167
+ ``shared_ptr`` — so a single build is reused everywhere without any reload.
168
+ """
169
+ if self._configurations is None:
170
+ self._configurations = [
171
+ itk.ModelConfiguration(
172
+ path,
173
+ DIM,
174
+ self._num_channels,
175
+ [0, 0, 0],
176
+ [float(v) for v in self._voxel_size],
177
+ self._overlap,
178
+ list(self._layers_mask),
179
+ self._mixed_precision,
180
+ )
181
+ for path in self._model_paths
182
+ ]
183
+ return self._configurations
184
+
185
+ def _linear_align(self, fixed: "itk.Image", moving: "itk.Image") -> "itk.AffineTransform":
186
+ """Moments-initialised rigid + affine (Mattes MI), mapping fixed -> moving physical points."""
187
+ rigid = itk.VersorRigid3DTransform[itk.D].New()
188
+ initializer = itk.CenteredTransformInitializer[itk.VersorRigid3DTransform[itk.D], _IMAGE_F, _IMAGE_F].New(
189
+ Transform=rigid, FixedImage=fixed, MovingImage=moving
190
+ )
191
+ initializer.MomentsOn()
192
+ initializer.InitializeTransform()
193
+
194
+ affine = itk.AffineTransform[itk.D, DIM].New()
195
+ affine.SetCenter(rigid.GetCenter())
196
+ affine.SetMatrix(rigid.GetMatrix())
197
+ affine.SetOffset(rigid.GetOffset())
198
+ levels = 3
199
+ metric_type = itk.MattesMutualInformationImageToImageMetricv4[_IMAGE_F, _IMAGE_F]
200
+ metric = metric_type.New()
201
+ metric.SetNumberOfHistogramBins(32)
202
+ optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
203
+ optimizer.SetNumberOfIterations(self._linear_iterations)
204
+ optimizer.SetLearningRate(1.0)
205
+ optimizer.SetMinimumStepLength(1e-5)
206
+ optimizer.SetRelaxationFactor(0.6)
207
+ scales = itk.RegistrationParameterScalesFromPhysicalShift[metric_type].New()
208
+ scales.SetMetric(metric)
209
+ optimizer.SetScalesEstimator(scales)
210
+ registration = itk.ImageRegistrationMethodv4[_IMAGE_F, _IMAGE_F].New(
211
+ FixedImage=fixed, MovingImage=moving, Metric=metric, Optimizer=optimizer, InitialTransform=affine
212
+ )
213
+ registration.SetNumberOfLevels(levels)
214
+ registration.SetShrinkFactorsPerLevel([2 ** (levels - 1 - i) for i in range(levels)])
215
+ registration.SetSmoothingSigmasPerLevel([float(levels - 1 - i) for i in range(levels)])
216
+ registration.InPlaceOn()
217
+ registration.Update()
218
+ return affine
219
+
220
+ def _coarse(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image":
221
+ """ConvexAdam coarse coupled-convex initializer -> robust low-resolution field on the fixed grid."""
222
+ coarse = _coarse_registration_type().New()
223
+ coarse.SetFixedImage(fixed)
224
+ coarse.SetMovingImage(moving)
225
+ for configuration in self._model_configurations():
226
+ coarse.AddModelConfiguration(configuration)
227
+ coarse.SetGridSpacing(self._grid_spacing)
228
+ coarse.SetDisplacementHalfWidth(self._displacement_half_width)
229
+ coarse.SetDevice(device)
230
+ coarse.SetSeed(self._seed)
231
+ coarse.Update()
232
+ field = coarse.GetOutput()
233
+ field.DisconnectPipeline()
234
+ return field
235
+
236
+ def _fine(
237
+ self, fixed: "itk.Image", moving: "itk.Image", initial_field: "itk.Image | None", device: str
238
+ ) -> "itk.Image":
239
+ """Adam instance-optimisation refinement, warm-started from ``initial_field`` (zero if none)."""
240
+ fine = _fine_registration_type().New()
241
+ fine.SetFixedImage(fixed)
242
+ fine.SetMovingImage(moving)
243
+ fine.SetInitialDisplacementField(initial_field if initial_field is not None else self._zero_field(fixed))
244
+ for configuration in self._model_configurations():
245
+ fine.AddModelConfiguration(configuration)
246
+ fine.SetDistance(list(self._distance))
247
+ fine.SetLayersWeight([float(v) for v in self._layers_weight])
248
+ fine.SetSubsetFeatures([int(v) for v in self._subset_features])
249
+ fine.SetPCA([int(v) for v in self._pca])
250
+ fine.SetNumberOfIterations(self._iterations)
251
+ fine.SetLearningRate(self._learning_rate)
252
+ fine.SetRegularizationWeight(self._regularization_weight)
253
+ fine.SetGridShrinkFactor(self._grid_shrink)
254
+ fine.SetDevice(device)
255
+ fine.SetSeed(self._seed)
256
+
257
+ # Mirror KonfAI's informative bars: drive a tqdm over the Adam iterations from the metric trace so
258
+ # SlicerKonfAI (which parses the "N% done/total" progress line) shows real progress. The observer is
259
+ # best-effort — if the filter does not emit IterationEvent the bar simply fills on completion.
260
+ progress = tqdm.tqdm(total=self._iterations or None, desc="Registration", ncols=0, leave=True)
261
+
262
+ def _update(*_: object) -> None:
263
+ values = list(fine.GetMetricValuesPerIteration())
264
+ progress.n = min(len(values), self._iterations)
265
+ if values:
266
+ progress.set_description(f"Registration : iter {len(values)} | metric {float(values[-1]):.4f}")
267
+ progress.refresh()
268
+
269
+ try:
270
+ fine.AddObserver(itk.IterationEvent(), _update)
271
+ except Exception: # nosec B110 - progress is best-effort; never fail a run over the bar
272
+ pass
273
+ fine.Update()
274
+ progress.close()
275
+ field = fine.GetDisplacementField()
276
+ field.DisconnectPipeline()
277
+ return field
278
+
279
+ @staticmethod
280
+ def _zero_field(reference: "itk.Image") -> "itk.Image":
281
+ """An all-zero displacement field on ``reference``'s grid (identity warm-start for a lone fine stage)."""
282
+ field = itk.Image[itk.Vector[itk.F, DIM], DIM].New()
283
+ field.CopyInformation(reference)
284
+ field.SetRegions(reference.GetLargestPossibleRegion())
285
+ field.Allocate()
286
+ zero = itk.Vector[itk.F, DIM]()
287
+ zero.Fill(0) # itk::Vector default ctor does not zero-initialise
288
+ field.FillBuffer(zero)
289
+ return field
290
+
291
+ def _run_stages(self, fixed: "itk.Image", moving: "itk.Image", device: str) -> "itk.Image | None":
292
+ """Run the configured coarse/fine chain; each fine warm-starts from the running field.
293
+
294
+ ``coarse`` produces a field from scratch; ``fine`` refines the running field. So ``['coarse']`` is a
295
+ coarse-only app, ``['fine']`` a fine-only app (zero warm-start), and ``['coarse', 'fine']`` chains both
296
+ (the composite, as before). Returns None when no deformable stage runs (e.g. a linear-only chain).
297
+ """
298
+ field: "itk.Image | None" = None
299
+ for stage in self._stages:
300
+ if stage == "coarse":
301
+ field = self._coarse(fixed, moving, device)
302
+ elif stage == "fine":
303
+ field = self._fine(fixed, moving, field, device)
304
+ else:
305
+ raise ValueError(f"Unknown registration stage '{stage}' (expected 'coarse' or 'fine').")
306
+ return field
307
+
308
+ def register(
309
+ self,
310
+ fixed: sitk.Image,
311
+ moving: sitk.Image,
312
+ device_index: int,
313
+ fixed_mask: sitk.Image | None = None,
314
+ moving_mask: sitk.Image | None = None,
315
+ ) -> tuple[np.ndarray, np.ndarray]:
316
+ """Register ``moving`` onto ``fixed``; return (moved, dvf) as channel-first arrays on the fixed grid."""
317
+ device = f"cuda:{device_index}" if device_index >= 0 else "cpu"
318
+ fixed_itk = _sitk_to_itk(fixed)
319
+ moving_itk = _sitk_to_itk(moving)
320
+
321
+ # Optional linear pre-align: resample the moving onto the fixed grid so the deformable stage starts close.
322
+ affine = self._linear_align(fixed_itk, moving_itk) if self._linear else itk.AffineTransform[itk.D, DIM].New()
323
+ resampler = itk.ResampleImageFilter[_IMAGE_F, _IMAGE_F].New(
324
+ Input=moving_itk, ReferenceImage=fixed_itk, Transform=affine
325
+ )
326
+ resampler.UseReferenceImageOn()
327
+ resampler.SetInterpolator(itk.LinearInterpolateImageFunction[_IMAGE_F, itk.D].New())
328
+ resampler.Update()
329
+ moving_linear = resampler.GetOutput()
330
+
331
+ field = self._run_stages(fixed_itk, moving_linear, device)
332
+
333
+ # One transform on the fixed grid = affine then deformable, so the returned DVF/transform warps the
334
+ # ORIGINAL moving. SimpleITK applies the last-added transform first, so [affine, deformable] gives
335
+ # moved(p) = moving(affine(deformable(p))). A linear-only chain (field is None) yields the affine alone.
336
+ chain = [_itk_affine_to_sitk(affine)]
337
+ if field is not None:
338
+ chain.append(_itk_field_to_sitk_transform(field, fixed))
339
+ composite = sitk.CompositeTransform(chain)
340
+ moved = sitk.Resample(moving, fixed, composite, sitk.sitkLinear, 0.0, moving.GetPixelID())
341
+ dvf = sitk.TransformToDisplacementField(
342
+ composite,
343
+ sitk.sitkVectorFloat64,
344
+ fixed.GetSize(),
345
+ fixed.GetOrigin(),
346
+ fixed.GetSpacing(),
347
+ fixed.GetDirection(),
348
+ )
349
+ moved_np, _ = image_to_data(moved)
350
+ dvf_np, _ = image_to_data(dvf)
351
+ return moved_np, dvf_np
352
+
353
+
354
+ class ConvexAdamRegistration(torch.nn.Module):
355
+ """Graph module: (fixed, moving) tensors + their geometry -> moved image + DVF on the fixed grid.
356
+
357
+ ``accepts_attributes = True`` opts this module into receiving the per-branch ``Attribute`` list alongside
358
+ the tensors (same convention as ``CriterionWithAttribute``); registration needs the physical geometry.
359
+ """
360
+
361
+ accepts_attributes = True
362
+
363
+ def __init__(self, engine: ConvexAdamEngine) -> None:
364
+ super().__init__()
365
+ self._engine = engine
366
+
367
+ def forward(
368
+ self,
369
+ fixed: torch.Tensor,
370
+ moving: torch.Tensor,
371
+ fixed_mask: torch.Tensor,
372
+ moving_mask: torch.Tensor,
373
+ attributes: list[list[Attribute]],
374
+ ) -> torch.Tensor:
375
+ # attributes = [fixed, moving, fixed_mask, moving_mask] branch attrs; each a list[Attribute] over the batch.
376
+ # Returns, per sample, the moved image (1 channel) channel-stacked with the displacement field (DIM
377
+ # channels); downstream ChannelSelect modules split them. Masks are ignored by the ConvexAdam engine.
378
+ fixed_attrs, moving_attrs, _, _ = attributes
379
+ device_index = fixed.device.index if fixed.device.type == "cuda" else -1
380
+ combined = []
381
+ for b in range(fixed.shape[0]):
382
+ fixed_img = data_to_image(fixed[b].detach().cpu().numpy(), fixed_attrs[b])
383
+ moving_img = data_to_image(moving[b].detach().cpu().numpy(), moving_attrs[b])
384
+ moved_np, dvf_np = self._engine.register(fixed_img, moving_img, device_index)
385
+ combined.append(torch.from_numpy(np.concatenate([moved_np, dvf_np], axis=0)))
386
+ return torch.stack(combined, dim=0).to(fixed.device)
387
+
388
+
389
+ class ChannelSelect(torch.nn.Module):
390
+ """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF)."""
391
+
392
+ def __init__(self, start: int, stop: int) -> None:
393
+ super().__init__()
394
+ self._start = start
395
+ self._stop = stop
396
+
397
+ def forward(self, tensor: torch.Tensor) -> torch.Tensor:
398
+ return tensor[:, self._start : self._stop]
399
+
400
+
401
+ class RegistrationNet(network.Network):
402
+ """Pairwise ConvexAdam registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1;
403
+ the mask branches 2/3 are accepted but unused by this engine).
404
+
405
+ Outputs on the fixed grid: ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` (the
406
+ DIM-component displacement field, in mm). Geometry is attached by the predictor via
407
+ ``same_as_group: Volume_0:Fixed``.
408
+ """
409
+
410
+ def __init__(
411
+ self,
412
+ optimizer: network.OptimizerLoader = network.OptimizerLoader(),
413
+ schedulers: dict[str, network.LRSchedulersLoader] = {
414
+ "default:ReduceLROnPlateau": network.LRSchedulersLoader(0)
415
+ },
416
+ outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()},
417
+ models: list[str] = [],
418
+ voxel_size: list[float] = [3.0, 3.0, 3.0],
419
+ num_channels: int = 1,
420
+ overlap: int = 2,
421
+ layers_mask: list[bool] = [True],
422
+ mixed_precision: bool = False,
423
+ grid_spacing: int = 4,
424
+ displacement_half_width: int = 6,
425
+ iterations: int = 150,
426
+ learning_rate: float = 0.2,
427
+ regularization_weight: float = 1.0,
428
+ grid_shrink: int = 4,
429
+ distance: list[str] = ["L1"],
430
+ layers_weight: list[float] = [1.0],
431
+ subset_features: list[int] = [32],
432
+ pca: list[int] = [0],
433
+ stages: list[str] = ["coarse", "fine"],
434
+ linear: bool = True,
435
+ linear_iterations: int = 200,
436
+ seed: int = 42,
437
+ ) -> None:
438
+ super().__init__(
439
+ in_channels=1,
440
+ optimizer=optimizer,
441
+ schedulers=schedulers,
442
+ outputs_criterions=outputs_criterions,
443
+ dim=3,
444
+ )
445
+ engine = ConvexAdamEngine(
446
+ models,
447
+ voxel_size,
448
+ num_channels,
449
+ overlap,
450
+ layers_mask,
451
+ mixed_precision,
452
+ grid_spacing,
453
+ displacement_half_width,
454
+ iterations,
455
+ learning_rate,
456
+ regularization_weight,
457
+ grid_shrink,
458
+ distance,
459
+ layers_weight,
460
+ subset_features,
461
+ pca,
462
+ stages,
463
+ linear,
464
+ linear_iterations,
465
+ seed,
466
+ )
467
+ self.add_module(
468
+ "Registration", ConvexAdamRegistration(engine), in_branch=[0, 1, 2, 3], out_branch=["registration"]
469
+ )
470
+ self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"])
471
+ self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"])
ConvexAdam_Fine/Prediction.yml ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Predictor:
2
+ Model:
3
+ classpath: Model:RegistrationNet
4
+ RegistrationNet:
5
+ models:
6
+ - VBoussot/impact-torchscript-models:MIND/R1D2_3D.pt
7
+ voxel_size:
8
+ - 3.0
9
+ - 3.0
10
+ - 3.0
11
+ num_channels: 1
12
+ overlap: 2
13
+ layers_mask:
14
+ - true
15
+ mixed_precision: false
16
+ grid_spacing: 4
17
+ displacement_half_width: 6
18
+ iterations: 150
19
+ learning_rate: 0.2
20
+ regularization_weight: 1.0
21
+ grid_shrink: 4
22
+ distance:
23
+ - L1
24
+ layers_weight:
25
+ - 1.0
26
+ subset_features:
27
+ - 32
28
+ pca:
29
+ - 0
30
+ linear: false
31
+ linear_iterations: 200
32
+ seed: 42
33
+ outputs_criterions: None
34
+ stages:
35
+ - fine
36
+ Dataset:
37
+ groups_src:
38
+ Volume_0:
39
+ groups_dest:
40
+ Fixed:
41
+ transforms:
42
+ TensorCast:
43
+ dtype: float32
44
+ inverse: false
45
+ patch_transforms: None
46
+ is_input: true
47
+ Volume_1:
48
+ groups_dest:
49
+ Moving:
50
+ transforms:
51
+ TensorCast:
52
+ dtype: float32
53
+ inverse: false
54
+ patch_transforms: None
55
+ is_input: true
56
+ Volume_2:
57
+ groups_dest:
58
+ FixedMask:
59
+ transforms:
60
+ TensorCast:
61
+ dtype: float32
62
+ inverse: false
63
+ patch_transforms: None
64
+ is_input: true
65
+ Volume_3:
66
+ groups_dest:
67
+ MovingMask:
68
+ transforms:
69
+ TensorCast:
70
+ dtype: float32
71
+ inverse: false
72
+ patch_transforms: None
73
+ is_input: true
74
+ augmentations:
75
+ DataAugmentation_0:
76
+ data_augmentations:
77
+ Flip:
78
+ f_prob:
79
+ - 0
80
+ - 0.5
81
+ - 0.5
82
+ vector_field: true
83
+ prob: 1
84
+ nb: 2
85
+ Patch:
86
+ patch_size: None
87
+ overlap: None
88
+ mask: None
89
+ pad_value: None
90
+ extend_slice: 0
91
+ subset: None
92
+ filter: None
93
+ dataset_filenames:
94
+ - ./Dataset/:mha
95
+ use_cache: false
96
+ batch_size: 1
97
+ num_workers: None
98
+ pin_memory: false
99
+ prefetch_factor: None
100
+ persistent_workers: None
101
+ outputs_dataset:
102
+ MovedImage:
103
+ OutputDataset:
104
+ name_class: OutSameAsGroupDataset
105
+ before_reduction_transforms: None
106
+ after_reduction_transforms: None
107
+ final_transforms:
108
+ TensorCast:
109
+ dtype: float32
110
+ inverse: false
111
+ dataset_filename: Moved:mha
112
+ group: Moved
113
+ same_as_group: Volume_0:Fixed
114
+ patch_combine: None
115
+ inverse_transform: false
116
+ reduction: Mean
117
+ Mean: {}
118
+ DisplacementField:
119
+ OutputDataset:
120
+ name_class: OutSameAsGroupDataset
121
+ before_reduction_transforms: None
122
+ after_reduction_transforms: None
123
+ final_transforms:
124
+ TensorCast:
125
+ dtype: float32
126
+ inverse: false
127
+ dataset_filename: DVF:mha
128
+ group: DVF
129
+ same_as_group: Volume_0:Fixed
130
+ patch_combine: None
131
+ inverse_transform: false
132
+ reduction: Mean
133
+ Mean: {}
134
+ train_name: ImpactReg-ConvexAdam-Fine
135
+ manual_seed: 42
136
+ gpu_checkpoints: None
137
+ images_log: None
138
+ combine: Mean
139
+ autocast: false
140
+ data_log: None
ConvexAdam_Fine/Uncertainty.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Norm: {}
16
+ StandardDeviation: {}
17
+ Save:
18
+ dataset: ./Uncertainties/ImpactReg/Output:mha
19
+ group: None
20
+ subset: None
21
+ dataset_filenames:
22
+ - ./Dataset:mha
23
+ validation: None
24
+ train_name: ImpactReg
ConvexAdam_Fine/__pycache__/Model.cpython-314.pyc ADDED
Binary file (32.8 kB). View file
 
ConvexAdam_Fine/app.json ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "display_name": "ConvexAdam Fine (MIND)",
3
+ "short_description": "Fine Adam refinement (tileable; expects a coarse/pre-aligned start).",
4
+ "description": "Second stage of the ConvexAdam pipeline: the Adam instance-optimisation refinement driven by the IMPACT metric on MIND features, with no linear pre-align (it assumes the moving is already on the fixed grid, e.g. resampled by the coarse stage). Runs from a zero warm-start and tiles naturally for large images (set a patch size).",
5
+ "task": "registration",
6
+ "tta": 3,
7
+ "mc_dropout": 0,
8
+ "vram_plan": {
9
+ "8": {"patch_size": [128, 128, 128], "batch_size": 1},
10
+ "16": {"patch_size": [192, 192, 192], "batch_size": 1},
11
+ "24": {"patch_size": [256, 256, 256], "batch_size": 1},
12
+ "40": {"patch_size": [320, 320, 320], "batch_size": 1}
13
+ },
14
+ "models": [
15
+ "model.pt"
16
+ ],
17
+ "inputs": {
18
+ "Fixed": {
19
+ "display_name": "Fixed image",
20
+ "volume_type": "VOLUME",
21
+ "required": true
22
+ },
23
+ "Moving": {
24
+ "display_name": "Moving image",
25
+ "volume_type": "VOLUME",
26
+ "required": true
27
+ },
28
+ "FixedMask": {
29
+ "display_name": "Fixed mask (optional)",
30
+ "volume_type": "SEGMENTATION",
31
+ "required": false
32
+ },
33
+ "MovingMask": {
34
+ "display_name": "Moving mask (optional)",
35
+ "volume_type": "SEGMENTATION",
36
+ "required": false
37
+ }
38
+ },
39
+ "outputs": {
40
+ "MovedImage": {
41
+ "display_name": "Moved image",
42
+ "volume_type": "VOLUME",
43
+ "required": true
44
+ },
45
+ "DisplacementField": {
46
+ "display_name": "Displacement field",
47
+ "volume_type": "VOLUME",
48
+ "required": false
49
+ }
50
+ },
51
+ "inputs_evaluations": {
52
+ "Image": {
53
+ "Evaluation_with_images.yml": {
54
+ "FixedImage": {
55
+ "display_name": "Fixed image",
56
+ "volume_type": "VOLUME",
57
+ "required": true
58
+ },
59
+ "MovingImage": {
60
+ "display_name": "Moving image",
61
+ "volume_type": "VOLUME",
62
+ "required": true
63
+ },
64
+ "Mask": {
65
+ "display_name": "Evaluation mask",
66
+ "volume_type": "SEGMENTATION",
67
+ "required": false
68
+ }
69
+ }
70
+ },
71
+ "Segmentation": {
72
+ "Evaluation_with_seg.yml": {
73
+ "FixedSeg": {
74
+ "display_name": "Fixed segmentation",
75
+ "volume_type": "SEGMENTATION",
76
+ "required": true
77
+ },
78
+ "MovingSeg": {
79
+ "display_name": "Moving segmentation",
80
+ "volume_type": "SEGMENTATION",
81
+ "required": true
82
+ }
83
+ }
84
+ },
85
+ "Landmarks": {
86
+ "Evaluation_with_fid.yml": {
87
+ "FixedFid": {
88
+ "display_name": "Fixed landmarks",
89
+ "volume_type": "FIDUCIALS",
90
+ "required": true
91
+ },
92
+ "MovingFid": {
93
+ "display_name": "Moving landmarks",
94
+ "volume_type": "FIDUCIALS",
95
+ "required": true
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
ConvexAdam_Fine/model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:de99fbc36331ce674639acc774f52b4a2d0027f2f312d9d28669e831a0c4fd7e
3
+ size 1249
ConvexAdam_Fine/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ itk-impact