"""Alignment records and enhanced records for registration (angle, offset, weight, etc.)."""
from math import pi
import os
from typing import Any
import warnings
import numpy as np
from numpy.typing import NDArray
import nornir_imageregistration
from nornir_imageregistration.transforms import ITransform
[docs]
class AlignmentRecord(object):
"""
Records basic registration information as an angle and offset between a fixed and moving image
If the offset is zero the center of both images occupy the same point.
The offset determines the translation of the moving image over the fixed image.
There is no support for scale, and there should not be unless added as another variable to the alignment record
:param array peak: Translation vector for moving image
:param float weight: The strength of the alignment
:param float angle: Angle to rotate moving image in degrees
"""
_peak: NDArray[np.floating]
_angle: float
_weight: float
_flippedud: bool
_scale: float
_peak_ratio: float | None
@property
def angle(self) -> float:
"""Rotation in degrees"""
return self._angle
@property
def rangle(self) -> float:
"""Rotation in radians"""
return self._angle * (pi / 180.0)
@property
def weight(self) -> float:
"""Quantifies the quality of the alignment"""
return self._weight
@weight.setter
def weight(self, value: float):
self._weight = float(value)
@property
def peak_ratio(self) -> float | None:
"""Primary / masked-2nd-peak uniqueness, or None if not measured."""
return self._peak_ratio
@peak_ratio.setter
def peak_ratio(self, value: float | None):
self._peak_ratio = None if value is None else float(value)
@property
def flippedud(self) -> bool:
"""True if the warped image was flipped vertically for the alignment"""
return self._flippedud
@flippedud.setter
def flippedud(self, value: bool):
self._flippedud = value
@property
def peak(self) -> NDArray[np.floating]:
"""Translation vector for the alignment"""
return self._peak
[docs]
def WeightKey(self) -> float:
return self._weight
@property
def scale(self) -> float:
return self._scale
@scale.setter
def scale(self, value: float):
"""Scales the source space to target space, including peak"""
self._scale = value
[docs]
def translate(self, value: NDArray[np.floating]):
"""Translates the peak position using tuple (Y,X)"""
self._peak += value
[docs]
def Invert(self):
"""
Returns a new alignment record with the coordinates of the peak reversed
Used to change the frame of reference of the alignment from one tile to another
"""
return AlignmentRecord(
(-self.peak[0], -self.peak[1]),
self.weight,
self.angle,
peak_ratio=self._peak_ratio)
def __repr__(self):
s = '{x:.2f}x, {y:.2f}y Weight: {w:.2f}'.format(x=self._peak[1], y=self._peak[0], w=self._weight)
if self._angle != 0:
s += f' Angle: {self._angle:.2f}'
if self._peak_ratio is not None:
s += f' PeakRatio: {self._peak_ratio:.2f}'
# s = 'angle: ' + str(self._angle) + ' offset: ' + str(self._peak) + ' weight: ' + str(self._weight)
if self.flippedud:
s += ' Flipped up/down'
return s
def __str__(self):
return f'Offset: {self.__repr__()}'
def __init__(self, peak: NDArray[np.floating] | tuple[float, float],
weight: float,
angle: float = 0.0,
flipped_ud: bool = False,
scale: float = 1.0,
peak_ratio: float | None = None):
"""
:param float scale: Scales source space by this factor to map into target space
:param peak_ratio: Optional primary/2nd-peak uniqueness from phase correlation
"""
if not isinstance(angle, float):
angle = float(angle)
self._angle = angle
if not isinstance(peak, np.ndarray):
peak = np.array(peak)
self._scale = scale
self._peak = peak
self._weight = float(weight)
self._flippedud = flipped_ud
self._peak_ratio = None if peak_ratio is None else float(peak_ratio)
[docs]
def CorrectPeakForOriginalImageSize(self, TargetImageShape: NDArray[np.integer],
SourceImageShape: NDArray[np.integer]):
offset = self.peak
if self.peak is None:
offset = (0, 0)
return nornir_imageregistration.transforms.factory.__CorrectOffsetForMismatchedImageSizes(offset=offset,
target_image_shape=TargetImageShape,
source_image_shape=SourceImageShape)
# def GetTransformedCornerPointsForImage(self, warpedImageSize: NDArray[np.integer]) -> NDArray[np.floating]:
# """
# Return the corners of a bounding box in the target space after the transform is applied.
# """
# return nornir_imageregistration.transforms.factory.GetTransformedRigidCornerPointsForImage(warpedImageSize,
# self.rangle,
# self.peak,
# self.flippedud)
# return nornir_imageregistration.transforms.factory.CreateRigidTransform(warped_offset=self.peak,
# rangle=self.rangle,
# target_image_shape=fixedImageSize,
# source_image_shape=warpedImageSize,
# flip_ud=self.flippedud
# )
# return nornir_imageregistration.transforms.factory.CreateRigidMeshTransform(target_image_shape=fixedImageSize,
# source_image_shape=warpedImageSize,
# rangle=self.rangle,
# warped_offset=self.peak,
# flip_ud=self.flippedud,
# scale=self.scale)
[docs]
def ToStos(self,
ImagePath: str,
WarpedImagePath: str,
FixedImageMaskPath: str | None = None,
WarpedImageMaskPath: str | None = None,
PixelSpacing: float | int = 1):
"""Convert the alignment record to a StosFile"""
stos = nornir_imageregistration.StosFile()
stos.ControlImageName = os.path.basename(ImagePath)
stos.ControlImagePath = os.path.dirname(ImagePath)
stos.MappedImageName = os.path.basename(WarpedImagePath)
stos.MappedImagePath = os.path.dirname(WarpedImagePath)
if FixedImageMaskPath is not None:
stos.ControlMaskName = os.path.basename(FixedImageMaskPath)
stos.ControlMaskPath = os.path.dirname(FixedImageMaskPath)
if WarpedImageMaskPath is not None:
stos.MappedMaskName = os.path.basename(WarpedImageMaskPath)
stos.MappedMaskPath = os.path.dirname(WarpedImageMaskPath)
(ControlHeight, ControlWidth) = nornir_imageregistration.core.GetImageSize(ImagePath)
stos.ControlImageDim = [ControlWidth, ControlHeight] # type: ignore[assignment]
(MappedHeight, MappedWidth) = nornir_imageregistration.core.GetImageSize(WarpedImagePath)
stos.MappedImageDim = [MappedWidth, MappedHeight] # type: ignore[assignment]
# transformTemplate = "FixedCenterOfRotationAffineTransform_double_2_2 vp 8 %(cos)g %(negsin)g %(sin)g %(cos)g %(x)g %(y)g 1 1 fp 2 %(mapwidth)d %(mapheight)d"
# stos.transform = transformTemplate % {'cos' : cos(Match.angle * numpy.pi / 180),
# 'sin' : sin(Match.angle * numpy.pi / 180),
# 'negsin' : -sin(Match.angle * numpy.pi / 180),
# 'x' : Match.peak[0],
# 'y' : -Match.peak[1],
# 'mapwidth' : stos.MappedImageDim[0]/2,
# 'mapheight' : stos.MappedImageDim[1]/2}
# transformTemplate = "GridTransform_double_2_2 vp 8 %(coordString)s fp 7 0 1 1 0 0 %(width)d %(height)d"
# I have checked the dimensions that should be written for Grid transform against the original SCI code. The image dimensions should be the actual dimensions and not
# have a -1 to account for the zero origin
# stos.transform = transformTemplate % {'coordString': coordString,
# 'width': stos.MappedImageDim[0],
# 'height': stos.MappedImageDim[1]}
transform = self.ToImageTransform(target_image_shape=(ControlHeight, ControlWidth),
source_image_shape=(MappedHeight, MappedWidth))
stos.Transform = transform.ToITKString()
stos.Downsample = PixelSpacing
# print "Done!"
return stos
class EnhancedAlignmentRecord(AlignmentRecord):
"""
An extension of the AlignmentRecord class that also records the Fixed and Warped Points
"""
_ID: Any
_TargetPoint: NDArray[np.floating]
_SourcePoint: NDArray[np.floating]
_cutoff_percent: float | None
_cutoff_value: float | None
_roi_candidate: str | None
@property
def ID(self):
return self._ID
@property
def TargetPoint(self):
return self._TargetPoint
@property
def SourcePoint(self):
return self._SourcePoint
@property
def AdjustedTargetPoint(self):
return self._TargetPoint + self.peak
@property
def TargetOffset(self) -> NDArray[np.floating]:
"""The amount to add to the target point to get the adjusted target point"""
return self.peak
@property
def AdjustedSourcePoint(self):
"""Note if there is rotation involved this point is not reliable"""
return self._SourcePoint - self.peak
@property
def CutoffPercent(self) -> float | None:
"""The percentage of pixels in the correlation image that are below the cutoff value used to locate the peak"""
return self._cutoff_percent
@property
def CutoffValue(self) -> float | None:
"""The value below which pixels in the correlation image are considered to be below the cutoff"""
return self._cutoff_value
@property
def roi_candidate(self) -> str | None:
"""Which source ROI produced this peak: ``'rigid'``, ``'exact'``, or None."""
return self._roi_candidate
@roi_candidate.setter
def roi_candidate(self, value: str | None) -> None:
self._roi_candidate = None if value is None else str(value)
def __init__(self,
ID,
TargetPoint: NDArray[np.floating],
SourcePoint: NDArray[np.floating],
peak: NDArray[np.floating],
weight: float,
angle: float = 0.0,
flipped_ud: bool = False,
cutoff_percent: float | None = None,
cutoff_value: float | None = None,
peak_ratio: float | None = None,
roi_candidate: str | None = None):
super(EnhancedAlignmentRecord, self).__init__(
peak=peak, weight=weight, angle=angle, flipped_ud=flipped_ud, peak_ratio=peak_ratio)
self._ID = ID
self._TargetPoint = TargetPoint
self._SourcePoint = SourcePoint
self._cutoff_percent = cutoff_percent
self._cutoff_value = cutoff_value
self._roi_candidate = None if roi_candidate is None else str(roi_candidate)
def __repr__(self):
return f'ID: {self._ID} {super(EnhancedAlignmentRecord, self).__repr__()}'
def __str__(self):
return self.__repr__()