Image Registration

Image registration is the core functionality of FireANTs. It provides a set of algorithms to register images to a common coordinate system. It works with BatchedImages objects.

Abstract registration

All registration algorithms inherit from the AbstractRegistration class.

Bases: ABC

Base class for all registration algorithms in FireANTs.

This abstract class provides the core functionality and interface for image registration, handling all the common functionality for all linear and non-linear registration algorithms. It handles features like - multi-resolution optimization, - arbitrary similarity metrics, and - convergence monitoring.

Parameters:
  • scales (List[int]) –

    Downsampling factors for multi-resolution registration. Must be in descending order (e.g. [4,2,1]).

  • iterations (List[float]) –

    Number of iterations to perform at each scale. Must match length of scales.

  • fixed_images (BatchedImages) –

    Fixed/reference images to register to.

  • moving_images (BatchedImages) –

    Moving images to be registered.

  • loss_type (str) –

    Similarity metric to use. A set of predefined loss functions are provided: - 'mi': Mutual information Global mutual information between the fixed image and the moved image is measured using Parzen windowing using different kernel types. Mutual information is implemented using the GlobalMutualInformationLoss class. - 'cc': Cross correlation (default) Local normalized cross correlation between the fixed image and the moved image is measured using a kernel of size cc_kernel_size. This is a de-facto standard similarity metric for both linear and non-linear image registration. It also consumes less very little memory than mutual information. Cross correlation is implemented using the LocalNormalizedCrossCorrelationLoss class. - 'mse': Mean squared error Mean squared error is implemented using the MeanSquaredError class. This is the fastest similarity metric, but it is not robust to outliers, multi-modal images, or even intensity inhomogeneities within the same modality. Good for testing registration pipelines. - 'custom': Custom loss function (see custom_loss for more details) - 'noop': No similarity metric (this is useful for testing regularizations)

  • mi_kernel_type (str) –

    Kernel type for mutual information loss. Default: 'gaussian'

  • cc_kernel_type (str) –

    Kernel type for cross correlation loss. Default: 'rectangular'

  • custom_loss (nn.Module) –

    Custom loss module if loss_type='custom'. See Custom Loss Functions for more details on how to implement custom loss functions.

  • loss_params (dict) –

    Additional parameters for loss function. See the documentation of the loss function for more details on what parameters are available. This implementation abstracts away the loss function implementation details from the registration pipeline.

  • cc_kernel_size (int) –

    Kernel size for cross correlation loss. Default: 3

  • reduction (str) –

    Loss reduction method. Default: 'mean'

  • tolerance (float) –

    Convergence tolerance. Default: 1e-6

  • max_tolerance_iters (int) –

    Max iterations for convergence check. Default: 10

  • progress_bar (bool) –

    Whether to show progress bar. Default: True

Methods

optimize(): Abstract method to perform registration optimization get_warped_coordinates(): Abstract method to get transformed coordinates evaluate(): Apply learned transformation to new images

Note

The number of scales and iterations must match, and scales must be in descending order. The fixed and moving images must be broadcastable in batch dimension.

