DeformableMixin

This mixin provides common functionality for deformable registration classes.

Why Deformable Mixin?

Although the AbstractRegistration class provides a common interface for all registration classes, there are significant differences in the functionality required from deformable registration classes. Greedy and Symmetric registration classes have different optimizations, but share the same final transformation: a deformation field that warps the moving image to the fixed image space.

The DeformableMixin class provides common functionality for both Greedy and Symmetric registration classes, particularly for saving deformation fields in a format compatible with ANTs (Advanced Normalization Tools) and other widely used registration tools.


DeformableMixin class

Mixin class providing common functionality for deformable registration classes.

This mixin implements shared methods used by both GreedyRegistration and SyNRegistration classes, particularly for saving deformation fields in a format compatible with ANTs (Advanced Normalization Tools) and other widely used registration tools.

The mixin assumes the parent class has:

  • opt_size: Number of registration pairs
  • dims: Number of spatial dimensions
  • fixed_images: BatchedImages object containing fixed images
  • moving_images: BatchedImages object containing moving images
  • get_warped_coordinates(): Method to get transformed coordinates
Source code in fireants/registration/deformablemixin.py
 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
class DeformableMixin:
    """Mixin class providing common functionality for deformable registration classes.

    This mixin implements shared methods used by both GreedyRegistration and SyNRegistration
    classes, particularly for saving deformation fields in a format compatible with ANTs
    (Advanced Normalization Tools) and other widely used registration tools.

    The mixin assumes the parent class has:

    - opt_size: Number of registration pairs
    - dims: Number of spatial dimensions
    - fixed_images: BatchedImages object containing fixed images
    - moving_images: BatchedImages object containing moving images
    - get_warped_coordinates(): Method to get transformed coordinates
    """

    @torch.no_grad()
    def save_as_ants_transforms(reg, filenames: Union[str, List[str]], save_inverse=False):
        """Save deformation fields in ANTs-compatible format.

        Converts the learned deformation fields to displacement fields in physical space
        and saves them in a format that can be used by ANTs registration tools.
        The displacement fields are saved as multi-component images where each component
        represents the displacement along one spatial dimension.

        Args:
            filenames (Union[str, List[str]]): Path(s) where the transform(s) should be saved.
                If a single string is provided for multiple transforms, it will be treated
                as the first filename. For multiple transforms, provide a list of filenames
                matching the number of transforms.

        Raises:
            AssertionError: If number of filenames doesn't match number of transforms (opt_size)

        !!! caution "Physical Space Coordinates"
            The saved transforms are in physical space coordinates, not normalized [-1,1] space.
            The displacement fields are saved with the same orientation and spacing as the 
            fixed images.
        """
        if "distributed" in reg.__class__.__name__.lower():
            logger.info("Rerouting to distributed transform")
            return reg.save_as_ants_transforms_distributed(filenames, save_inverse)

        if isinstance(filenames, str):
            filenames = [filenames]
        assert len(filenames) == reg.opt_size, "Number of filenames should match the number of warps"
        # get the warp field
        fixed_image: BatchedImages = reg.fixed_images
        moving_image: BatchedImages = reg.moving_images

        # get metadata to convert torch space to physical space
        moving_t2p = moving_image.get_torch2phy()
        fixed_t2p = fixed_image.get_torch2phy()

        # get params or inverse params
        if save_inverse:
            moved_params = reg.get_inverse_warp_parameters(fixed_image, moving_image)   # contains affine and grid
        else:
            moved_params = reg.get_warp_parameters(fixed_image, moving_image)   # contains affine and grid

        moved_coords = moved_params['grid'].detach()
        affine = moved_params['affine']

        if save_inverse:
            moved_coords = torch.einsum('bij, b...j->b...i', fixed_t2p[:, :reg.dims, :reg.dims], moved_coords).contiguous()  # convert to fixed space
        else:
            # convert to ants format
            moved_coords = torch.einsum('bij, b...j->b...i', moving_t2p[:, :reg.dims, :reg.dims], moved_coords).contiguous()  # convert to moving space

        # create ants affine
        row = torch.zeros((affine.shape[0], 1, reg.dims+1), device=affine.device, dtype=affine.dtype)
        row[:, 0, -1] = 1
        affine = torch.cat([affine, row], dim=1)  # [b, dim+1, dim+1]
        # 
        if save_inverse:
            affine = ((fixed_t2p @ affine - moving_t2p)[:, :reg.dims]).contiguous()
        else:
            affine = ((moving_t2p @ affine - fixed_t2p)[:, :reg.dims]).contiguous()

        # create moved_disp
        moved_disp_final = fireants_interpolator.affine_warp(affine=affine, grid=moved_coords)

        # save 
        for i in range(reg.opt_size):
            moved_disp = moved_disp_final[i].detach().cpu().numpy()  # [H, W, D, 3]
            savefile = filenames[i]
            # get itk image
            if len(fixed_image.images) < i:     # this image is probably broadcasted then
                itk_data = fixed_image.images[0].itk_image if not save_inverse else moving_image.images[0].itk_image
            else:
                itk_data = fixed_image.images[i].itk_image if not save_inverse else moving_image.images[i].itk_image
            # copy itk data
            warp = sitk.GetImageFromArray(moved_disp)
            warp.CopyInformation(itk_data)
            sitk.WriteImage(warp, savefile)

    @torch.no_grad()
    def save_as_ants_transforms_distributed(reg, filenames: Union[str, List[str]], save_inverse=False):
        '''
        Save the warp field in ANTs compatible format for distributed registration.
        This function is called by `save_as_ants_transforms` if the registration is distributed.

        Args:
            filenames (Union[str, List[str]]): Path(s) where the transform(s) should be saved.
                If a single string is provided for multiple transforms, it will be treated
                as the first filename. For multiple transforms, provide a list of filenames
                matching the number of transforms.

        Raises:
            AssertionError: If number of filenames doesn't match number of transforms (opt_size)
        ''' 
        if "distributed" not in reg.__class__.__name__.lower():
            raise ValueError("This function should only be called by distributed registration")

        assert not save_inverse, "Inverse transforms are not supported in distributed mode"

        if isinstance(filenames, str):
            filenames = [filenames]
        assert len(filenames) == reg.opt_size, "Number of filenames should match the number of warps"
        logger.info(f"Saving {reg.opt_size} transforms to {filenames}")
        # get the warp field
        fixed_image: BatchedImages = reg.fixed_images
        moving_image: BatchedImages = reg.moving_images 
        moved_params = reg.get_warp_parameters(fixed_image, moving_image)   # contains affine and grid

        # the idea is that we have Ax + u in torch space,
        # we want Am . (Ax + u) - Af . x  (coordinates translated to moving frame - identity at fixed frame)
        # this is equal to (Am . A - Af) x + Am . u

        # metadata
        moving_t2p = moving_image.get_torch2phy()
        fixed_t2p = fixed_image.get_torch2phy()

        logger.info(f"device: {parallel_state.get_device()}, moving_t2p: {moving_t2p.device}, fixed_t2p: {fixed_t2p.device}, moved_params: {moved_params['grid'].device}")

        # gather grid
        grid = moved_params['grid']   # [B,N,dim]
        affine = moved_params['affine']  # [B, dim, dim+1]
        grid = torch.einsum('bij, b...j->b...i', moving_t2p[:, :reg.dims, :reg.dims], grid).contiguous()  # convert to moving space

        # create ants affine
        row = torch.zeros((affine.shape[0], 1, reg.dims+1), device=affine.device, dtype=affine.dtype)
        row[:, 0, -1] = 1
        affine = torch.cat([affine, row], dim=1)  # [b, dim+1, dim+1]
        affine = ((moving_t2p @ affine - fixed_t2p)[:, :reg.dims]).contiguous()   # this is synchronized

        # synchronize grid stats
        logger.info(f"Gathering grid stats")
        _, grid_gather_stats = gather_and_concat(grid, reg.rank, True, reg.dim_to_shard-1, gather_stats_only=True)
        grid_gather_stats = refactor_grid_to_image_stats(grid_gather_stats) 

        logger.info(f"Calculating bbox")
        min_coords, max_coords = calculate_bbox_from_gather_stats(grid_gather_stats, reg.rank, reg.dims)
        # get updated grid
        logger.info(f"Warping grid")
        grid = fireants_interpolator.affine_warp(affine=affine, grid=grid, min_coords=min_coords, max_coords=max_coords)
        logger.info(f"Grid shape: {grid.shape}")

        # save grid in disk and reload into cpu and concat (too big to fit in GPU)

        for i in range(reg.opt_size):
            grid_i = grid[i].detach()  # [H, W, D, dim]
            additional_grids = [grid_i.cpu()]
            # get grid parallel ids
            rank = parallel_state.get_parallel_state().get_rank()
            grid_parallel_group = parallel_state.get_parallel_state().get_current_gp_group()
            grid_parallel_group_obj = parallel_state.get_parallel_state().gp_group_obj
            master_rank = grid_parallel_group[0]
            is_master = (rank == grid_parallel_group[0])
            logger.info(f"Rank {rank} is master: {is_master}")

            # for others, we will send or receive the grid
            for pollrank in grid_parallel_group[1:]:
                # fetch
                if is_master:
                    print(f"Rank {rank} is fetching from {pollrank}")
                    tensor, req, sz = async_recv_tensor_ack(grid_i, pollrank, rank, False) # grid_i is passed for its shape length
                    req.wait()
                    tensor = tensor.cpu()
                    additional_grids.append(tensor)
                else:
                    # im the pollrank, 
                    if rank == pollrank:
                        print(f"Rank {rank} is sending to {master_rank}")
                        grid_i = grid_i.to(parallel_state.get_device())
                        async_send_tensor_ack(grid_i, master_rank, rank, False)
                # distributed
                torch.distributed.barrier(group=grid_parallel_group_obj)

            if is_master:
                # gather all grids
                grid_i = torch.cat(additional_grids, dim=reg.dim_to_shard).cpu().numpy()
                # save grid
                savefile = filenames[i]
                # get itk data
                if len(fixed_image.images) < i:     # this image is probably broadcasted then
                    itk_data = fixed_image.images[0].itk_image
                else:
                    itk_data = fixed_image.images[i].itk_image
                print("Writing to SimpleITK image... ")
                # copy itk data
                warp = sitk.GetImageFromArray(grid_i)
                warp.CopyInformation(itk_data)
                sitk.WriteImage(warp, savefile)
                print(f"Saved grid {i} to {savefile}")


    @torch.no_grad()
    def save_as_scipy_transforms(reg, filenames: Union[str, List[str]], downsample_scale=1, dtype=np.float32):
        '''
        Save the warp field in scipy format - typically used for submission to learn2reg challenge or similar
        '''

        # not supported for distributed
        if "distributed" in reg.__class__.__name__.lower():
            raise ValueError("Saving as scipy transforms is not supported for distributed registration")

        if isinstance(filenames, str):
            filenames = [filenames]
        assert len(filenames) == reg.opt_size, "Number of filenames should match the number of warps"
        # get the warp field
        fixed_image: BatchedImages = reg.fixed_images
        moving_image: BatchedImages = reg.moving_images

        # get metadata to convert torch space to physical space
        moving_t2p = moving_image.get_torch2phy()
        fixed_t2p = fixed_image.get_torch2phy()

        moved_params = reg.get_warp_parameters(fixed_image, moving_image)   # contains affine and grid

        moved_coords = moved_params['grid'].detach()
        affine = moved_params['affine']
        batch_size = affine.shape[0]
        dims = reg.dims
        # get (A-I)x
        affine[:, :dims, :dims] = affine[:, :dims, :dims] - torch.eye(dims, device=affine.device, dtype=affine.dtype)
        # get displacement coordinates in torch space
        disp = fireants_interpolator.affine_warp(affine=affine, grid=moved_coords)
        # convert to physical space
        sizes = list(disp.shape[1:-1])
        sizes = sizes[::-1]
        for i in range(dims):
            disp[..., i] *= ((sizes[i]-1)/2)   # assumed align_corners = True

       # save to nifti or npz file
        for i in range(reg.opt_size):
            moved_disp = disp[i].detach().cpu().numpy()  # [ZYX,(xyz)]
            if dims == 3:
                moved_disp = moved_disp.transpose(2, 1, 0, 3)
            elif dims == 2:
                moved_disp = moved_disp.transpose(1, 0, 2)
            savefile = filenames[i]
            if savefile.endswith('.npz'): # save in npz format
                np.savez(savefile, moved_disp.astype(np.float16))
            elif savefile.endswith('.nii.gz') or savefile.endswith('.nii'): # save in nifti format
                import nibabel as nib
                nib.save(nib.Nifti1Image(moved_disp, np.eye(4)), savefile)
            else:
                raise ValueError(f"Unsupported file extension: {savefile}")

