Source code for ClearMap.Alignment.landmarks_registration.registration_data

from pathlib import Path
from typing import Optional, TYPE_CHECKING
from dataclasses import dataclass

import itk
import numpy as np


# WARNING: required to have access to module
#  We execute two commands to force itk to load subpackages, so as to have the following type available.
#  types hidden by itk lazy loading:
#    - itk.itkImagePython
#    - itk.elxParameterObjectPython.elastixParameterObject
if TYPE_CHECKING:
    from itk.elxParameterObjectPython import elastixParameterObject
    from itk import itkImagePython

_itk_warmed = False

[docs] def ensure_itk_warmed() -> None: global _itk_warmed if _itk_warmed: return print('Initialising ITK and loading subpackages...') itk.ParameterObject.New() print('Loaded elastixParameterObject') _itk_warmed = True
# Warning: below, the geometric dimension of the image is merely inferred from np.array shape, # this works only for 1-channel images. # TODO: deal with multichannel images
[docs] def index_to_physical_matrix(origin, spacing, direction=None): if len(origin) != len(spacing): raise ValueError("origin and spacing should be coordinate lists of the same size.") if direction is None: direction = np.eye(len(origin)) matrix_dim = len(origin) + 1 if direction.shape != (matrix_dim - 1,) * 2: raise ValueError("direction shape must be (d,d) where d is the length of origin") scaling_matrix = np.eye(matrix_dim, dtype="float64") translation_matrix = np.eye(matrix_dim, dtype="float64") rotation_matrix = np.eye(matrix_dim, dtype="float64") for i in range(matrix_dim - 1): scaling_matrix[i, i] = spacing[i] translation_matrix[i, -1] = origin[i] rotation_matrix[:-1, :-1] = direction print(rotation_matrix, translation_matrix, scaling_matrix) return np.linalg.inv(translation_matrix) @ rotation_matrix @ scaling_matrix
[docs] class ITKImage: def __init__(self, source: str | np.ndarray, perm=None): """_summary_ Arguments: ---------- source (str | np.ndarray): path to the image file or the image array perm (_type_, optional): permutation to apply to the coords (see code). Defaults to None. If None, the perm is the reversing of the coords Raises: ValueError: _description_ ValueError: _description_ """ if isinstance(source, (str, Path)): source = str(source) if source.endswith('.npy'): self.image = np.load(source) # TODO: see if same as below else: self.image = itk.imread(source, itk.F) else: self.image = itk.GetImageFromArray(np.ascontiguousarray(source)) self.ndim = len(self.image.shape) if perm is not None: if len(perm) != self.ndim: raise ValueError("the provided perm list is not a permutation of range(ndim)") else: perm = list(range(self.ndim))[::-1] self.input_to_numpy_axis_perm = perm try: self.numpy_to_input_axis_perm = [perm.index(i) for i in range(self.ndim)] except: raise ValueError("the provided perm list is not a permutation of range(ndim)") self.meta_dict = dict(self.image) self._ipmatrix = None @property def index_to_physical_matrix(self): """Return the physical to index affine transformation, as a projective transform.""" if self._ipmatrix is None: self._ipmatrix = index_to_physical_matrix( self.meta_dict["origin"], self.meta_dict["spacing"], self.meta_dict["direction"], ) return self._ipmatrix.transpose() @property def index_to_physical_pair(self): """ Return the physical to index affine transformation, as a pair (matrix, translation_vector). """ dim = self.image.ndim translation_vector = np.array( list(self.image.TransformIndexToPhysicalPoint((0,) * dim)) ).reshape(1, -1) matrix = ( np.array( [ list( self.image.TransformIndexToPhysicalPoint( list((1 if i == j else 0 for j in range(dim))) ) ) for i in range(dim) ] ) - translation_vector ) return matrix, translation_vector # def physical_to_index(self, points): # return (points @ np.linalg.inv(self.index_to_physical_matrix))[:, :-1]
[docs] def physical_to_index(self, points, perm=None): if perm is None: perm = self.input_to_numpy_axis_perm matrix, translation_vector = self.index_to_physical_pair return ((points - translation_vector) @ np.linalg.inv(matrix))[:, perm]
[docs] def index_to_physical(self, points, perm=None): if perm is None: perm = self.numpy_to_input_axis_perm matrix, translation_vector = self.index_to_physical_pair return (points[:, perm]) @ matrix + translation_vector
[docs] def nice_contrast_limits(self): return (0, np.percentile(self.image, 99.97))
[docs] def brutal_rotate(self, rotation_axis, power=1): """Perform a 90° rotation of the 3d image without editing the metadata accordingly.""" if self.image.ndim != 3: raise ValueError("this method is only for 3d images") power = power % 4 moving = [i for i in range(3) if i != rotation_axis] for i in range(power): self.image = itk.GetImageFromArray( np.ascontiguousarray( np.flip(np.swapaxes(self.image, *moving), moving[0]) ) )
[docs] @dataclass class RegistrationData: """ Class that retains the data of a registration attempt. Encapsulate fixed and moving image and points, chosen elastix registration parameters, computed transform parameters, transformed image, transformed fixed points, registration scores for various metrics. All points are stored in index coordinates (numpy order), as the data arrays of napari. """ fixed_image: ITKImage moving_image: ITKImage fixed_points: Optional[np.array] = None moving_points: Optional[np.array] = None reg_params: Optional['elastixParameterObject'] = None pullback_image: Optional['itkImagePython'] = None transform_params: Optional['elastixParameterObject'] = None