Source code in fireants/registration/abstract.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
class AbstractRegistration(ABC):
    """Base class for all registration algorithms in FireANTs.

    This abstract class provides the core functionality and interface for image registration,
    handling all the common functionality for all linear and non-linear registration algorithms.
    It handles features like
        - multi-resolution optimization,
        - arbitrary similarity metrics, and
        - convergence monitoring.

    Args:
        scales (List[int]): Downsampling factors for multi-resolution registration.
            Must be in descending order (e.g. [4,2,1]).
        iterations (List[float]): Number of iterations to perform at each scale.
            Must match length of scales.
        fixed_images (BatchedImages): Fixed/reference images to register to.
        moving_images (BatchedImages): Moving images to be registered.
        loss_type (str, optional): Similarity metric to use. A set of predefined loss functions are provided:
            - 'mi': Mutual information
                Global mutual information between the fixed image and the moved image is measured using Parzen windowing using different kernel types.
                Mutual information is implemented using the `GlobalMutualInformationLoss` class.
            - 'cc': Cross correlation (default)
                Local normalized cross correlation between the fixed image and the moved image is measured using a kernel of size `cc_kernel_size`. This is a de-facto standard similarity metric for both linear and non-linear image registration. It also consumes less very little memory than mutual information.
                Cross correlation is implemented using the `LocalNormalizedCrossCorrelationLoss` class.
            - 'mse': Mean squared error
                Mean squared error is implemented using the `MeanSquaredError` class.
                This is the fastest similarity metric, but it is not robust to outliers, multi-modal images, or even intensity inhomogeneities within the same modality. Good for testing registration pipelines.
            - 'custom': Custom loss function (see `custom_loss` for more details)
            - 'noop': No similarity metric  (this is useful for testing regularizations)
        mi_kernel_type (str, optional): Kernel type for mutual information loss. Default: 'gaussian'
        cc_kernel_type (str, optional): Kernel type for cross correlation loss. Default: 'rectangular'
        custom_loss (nn.Module, optional): Custom loss module if loss_type='custom'.
            See [Custom Loss Functions](../advanced/customloss.md) for more details on how to implement custom loss functions.
        loss_params (dict, optional): Additional parameters for loss function. See the documentation of the loss function for more details on what parameters are available. This implementation abstracts away the loss function implementation details from the registration pipeline.
        cc_kernel_size (int, optional): Kernel size for cross correlation loss. Default: 3
        reduction (str, optional): Loss reduction method. Default: 'mean'
        tolerance (float, optional): Convergence tolerance. Default: 1e-6
        max_tolerance_iters (int, optional): Max iterations for convergence check. Default: 10
        progress_bar (bool, optional): Whether to show progress bar. Default: True

    Methods:
        optimize(): Abstract method to perform registration optimization
        get_warped_coordinates(): Abstract method to get transformed coordinates
        evaluate(): Apply learned transformation to new images

    Note:
        The number of scales and iterations must match, and scales must be in descending order.
        The fixed and moving images must be broadcastable in batch dimension.
    """

    def __init__(self,
                scales: List[float], iterations: List[int],
                fixed_images: BatchedImages, moving_images: BatchedImages,
                loss_type: str = "cc",
                mi_kernel_type: str = 'gaussian', cc_kernel_type: str = 'rectangular',
                custom_loss: nn.Module = None,
                loss_params: dict = {},
                cc_kernel_size: int = 3,
                reduction: str = 'mean',
                tolerance: float = 1e-6, max_tolerance_iters: int = 10,
                progress_bar: bool = True,
                dtype: torch.dtype = torch.float32,
                ) -> None:
        '''
        Initialize abstract registration class
        '''
        super().__init__()
        self.scales = scales
        _assert_check_scales_decreasing(self.scales)
        self.iterations = iterations
        assert len(self.iterations) == len(self.scales), "Number of iterations must match number of scales"
        # check for fixed and moving image sizes
        self.fixed_images = fixed_images
        self.moving_images = moving_images
        # assert (self.fixed_images.size() == self.moving_images.size()), "Number of fixed and moving images must match"

        # check if sizes are broadcastable
        fsize, msize = self.fixed_images.size(), self.moving_images.size()
        assert (fsize == msize) or (fsize == 1) or (msize == 1), "Number of fixed and moving images must match or broadcastable"
        self.opt_size = max(fsize, msize)

        self.tolerance = tolerance
        self.max_tolerance_iters = max_tolerance_iters
        self.convergence_monitor = ConvergenceMonitor(self.max_tolerance_iters, self.tolerance)

        self.device = fixed_images.device
        self.dtype = dtype
        if not is_torch_float_type(self.dtype):
            raise ValueError(f"non-float dtype {self.dtype} is not supported for registration")

        self.dims = self.fixed_images.dims
        self.progress_bar = progress_bar        # variable to show or hide progress bar

        # initialize losses
        # track whether we are in masked mode; this is driven by the loss configuration
        if loss_type.startswith("masked_"):
            loss_params['masked'] = True
            self.masked = True
            loss_type = loss_type.replace("masked_", "")
            logger.info(f"Masked mode specified, will use {loss_type} function with masking if it supports it.")
        else:
            loss_params['masked'] = False
            self.masked = False
            logger.info(f"Masked mode not specified, will not use masking.")

        if loss_type == 'mi':
            self.loss_fn = GlobalMutualInformationLoss(kernel_type=mi_kernel_type, reduction=reduction, **loss_params)
        elif loss_type == 'cc':
            self.loss_fn = LocalNormalizedCrossCorrelationLoss(kernel_type=cc_kernel_type, spatial_dims=self.dims,
                                                               kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
        elif loss_type == 'fusedcc':
            try:
                from fireants.losses.fusedcc import FusedLocalNormalizedCrossCorrelationLoss
                self.loss_fn = FusedLocalNormalizedCrossCorrelationLoss(spatial_dims=self.dims,
                                                        kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
            except ImportError:
                logger.warning("fireants_fused_ops not available, falling back to non-fused LocalNormalizedCrossCorrelationLoss")
                self.loss_fn = LocalNormalizedCrossCorrelationLoss(kernel_type=cc_kernel_type, spatial_dims=self.dims,
                                                                   kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
        elif loss_type == 'lite_lncc':
            try:
                from fireants.losses.lite_lncc_backend import LiteLNCCLoss
                self.loss_fn = LiteLNCCLoss(spatial_dims=self.dims,
                                            kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
            except ImportError:
                logger.warning("lite_lncc not available, falling back to non-fused LocalNormalizedCrossCorrelationLoss")
                self.loss_fn = LocalNormalizedCrossCorrelationLoss(kernel_type=cc_kernel_type, spatial_dims=self.dims,
                                                                   kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
        elif loss_type == 'fusedmi':
            try:
                from fireants.losses.fusedmi import FusedGlobalMutualInformationLoss
                self.loss_fn = FusedGlobalMutualInformationLoss(kernel_type=mi_kernel_type, reduction=reduction, **loss_params)
            except ImportError:
                logger.warning("fireants_fused_ops not available, falling back to non-fused GlobalMutualInformationLoss")
                self.loss_fn = GlobalMutualInformationLoss(kernel_type=mi_kernel_type, reduction=reduction, **loss_params)
        elif loss_type == 'custom':
            self.loss_fn = custom_loss
        elif loss_type == 'noop':
            self.loss_fn = NoOp()
        elif loss_type == 'mse':
            self.loss_fn = MeanSquaredError(reduction=reduction, **loss_params)
        else:
            raise ValueError(f"Loss type {loss_type} not supported")

        # see if loss can store the iterations
        if hasattr(self.loss_fn, 'set_iterations'):
            logger.info("Setting iterations for loss function")
            self.loss_fn.set_iterations(self.iterations)
        if hasattr(self.loss_fn, 'set_scales'):
            logger.info("Setting scales for loss function")
            self.loss_fn.set_scales(self.scales)

        self.print_init_msg()

    def print_init_msg(self):
        logger.info(f"Registration of type {self.__class__.__name__} initialized with dtype {self.dtype}")

    def _split_image_and_mask_last_channel(self, arrays: torch.Tensor):
        """Split arrays into (image_channels, mask_channel) if in masked mode.

        Convention: when `self.masked` is True, the last channel is treated as a mask
        and should not be Gaussian-smoothed. All preceding channels are treated as image.

        Returns:
            (img, mask): img is the tensor to be smoothed; mask is the last channel (kept unsmoothed),
            or None if not in masked mode / no explicit mask channel.
        """
        if getattr(self, "masked", False) and arrays.shape[1] > 1:
            return arrays[:, :-1], arrays[:, -1:]
        return arrays, None

    def _concat_image_and_mask_last_channel(self, img: torch.Tensor, mask: Optional[torch.Tensor]):
        """Inverse of `_split_image_and_mask_last_channel`."""
        if mask is None:
            return img
        return torch.cat([img, mask], dim=1)

    def _downsample_image_and_mask(
        self,
        arrays: torch.Tensor,
        *,
        size,
        mode: str,
        gaussians=None,
        align_corners: bool = True,
    ) -> torch.Tensor:
        """Downsample arrays while respecting masked-last-channel convention.

        - If `gaussians` is provided, we Gaussian-smooth + downsample the image channels via `downsample`.
        - The (optional) mask channel is *never* Gaussian-smoothed; it is resized via interpolation only.
        """
        img, mask = self._split_image_and_mask_last_channel(arrays)
        if gaussians is None:
            img_down = F.interpolate(img, size=size, mode=mode, align_corners=align_corners)
        else:
            img_down = downsample(img, size=size, mode=mode, gaussians=gaussians)

        if mask is None:
            return img_down
        mask_down = F.interpolate(mask, size=size, mode=mode, align_corners=align_corners)
        return self._concat_image_and_mask_last_channel(img_down, mask_down)

    def _smooth_image_not_mask(self, arrays: torch.Tensor, gaussians) -> torch.Tensor:
        """Gaussian-smooth image channels but not the (optional) mask channel."""
        img, mask = self._split_image_and_mask_last_channel(arrays)
        img_smooth = separable_filtering(img, gaussians)
        return self._concat_image_and_mask_last_channel(img_smooth, mask)

    @abstractmethod
    def optimize(self):
        '''
        Abstract method to perform registration optimization
        '''
        pass

    @abstractmethod
    def get_warp_parameters(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
        ''' Get dictionary of parameters to pass into fireants_interpolator '''
        raise NotImplementedError("This method must be implemented by the registration class")

    @abstractmethod
    def get_inverse_warp_parameters(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
        ''' Get dictionary of parameters to pass into fireants_interpolator '''
        raise NotImplementedError("This method must be implemented by the registration class")

    def get_warped_coordinates(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
        '''Get the transformed coordinates for warping the moving image.

        This abstract method must be implemented by all registration classes to define how
        coordinates are transformed from the fixed image space to the moving image space.
        The transformed coordinates are used with grid_sample to warp the moving image.

        Args:
            fixed_images (BatchedImages): Fixed/reference images that define the target space.
                The output coordinates will be in this image's coordinate system.
            moving_images (BatchedImages): Moving images to be transformed.
                Used to access the original image space parameters.
            shape (Optional[Tuple[int, ...]]): Optional output shape for the coordinate grid.
                If None, uses the shape of the fixed image.

        Returns:
            torch.Tensor: Transformed coordinates in normalized [-1, 1] space.
                Has shape [N, H, W, [D], dims] where:
                    N = batch size
                    H, W, [D] = spatial dimensions (D only for 3D)
                    dims = number of spatial dimensions (2 or 3)
                These coordinates can be used directly with `torch.nn.functional.grid_sample()`

        Note:
            - Coordinates are returned in the normalized [-1, 1] coordinate system required by grid_sample
            - The transformation maps from fixed image space to moving image space (backward transform)
            - Physical space transformations are handled internally using the image metadata
        '''
        params = self.get_warp_parameters(fixed_images, moving_images, shape)
        if 'affine' in params and 'grid' not in params:
            return F.affine_grid(params['affine'], params['out_shape'], align_corners=True)
        elif 'affine' in params and 'grid' in params:
            # this is just a warp field
            affine = params['affine']
            grid = params['grid']
            grid = fireants_interpolator.affine_warp(affine, grid, align_corners=True)
            return grid
        else:
            raise ValueError(f"Invalid warp parameters with keys {params.keys()}")

    def get_inverse_warped_coordinates(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
        ''' Get inverse warped coordinates for the moving image.

        This method is useful to analyse the effect of how the moving coordinates (fixed images) are transformed
        '''
        params = self.get_inverse_warp_parameters(fixed_images, moving_images, shape)
        if 'affine' in params and 'grid' not in params:
            return F.affine_grid(params['affine'], params['out_shape'], align_corners=True)
        elif 'affine' in params and 'grid' in params:
            # this is just a warp field
            affine = params['affine']
            grid = params['grid']
            shape = [affine.shape[0], 1] + list(grid.shape[1:-1])
            grid = fireants_interpolator.affine_warp(affine, grid, align_corners=True)
            return grid
        else:
            raise ValueError(f"Invalid warp parameters with keys {params.keys()}")

    def save_moved_images(self, moved_images: Union[BatchedImages, FakeBatchedImages, torch.Tensor], filenames: Union[str, List[str]], moving_to_fixed: bool = True, ignore_size_match: bool = False):
        '''
        Save the moved images to disk.

        Args:
            moved_images (Union[BatchedImages, FakeBatchedImages, torch.Tensor]): The moved images to save.
            filenames (Union[str, List[str]]): The filenames to save the moved images to.
            moving_to_fixed (bool, optional): If True, the moving images are saved to the fixed image space. Defaults to True.
                if False, we are dealing with an image that is moved from fixed space to moving space
        '''
        if isinstance(moved_images, BatchedImages):
            moved_images_save = FakeBatchedImages(moved_images(), moved_images, ignore_size_match)   # roundabout way to call the fakebatchedimages
        elif isinstance(moved_images, torch.Tensor):
            moved_images_save = FakeBatchedImages(moved_images, self.fixed_images if moving_to_fixed else self.moving_images, ignore_size_match)
        else:
            # if it is already a fakebatchedimages, we can just use it
            moved_images_save = moved_images
        moved_images_save.write_image(filenames)


    def evaluate_inverse(self, fixed_images: Union[BatchedImages, torch.Tensor], moving_images: Union[BatchedImages, torch.Tensor], shape=None, **kwargs):
        ''' Apply the inverse of the learned transformation to new images.

        This method is useful to analyse the effect of how the moving coordinates (fixed images) are transformed
        '''
        if isinstance(fixed_images, torch.Tensor):
            fixed_images = FakeBatchedImages(fixed_images, self.fixed_images)
        if isinstance(moving_images, torch.Tensor):
            moving_images = FakeBatchedImages(moving_images, self.moving_images)

        fixed_arrays = moving_images()
        fixed_moved_coords = self.get_inverse_warp_parameters(fixed_images, moving_images, shape=shape, **kwargs)
        fixed_moved_image = fireants_interpolator(fixed_arrays, **fixed_moved_coords, mode='bilinear', align_corners=True)  # [N, C, H, W, [D]]
        return fixed_moved_image


    def evaluate(self, fixed_images: Union[BatchedImages, torch.Tensor], moving_images: Union[BatchedImages, torch.Tensor], shape=None):
        '''Apply the learned transformation to new images.

        This method applies the registration transformation learned during optimization
        to a new set of images. It can be used to:
            - Validate registration performance on test images
            - Apply learned transformations to new data
            - Transform auxiliary data (e.g. segmentation masks) using learned parameters

        All registration classes will implement their own `get_warped_coordinates` method,
        which is used to apply the learned transformation to new images.

        Args:
            fixed_images (BatchedImages): Fixed/reference images that define the target space
            moving_images (BatchedImages): Moving images to be transformed
            shape (Optional[Tuple[int, ...]]): Optional output shape for the transformed image.
                If None, uses the shape of the fixed image.

        Returns:
            torch.Tensor: The transformed moving image in the space of the fixed image.
                Has shape [N, C, H, W, [D]] where:
                    N = batch size
                    C = number of channels
                    H, W, D = spatial dimensions (D only for 3D)

        Note:
            The transformation is applied using bilinear interpolation with align_corners=True
            to maintain consistency with the optimization process.
        '''
        if isinstance(fixed_images, torch.Tensor):
            fixed_images = FakeBatchedImages(fixed_images, self.fixed_images)
        if isinstance(moving_images, torch.Tensor):
            moving_images = FakeBatchedImages(moving_images, self.moving_images)

        moving_arrays = moving_images()
        moved_coords = self.get_warp_parameters(fixed_images, moving_images, shape=shape)
        interpolate_mode = moving_images.get_interpolator_type()
        moved_image = fireants_interpolator(moving_arrays, **moved_coords, mode=interpolate_mode, align_corners=True)  # [N, C, H, W, [D]]
        return moved_image

    def evaluate_keypoints(self, fixed_keypoints: BatchedKeypoints, moving_keypoints: BatchedKeypoints):
        '''Apply the learned transformation to keypoints in the fixed image space

        This method applies the registration transformation learned during optimization
        to a set of unordered keypoints in the fixed image space. It can be used to:
            - Validate registration performance on test images 

        All registration classes will implement their own `get_warped_coordinates` method,
        which is used to apply the learned transformation to new images.

        Args:
            fixed_keypoints (BatchedKeypoints): Fixed/reference keypoints that define the target space

        Returns:
            BatchedKeypoints: The transformed keypoints in the space of the fixed image.
        '''
        moved_coords = self.get_warp_parameters(self.fixed_images, self.moving_images)
        kps_tensor = fixed_keypoints.as_torch_coordinates() # tensor if can_collate, list of tensors otherwise
        can_collate = fixed_keypoints.can_collate
        dims = self.dims
        if can_collate:
            # kps_tensor is a [B, N, dims] tensor -> unsqueeze to (B, 1, N, dims) or (B, 1, 1, N, dims)
            kps_tensor_disp = kps_tensor
            for _ in range(dims - 1):
                kps_tensor_disp = kps_tensor_disp.unsqueeze(1)
            # compute displacement first
            final_coord = 0
            if 'grid' in moved_coords:
                # compute u(x_f) and squeeze back
                final_coord = F.grid_sample(moved_coords['grid'].permute(*self.warp.permute_vtoimg), kps_tensor_disp, mode='bilinear', align_corners=True).permute(*self.warp.permute_imgtov)
                for _ in range(dims - 1):
                    final_coord = final_coord.squeeze(1)

            if 'affine' in moved_coords:
                final_coord = final_coord + BatchedKeypoints.transform_keypoints_batch(moved_coords['affine'], kps_tensor)
            else:
                final_coord = final_coord + kps_tensor
            # final_coord is [B, N, dims] tensor
            return BatchedKeypoints.from_tensor_and_metadata(final_coord, moving_keypoints, space='torch')
        else:
            raise NotImplementedError("Not implemented for non-collated keypoints")

__init__(scales, iterations, fixed_images, moving_images, loss_type='cc', mi_kernel_type='gaussian', cc_kernel_type='rectangular', custom_loss=None, loss_params={}, cc_kernel_size=3, reduction='mean', tolerance=1e-06, max_tolerance_iters=10, progress_bar=True, dtype=torch.float32)

Initialize abstract registration class

Source code in fireants/registration/abstract.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def __init__(self,
            scales: List[float], iterations: List[int],
            fixed_images: BatchedImages, moving_images: BatchedImages,
            loss_type: str = "cc",
            mi_kernel_type: str = 'gaussian', cc_kernel_type: str = 'rectangular',
            custom_loss: nn.Module = None,
            loss_params: dict = {},
            cc_kernel_size: int = 3,
            reduction: str = 'mean',
            tolerance: float = 1e-6, max_tolerance_iters: int = 10,
            progress_bar: bool = True,
            dtype: torch.dtype = torch.float32,
            ) -> None:
    '''
    Initialize abstract registration class
    '''
    super().__init__()
    self.scales = scales
    _assert_check_scales_decreasing(self.scales)
    self.iterations = iterations
    assert len(self.iterations) == len(self.scales), "Number of iterations must match number of scales"
    # check for fixed and moving image sizes
    self.fixed_images = fixed_images
    self.moving_images = moving_images
    # assert (self.fixed_images.size() == self.moving_images.size()), "Number of fixed and moving images must match"

    # check if sizes are broadcastable
    fsize, msize = self.fixed_images.size(), self.moving_images.size()
    assert (fsize == msize) or (fsize == 1) or (msize == 1), "Number of fixed and moving images must match or broadcastable"
    self.opt_size = max(fsize, msize)

    self.tolerance = tolerance
    self.max_tolerance_iters = max_tolerance_iters
    self.convergence_monitor = ConvergenceMonitor(self.max_tolerance_iters, self.tolerance)

    self.device = fixed_images.device
    self.dtype = dtype
    if not is_torch_float_type(self.dtype):
        raise ValueError(f"non-float dtype {self.dtype} is not supported for registration")

    self.dims = self.fixed_images.dims
    self.progress_bar = progress_bar        # variable to show or hide progress bar

    # initialize losses
    # track whether we are in masked mode; this is driven by the loss configuration
    if loss_type.startswith("masked_"):
        loss_params['masked'] = True
        self.masked = True
        loss_type = loss_type.replace("masked_", "")
        logger.info(f"Masked mode specified, will use {loss_type} function with masking if it supports it.")
    else:
        loss_params['masked'] = False
        self.masked = False
        logger.info(f"Masked mode not specified, will not use masking.")

    if loss_type == 'mi':
        self.loss_fn = GlobalMutualInformationLoss(kernel_type=mi_kernel_type, reduction=reduction, **loss_params)
    elif loss_type == 'cc':
        self.loss_fn = LocalNormalizedCrossCorrelationLoss(kernel_type=cc_kernel_type, spatial_dims=self.dims,
                                                           kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
    elif loss_type == 'fusedcc':
        try:
            from fireants.losses.fusedcc import FusedLocalNormalizedCrossCorrelationLoss
            self.loss_fn = FusedLocalNormalizedCrossCorrelationLoss(spatial_dims=self.dims,
                                                    kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
        except ImportError:
            logger.warning("fireants_fused_ops not available, falling back to non-fused LocalNormalizedCrossCorrelationLoss")
            self.loss_fn = LocalNormalizedCrossCorrelationLoss(kernel_type=cc_kernel_type, spatial_dims=self.dims,
                                                               kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
    elif loss_type == 'lite_lncc':
        try:
            from fireants.losses.lite_lncc_backend import LiteLNCCLoss
            self.loss_fn = LiteLNCCLoss(spatial_dims=self.dims,
                                        kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
        except ImportError:
            logger.warning("lite_lncc not available, falling back to non-fused LocalNormalizedCrossCorrelationLoss")
            self.loss_fn = LocalNormalizedCrossCorrelationLoss(kernel_type=cc_kernel_type, spatial_dims=self.dims,
                                                               kernel_size=cc_kernel_size, reduction=reduction, **loss_params)
    elif loss_type == 'fusedmi':
        try:
            from fireants.losses.fusedmi import FusedGlobalMutualInformationLoss
            self.loss_fn = FusedGlobalMutualInformationLoss(kernel_type=mi_kernel_type, reduction=reduction, **loss_params)
        except ImportError:
            logger.warning("fireants_fused_ops not available, falling back to non-fused GlobalMutualInformationLoss")
            self.loss_fn = GlobalMutualInformationLoss(kernel_type=mi_kernel_type, reduction=reduction, **loss_params)
    elif loss_type == 'custom':
        self.loss_fn = custom_loss
    elif loss_type == 'noop':
        self.loss_fn = NoOp()
    elif loss_type == 'mse':
        self.loss_fn = MeanSquaredError(reduction=reduction, **loss_params)
    else:
        raise ValueError(f"Loss type {loss_type} not supported")

    # see if loss can store the iterations
    if hasattr(self.loss_fn, 'set_iterations'):
        logger.info("Setting iterations for loss function")
        self.loss_fn.set_iterations(self.iterations)
    if hasattr(self.loss_fn, 'set_scales'):
        logger.info("Setting scales for loss function")
        self.loss_fn.set_scales(self.scales)

    self.print_init_msg()

evaluate(fixed_images, moving_images, shape=None)

Apply the learned transformation to new images.

This method applies the registration transformation learned during optimization to a new set of images. It can be used to: - Validate registration performance on test images - Apply learned transformations to new data - Transform auxiliary data (e.g. segmentation masks) using learned parameters

All registration classes will implement their own get_warped_coordinates method, which is used to apply the learned transformation to new images.

Parameters:
  • fixed_images (BatchedImages) –

    Fixed/reference images that define the target space

  • moving_images (BatchedImages) –

    Moving images to be transformed

  • shape (Optional[Tuple[int, ...]]) –

    Optional output shape for the transformed image. If None, uses the shape of the fixed image.

Returns:
  • –

    torch.Tensor: The transformed moving image in the space of the fixed image. Has shape [N, C, H, W, [D]] where: N = batch size C = number of channels H, W, D = spatial dimensions (D only for 3D)

Note

The transformation is applied using bilinear interpolation with align_corners=True to maintain consistency with the optimization process.

Source code in fireants/registration/abstract.py
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
def evaluate(self, fixed_images: Union[BatchedImages, torch.Tensor], moving_images: Union[BatchedImages, torch.Tensor], shape=None):
    '''Apply the learned transformation to new images.

    This method applies the registration transformation learned during optimization
    to a new set of images. It can be used to:
        - Validate registration performance on test images
        - Apply learned transformations to new data
        - Transform auxiliary data (e.g. segmentation masks) using learned parameters

    All registration classes will implement their own `get_warped_coordinates` method,
    which is used to apply the learned transformation to new images.

    Args:
        fixed_images (BatchedImages): Fixed/reference images that define the target space
        moving_images (BatchedImages): Moving images to be transformed
        shape (Optional[Tuple[int, ...]]): Optional output shape for the transformed image.
            If None, uses the shape of the fixed image.

    Returns:
        torch.Tensor: The transformed moving image in the space of the fixed image.
            Has shape [N, C, H, W, [D]] where:
                N = batch size
                C = number of channels
                H, W, D = spatial dimensions (D only for 3D)

    Note:
        The transformation is applied using bilinear interpolation with align_corners=True
        to maintain consistency with the optimization process.
    '''
    if isinstance(fixed_images, torch.Tensor):
        fixed_images = FakeBatchedImages(fixed_images, self.fixed_images)
    if isinstance(moving_images, torch.Tensor):
        moving_images = FakeBatchedImages(moving_images, self.moving_images)

    moving_arrays = moving_images()
    moved_coords = self.get_warp_parameters(fixed_images, moving_images, shape=shape)
    interpolate_mode = moving_images.get_interpolator_type()
    moved_image = fireants_interpolator(moving_arrays, **moved_coords, mode=interpolate_mode, align_corners=True)  # [N, C, H, W, [D]]
    return moved_image

evaluate_inverse(fixed_images, moving_images, shape=None, **kwargs)

Apply the inverse of the learned transformation to new images.

This method is useful to analyse the effect of how the moving coordinates (fixed images) are transformed

Source code in fireants/registration/abstract.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
def evaluate_inverse(self, fixed_images: Union[BatchedImages, torch.Tensor], moving_images: Union[BatchedImages, torch.Tensor], shape=None, **kwargs):
    ''' Apply the inverse of the learned transformation to new images.

    This method is useful to analyse the effect of how the moving coordinates (fixed images) are transformed
    '''
    if isinstance(fixed_images, torch.Tensor):
        fixed_images = FakeBatchedImages(fixed_images, self.fixed_images)
    if isinstance(moving_images, torch.Tensor):
        moving_images = FakeBatchedImages(moving_images, self.moving_images)

    fixed_arrays = moving_images()
    fixed_moved_coords = self.get_inverse_warp_parameters(fixed_images, moving_images, shape=shape, **kwargs)
    fixed_moved_image = fireants_interpolator(fixed_arrays, **fixed_moved_coords, mode='bilinear', align_corners=True)  # [N, C, H, W, [D]]
    return fixed_moved_image

evaluate_keypoints(fixed_keypoints, moving_keypoints)

Apply the learned transformation to keypoints in the fixed image space

This method applies the registration transformation learned during optimization to a set of unordered keypoints in the fixed image space. It can be used to: - Validate registration performance on test images

All registration classes will implement their own get_warped_coordinates method, which is used to apply the learned transformation to new images.

Parameters:
  • fixed_keypoints (BatchedKeypoints) –

    Fixed/reference keypoints that define the target space

Returns:
  • BatchedKeypoints –

    The transformed keypoints in the space of the fixed image.

Source code in fireants/registration/abstract.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
def evaluate_keypoints(self, fixed_keypoints: BatchedKeypoints, moving_keypoints: BatchedKeypoints):
    '''Apply the learned transformation to keypoints in the fixed image space

    This method applies the registration transformation learned during optimization
    to a set of unordered keypoints in the fixed image space. It can be used to:
        - Validate registration performance on test images 

    All registration classes will implement their own `get_warped_coordinates` method,
    which is used to apply the learned transformation to new images.

    Args:
        fixed_keypoints (BatchedKeypoints): Fixed/reference keypoints that define the target space

    Returns:
        BatchedKeypoints: The transformed keypoints in the space of the fixed image.
    '''
    moved_coords = self.get_warp_parameters(self.fixed_images, self.moving_images)
    kps_tensor = fixed_keypoints.as_torch_coordinates() # tensor if can_collate, list of tensors otherwise
    can_collate = fixed_keypoints.can_collate
    dims = self.dims
    if can_collate:
        # kps_tensor is a [B, N, dims] tensor -> unsqueeze to (B, 1, N, dims) or (B, 1, 1, N, dims)
        kps_tensor_disp = kps_tensor
        for _ in range(dims - 1):
            kps_tensor_disp = kps_tensor_disp.unsqueeze(1)
        # compute displacement first
        final_coord = 0
        if 'grid' in moved_coords:
            # compute u(x_f) and squeeze back
            final_coord = F.grid_sample(moved_coords['grid'].permute(*self.warp.permute_vtoimg), kps_tensor_disp, mode='bilinear', align_corners=True).permute(*self.warp.permute_imgtov)
            for _ in range(dims - 1):
                final_coord = final_coord.squeeze(1)

        if 'affine' in moved_coords:
            final_coord = final_coord + BatchedKeypoints.transform_keypoints_batch(moved_coords['affine'], kps_tensor)
        else:
            final_coord = final_coord + kps_tensor
        # final_coord is [B, N, dims] tensor
        return BatchedKeypoints.from_tensor_and_metadata(final_coord, moving_keypoints, space='torch')
    else:
        raise NotImplementedError("Not implemented for non-collated keypoints")

get_inverse_warp_parameters(fixed_images, moving_images, shape=None) abstractmethod

Get dictionary of parameters to pass into fireants_interpolator

Source code in fireants/registration/abstract.py
260
261
262
263
@abstractmethod
def get_inverse_warp_parameters(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
    ''' Get dictionary of parameters to pass into fireants_interpolator '''
    raise NotImplementedError("This method must be implemented by the registration class")

get_inverse_warped_coordinates(fixed_images, moving_images, shape=None)

Get inverse warped coordinates for the moving image.

This method is useful to analyse the effect of how the moving coordinates (fixed images) are transformed

Source code in fireants/registration/abstract.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def get_inverse_warped_coordinates(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
    ''' Get inverse warped coordinates for the moving image.

    This method is useful to analyse the effect of how the moving coordinates (fixed images) are transformed
    '''
    params = self.get_inverse_warp_parameters(fixed_images, moving_images, shape)
    if 'affine' in params and 'grid' not in params:
        return F.affine_grid(params['affine'], params['out_shape'], align_corners=True)
    elif 'affine' in params and 'grid' in params:
        # this is just a warp field
        affine = params['affine']
        grid = params['grid']
        shape = [affine.shape[0], 1] + list(grid.shape[1:-1])
        grid = fireants_interpolator.affine_warp(affine, grid, align_corners=True)
        return grid
    else:
        raise ValueError(f"Invalid warp parameters with keys {params.keys()}")

get_warp_parameters(fixed_images, moving_images, shape=None) abstractmethod

Get dictionary of parameters to pass into fireants_interpolator

Source code in fireants/registration/abstract.py
255
256
257
258
@abstractmethod
def get_warp_parameters(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
    ''' Get dictionary of parameters to pass into fireants_interpolator '''
    raise NotImplementedError("This method must be implemented by the registration class")

get_warped_coordinates(fixed_images, moving_images, shape=None)

Get the transformed coordinates for warping the moving image.

This abstract method must be implemented by all registration classes to define how coordinates are transformed from the fixed image space to the moving image space. The transformed coordinates are used with grid_sample to warp the moving image.

Parameters:
  • fixed_images (BatchedImages) –

    Fixed/reference images that define the target space. The output coordinates will be in this image's coordinate system.

  • moving_images (BatchedImages) –

    Moving images to be transformed. Used to access the original image space parameters.

  • shape (Optional[Tuple[int, ...]]) –

    Optional output shape for the coordinate grid. If None, uses the shape of the fixed image.

Returns:
  • –

    torch.Tensor: Transformed coordinates in normalized [-1, 1] space. Has shape [N, H, W, [D], dims] where: N = batch size H, W, [D] = spatial dimensions (D only for 3D) dims = number of spatial dimensions (2 or 3) These coordinates can be used directly with torch.nn.functional.grid_sample()

Note
  • Coordinates are returned in the normalized [-1, 1] coordinate system required by grid_sample
  • The transformation maps from fixed image space to moving image space (backward transform)
  • Physical space transformations are handled internally using the image metadata
Source code in fireants/registration/abstract.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def get_warped_coordinates(self, fixed_images: Union[BatchedImages, FakeBatchedImages], moving_images: Union[BatchedImages, FakeBatchedImages], shape=None):
    '''Get the transformed coordinates for warping the moving image.

    This abstract method must be implemented by all registration classes to define how
    coordinates are transformed from the fixed image space to the moving image space.
    The transformed coordinates are used with grid_sample to warp the moving image.

    Args:
        fixed_images (BatchedImages): Fixed/reference images that define the target space.
            The output coordinates will be in this image's coordinate system.
        moving_images (BatchedImages): Moving images to be transformed.
            Used to access the original image space parameters.
        shape (Optional[Tuple[int, ...]]): Optional output shape for the coordinate grid.
            If None, uses the shape of the fixed image.

    Returns:
        torch.Tensor: Transformed coordinates in normalized [-1, 1] space.
            Has shape [N, H, W, [D], dims] where:
                N = batch size
                H, W, [D] = spatial dimensions (D only for 3D)
                dims = number of spatial dimensions (2 or 3)
            These coordinates can be used directly with `torch.nn.functional.grid_sample()`

    Note:
        - Coordinates are returned in the normalized [-1, 1] coordinate system required by grid_sample
        - The transformation maps from fixed image space to moving image space (backward transform)
        - Physical space transformations are handled internally using the image metadata
    '''
    params = self.get_warp_parameters(fixed_images, moving_images, shape)
    if 'affine' in params and 'grid' not in params:
        return F.affine_grid(params['affine'], params['out_shape'], align_corners=True)
    elif 'affine' in params and 'grid' in params:
        # this is just a warp field
        affine = params['affine']
        grid = params['grid']
        grid = fireants_interpolator.affine_warp(affine, grid, align_corners=True)
        return grid
    else:
        raise ValueError(f"Invalid warp parameters with keys {params.keys()}")

optimize() abstractmethod

Abstract method to perform registration optimization

Source code in fireants/registration/abstract.py
248
249
250
251
252
253
@abstractmethod
def optimize(self):
    '''
    Abstract method to perform registration optimization
    '''
    pass

save_moved_images(moved_images, filenames, moving_to_fixed=True, ignore_size_match=False)

Save the moved images to disk.

Parameters:
  • moved_images (Union[BatchedImages, FakeBatchedImages, torch.Tensor]) –

    The moved images to save.

  • filenames (Union[str, List[str]]) –

    The filenames to save the moved images to.

  • moving_to_fixed (bool) –

    If True, the moving images are saved to the fixed image space. Defaults to True. if False, we are dealing with an image that is moved from fixed space to moving space

Source code in fireants/registration/abstract.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def save_moved_images(self, moved_images: Union[BatchedImages, FakeBatchedImages, torch.Tensor], filenames: Union[str, List[str]], moving_to_fixed: bool = True, ignore_size_match: bool = False):
    '''
    Save the moved images to disk.

    Args:
        moved_images (Union[BatchedImages, FakeBatchedImages, torch.Tensor]): The moved images to save.
        filenames (Union[str, List[str]]): The filenames to save the moved images to.
        moving_to_fixed (bool, optional): If True, the moving images are saved to the fixed image space. Defaults to True.
            if False, we are dealing with an image that is moved from fixed space to moving space
    '''
    if isinstance(moved_images, BatchedImages):
        moved_images_save = FakeBatchedImages(moved_images(), moved_images, ignore_size_match)   # roundabout way to call the fakebatchedimages
    elif isinstance(moved_images, torch.Tensor):
        moved_images_save = FakeBatchedImages(moved_images, self.fixed_images if moving_to_fixed else self.moving_images, ignore_size_match)
    else:
        # if it is already a fakebatchedimages, we can just use it
        moved_images_save = moved_images
    moved_images_save.write_image(filenames)