save_as_ants_transforms(reg, filenames, save_inverse=False)

Save deformation fields in ANTs-compatible format.

Converts the learned deformation fields to displacement fields in physical space and saves them in a format that can be used by ANTs registration tools. The displacement fields are saved as multi-component images where each component represents the displacement along one spatial dimension.

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

    Path(s) where the transform(s) should be saved. If a single string is provided for multiple transforms, it will be treated as the first filename. For multiple transforms, provide a list of filenames matching the number of transforms.

Raises:
  • AssertionError –

    If number of filenames doesn't match number of transforms (opt_size)

Physical Space Coordinates

The saved transforms are in physical space coordinates, not normalized [-1,1] space. The displacement fields are saved with the same orientation and spacing as the fixed images.

Source code in fireants/registration/deformablemixin.py
 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
@torch.no_grad()
def save_as_ants_transforms(reg, filenames: Union[str, List[str]], save_inverse=False):
    """Save deformation fields in ANTs-compatible format.

    Converts the learned deformation fields to displacement fields in physical space
    and saves them in a format that can be used by ANTs registration tools.
    The displacement fields are saved as multi-component images where each component
    represents the displacement along one spatial dimension.

    Args:
        filenames (Union[str, List[str]]): Path(s) where the transform(s) should be saved.
            If a single string is provided for multiple transforms, it will be treated
            as the first filename. For multiple transforms, provide a list of filenames
            matching the number of transforms.

    Raises:
        AssertionError: If number of filenames doesn't match number of transforms (opt_size)

    !!! caution "Physical Space Coordinates"
        The saved transforms are in physical space coordinates, not normalized [-1,1] space.
        The displacement fields are saved with the same orientation and spacing as the 
        fixed images.
    """
    if "distributed" in reg.__class__.__name__.lower():
        logger.info("Rerouting to distributed transform")
        return reg.save_as_ants_transforms_distributed(filenames, save_inverse)

    if isinstance(filenames, str):
        filenames = [filenames]
    assert len(filenames) == reg.opt_size, "Number of filenames should match the number of warps"
    # get the warp field
    fixed_image: BatchedImages = reg.fixed_images
    moving_image: BatchedImages = reg.moving_images

    # get metadata to convert torch space to physical space
    moving_t2p = moving_image.get_torch2phy()
    fixed_t2p = fixed_image.get_torch2phy()

    # get params or inverse params
    if save_inverse:
        moved_params = reg.get_inverse_warp_parameters(fixed_image, moving_image)   # contains affine and grid
    else:
        moved_params = reg.get_warp_parameters(fixed_image, moving_image)   # contains affine and grid

    moved_coords = moved_params['grid'].detach()
    affine = moved_params['affine']

    if save_inverse:
        moved_coords = torch.einsum('bij, b...j->b...i', fixed_t2p[:, :reg.dims, :reg.dims], moved_coords).contiguous()  # convert to fixed space
    else:
        # convert to ants format
        moved_coords = torch.einsum('bij, b...j->b...i', moving_t2p[:, :reg.dims, :reg.dims], moved_coords).contiguous()  # convert to moving space

    # create ants affine
    row = torch.zeros((affine.shape[0], 1, reg.dims+1), device=affine.device, dtype=affine.dtype)
    row[:, 0, -1] = 1
    affine = torch.cat([affine, row], dim=1)  # [b, dim+1, dim+1]
    # 
    if save_inverse:
        affine = ((fixed_t2p @ affine - moving_t2p)[:, :reg.dims]).contiguous()
    else:
        affine = ((moving_t2p @ affine - fixed_t2p)[:, :reg.dims]).contiguous()

    # create moved_disp
    moved_disp_final = fireants_interpolator.affine_warp(affine=affine, grid=moved_coords)

    # save 
    for i in range(reg.opt_size):
        moved_disp = moved_disp_final[i].detach().cpu().numpy()  # [H, W, D, 3]
        savefile = filenames[i]
        # get itk image
        if len(fixed_image.images) < i:     # this image is probably broadcasted then
            itk_data = fixed_image.images[0].itk_image if not save_inverse else moving_image.images[0].itk_image
        else:
            itk_data = fixed_image.images[i].itk_image if not save_inverse else moving_image.images[i].itk_image
        # copy itk data
        warp = sitk.GetImageFromArray(moved_disp)
        warp.CopyInformation(itk_data)
        sitk.WriteImage(warp, savefile)

save_as_ants_transforms_distributed(reg, filenames, save_inverse=False)

Save the warp field in ANTs compatible format for distributed registration. This function is called by save_as_ants_transforms if the registration is distributed.

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

    Path(s) where the transform(s) should be saved. If a single string is provided for multiple transforms, it will be treated as the first filename. For multiple transforms, provide a list of filenames matching the number of transforms.

Raises:
  • AssertionError –

    If number of filenames doesn't match number of transforms (opt_size)

Source code in fireants/registration/deformablemixin.py
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
@torch.no_grad()
def save_as_ants_transforms_distributed(reg, filenames: Union[str, List[str]], save_inverse=False):
    '''
    Save the warp field in ANTs compatible format for distributed registration.
    This function is called by `save_as_ants_transforms` if the registration is distributed.

    Args:
        filenames (Union[str, List[str]]): Path(s) where the transform(s) should be saved.
            If a single string is provided for multiple transforms, it will be treated
            as the first filename. For multiple transforms, provide a list of filenames
            matching the number of transforms.

    Raises:
        AssertionError: If number of filenames doesn't match number of transforms (opt_size)
    ''' 
    if "distributed" not in reg.__class__.__name__.lower():
        raise ValueError("This function should only be called by distributed registration")

    assert not save_inverse, "Inverse transforms are not supported in distributed mode"

    if isinstance(filenames, str):
        filenames = [filenames]
    assert len(filenames) == reg.opt_size, "Number of filenames should match the number of warps"
    logger.info(f"Saving {reg.opt_size} transforms to {filenames}")
    # get the warp field
    fixed_image: BatchedImages = reg.fixed_images
    moving_image: BatchedImages = reg.moving_images 
    moved_params = reg.get_warp_parameters(fixed_image, moving_image)   # contains affine and grid

    # the idea is that we have Ax + u in torch space,
    # we want Am . (Ax + u) - Af . x  (coordinates translated to moving frame - identity at fixed frame)
    # this is equal to (Am . A - Af) x + Am . u

    # metadata
    moving_t2p = moving_image.get_torch2phy()
    fixed_t2p = fixed_image.get_torch2phy()

    logger.info(f"device: {parallel_state.get_device()}, moving_t2p: {moving_t2p.device}, fixed_t2p: {fixed_t2p.device}, moved_params: {moved_params['grid'].device}")

    # gather grid
    grid = moved_params['grid']   # [B,N,dim]
    affine = moved_params['affine']  # [B, dim, dim+1]
    grid = torch.einsum('bij, b...j->b...i', moving_t2p[:, :reg.dims, :reg.dims], grid).contiguous()  # convert to moving space

    # create ants affine
    row = torch.zeros((affine.shape[0], 1, reg.dims+1), device=affine.device, dtype=affine.dtype)
    row[:, 0, -1] = 1
    affine = torch.cat([affine, row], dim=1)  # [b, dim+1, dim+1]
    affine = ((moving_t2p @ affine - fixed_t2p)[:, :reg.dims]).contiguous()   # this is synchronized

    # synchronize grid stats
    logger.info(f"Gathering grid stats")
    _, grid_gather_stats = gather_and_concat(grid, reg.rank, True, reg.dim_to_shard-1, gather_stats_only=True)
    grid_gather_stats = refactor_grid_to_image_stats(grid_gather_stats) 

    logger.info(f"Calculating bbox")
    min_coords, max_coords = calculate_bbox_from_gather_stats(grid_gather_stats, reg.rank, reg.dims)
    # get updated grid
    logger.info(f"Warping grid")
    grid = fireants_interpolator.affine_warp(affine=affine, grid=grid, min_coords=min_coords, max_coords=max_coords)
    logger.info(f"Grid shape: {grid.shape}")

    # save grid in disk and reload into cpu and concat (too big to fit in GPU)

    for i in range(reg.opt_size):
        grid_i = grid[i].detach()  # [H, W, D, dim]
        additional_grids = [grid_i.cpu()]
        # get grid parallel ids
        rank = parallel_state.get_parallel_state().get_rank()
        grid_parallel_group = parallel_state.get_parallel_state().get_current_gp_group()
        grid_parallel_group_obj = parallel_state.get_parallel_state().gp_group_obj
        master_rank = grid_parallel_group[0]
        is_master = (rank == grid_parallel_group[0])
        logger.info(f"Rank {rank} is master: {is_master}")

        # for others, we will send or receive the grid
        for pollrank in grid_parallel_group[1:]:
            # fetch
            if is_master:
                print(f"Rank {rank} is fetching from {pollrank}")
                tensor, req, sz = async_recv_tensor_ack(grid_i, pollrank, rank, False) # grid_i is passed for its shape length
                req.wait()
                tensor = tensor.cpu()
                additional_grids.append(tensor)
            else:
                # im the pollrank, 
                if rank == pollrank:
                    print(f"Rank {rank} is sending to {master_rank}")
                    grid_i = grid_i.to(parallel_state.get_device())
                    async_send_tensor_ack(grid_i, master_rank, rank, False)
            # distributed
            torch.distributed.barrier(group=grid_parallel_group_obj)

        if is_master:
            # gather all grids
            grid_i = torch.cat(additional_grids, dim=reg.dim_to_shard).cpu().numpy()
            # save grid
            savefile = filenames[i]
            # get itk data
            if len(fixed_image.images) < i:     # this image is probably broadcasted then
                itk_data = fixed_image.images[0].itk_image
            else:
                itk_data = fixed_image.images[i].itk_image
            print("Writing to SimpleITK image... ")
            # copy itk data
            warp = sitk.GetImageFromArray(grid_i)
            warp.CopyInformation(itk_data)
            sitk.WriteImage(warp, savefile)
            print(f"Saved grid {i} to {savefile}")

save_as_scipy_transforms(reg, filenames, downsample_scale=1, dtype=np.float32)

Save the warp field in scipy format - typically used for submission to learn2reg challenge or similar

Source code in fireants/registration/deformablemixin.py
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
@torch.no_grad()
def save_as_scipy_transforms(reg, filenames: Union[str, List[str]], downsample_scale=1, dtype=np.float32):
    '''
    Save the warp field in scipy format - typically used for submission to learn2reg challenge or similar
    '''

    # not supported for distributed
    if "distributed" in reg.__class__.__name__.lower():
        raise ValueError("Saving as scipy transforms is not supported for distributed registration")

    if isinstance(filenames, str):
        filenames = [filenames]
    assert len(filenames) == reg.opt_size, "Number of filenames should match the number of warps"
    # get the warp field
    fixed_image: BatchedImages = reg.fixed_images
    moving_image: BatchedImages = reg.moving_images

    # get metadata to convert torch space to physical space
    moving_t2p = moving_image.get_torch2phy()
    fixed_t2p = fixed_image.get_torch2phy()

    moved_params = reg.get_warp_parameters(fixed_image, moving_image)   # contains affine and grid

    moved_coords = moved_params['grid'].detach()
    affine = moved_params['affine']
    batch_size = affine.shape[0]
    dims = reg.dims
    # get (A-I)x
    affine[:, :dims, :dims] = affine[:, :dims, :dims] - torch.eye(dims, device=affine.device, dtype=affine.dtype)
    # get displacement coordinates in torch space
    disp = fireants_interpolator.affine_warp(affine=affine, grid=moved_coords)
    # convert to physical space
    sizes = list(disp.shape[1:-1])
    sizes = sizes[::-1]
    for i in range(dims):
        disp[..., i] *= ((sizes[i]-1)/2)   # assumed align_corners = True

   # save to nifti or npz file
    for i in range(reg.opt_size):
        moved_disp = disp[i].detach().cpu().numpy()  # [ZYX,(xyz)]
        if dims == 3:
            moved_disp = moved_disp.transpose(2, 1, 0, 3)
        elif dims == 2:
            moved_disp = moved_disp.transpose(1, 0, 2)
        savefile = filenames[i]
        if savefile.endswith('.npz'): # save in npz format
            np.savez(savefile, moved_disp.astype(np.float16))
        elif savefile.endswith('.nii.gz') or savefile.endswith('.nii'): # save in nifti format
            import nibabel as nib
            nib.save(nib.Nifti1Image(moved_disp, np.eye(4)), savefile)
        else:
            raise ValueError(f"Unsupported file extension: {savefile}")