In this exercise, we will use the SimpleITK library to perform 3D image registration. You will familiarize yourself with the registration process, its challenges and the different elements you can tune to improve the registration results.
After completing this exercise, the student should be able to do the following:
- Use SimpleITK for 3D registration.
You can find an important description of the theory in - Exercise theory
In this exercise, we will introduce SimpleITK. SimpleITK is an open-source image analysis toolkit designed to provide a simple and efficient way to access and manipulate 3D image data. It is a simplified, user-friendly interface to the Insight Segmentation and Registration Toolkit (ITK), a widely used image analysis library for image processing. SimpleITK is written in C++, but provides bindings for several programming languages, including Python. You can find more information about SimpleITK here.
You can install SimpleITK with the command pip install SimpleITK
.
We will use the virtual environment from the previous exercise (course02502).
Start by importing some useful functions:
import numpy as np
import matplotlib.pyplot as plt
import SimpleITK as sitk
from IPython.display import clear_output
from skimage.util import img_as_ubyte
and defining some useful functions:
def imshow_orthogonal_view(sitkImage, origin = None, title=None):
"""
Display the orthogonal views of a 3D volume from the middle of the volume.
Parameters
----------
sitkImage : SimpleITK image
Image to display.
origin : array_like, optional
Origin of the orthogonal views, represented by a point [x,y,z].
If None, the middle of the volume is used.
title : str, optional
Super title of the figure.
Note:
On the axial and coronal views, patient's left is on the right
On the sagittal view, patient's anterior is on the left
"""
data = sitk.GetArrayFromImage(sitkImage)
if origin is None:
origin = np.array(data.shape) // 2
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
data = img_as_ubyte(data/np.max(data))
axes[0].imshow(data[origin[0], ::-1, ::-1], cmap='gray')
axes[0].set_title('Axial')
axes[1].imshow(data[::-1, origin[1], ::-1], cmap='gray')
axes[1].set_title('Coronal')
axes[2].imshow(data[::-1, ::-1, origin[2]], cmap='gray')
axes[2].set_title('Sagittal')
[ax.set_axis_off() for ax in axes]
if title is not None:
fig.suptitle(title, fontsize=16)
def overlay_slices(sitkImage0, sitkImage1, origin = None, title=None):
"""
Overlay the orthogonal views of a two 3D volume from the middle of the volume.
The two volumes must have the same shape. The first volume is displayed in red,
the second in green.
Parameters
----------
sitkImage0 : SimpleITK image
Image to display in red.
sitkImage1 : SimpleITK image
Image to display in green.
origin : array_like, optional
Origin of the orthogonal views, represented by a point [x,y,z].
If None, the middle of the volume is used.
title : str, optional
Super title of the figure.
Note:
On the axial and coronal views, patient's left is on the right
On the sagittal view, patient's anterior is on the left
"""
vol0 = sitk.GetArrayFromImage(sitkImage0)
vol1 = sitk.GetArrayFromImage(sitkImage1)
if vol0.shape != vol1.shape:
raise ValueError('The two volumes must have the same shape.')
if np.min(vol0) < 0 or np.min(vol1) < 0: # Remove negative values - Relevant for the noisy images
vol0[vol0 < 0] = 0
vol1[vol1 < 0] = 0
if origin is None:
origin = np.array(vol0.shape) // 2
sh = vol0.shape
R = img_as_ubyte(vol0/np.max(vol0))
G = img_as_ubyte(vol1/np.max(vol1))
vol_rgb = np.zeros(shape=(sh[0], sh[1], sh[2], 3), dtype=np.uint8)
vol_rgb[:, :, :, 0] = R
vol_rgb[:, :, :, 1] = G
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(vol_rgb[origin[0], ::-1, ::-1, :])
axes[0].set_title('Axial')
axes[1].imshow(vol_rgb[::-1, origin[1], ::-1, :])
axes[1].set_title('Coronal')
axes[2].imshow(vol_rgb[::-1, ::-1, origin[2], :])
axes[2].set_title('Sagittal')
[ax.set_axis_off() for ax in axes]
if title is not None:
fig.suptitle(title, fontsize=16)
def composite2affine(composite_transform, result_center=None):
"""
Combine all of the composite transformation's contents to form an equivalent affine transformation.
Args:
composite_transform (SimpleITK.CompositeTransform): Input composite transform which contains only
global transformations, possibly nested.
result_center (tuple,list): The desired center parameter for the resulting affine transformation.
If None, then set to [0,...]. This can be any arbitrary value, as it is
possible to change the transform center without changing the transformation
effect.
Returns:
SimpleITK.AffineTransform: Affine transformation that has the same effect as the input composite_transform.
Source:
https://github.com/InsightSoftwareConsortium/SimpleITK-Notebooks/blob/master/Python/22_Transforms.ipynb
"""
# Flatten the copy of the composite transform, so no nested composites.
flattened_composite_transform = sitk.CompositeTransform(composite_transform)
flattened_composite_transform.FlattenTransform()
tx_dim = flattened_composite_transform.GetDimension()
A = np.eye(tx_dim)
c = np.zeros(tx_dim) if result_center is None else result_center
t = np.zeros(tx_dim)
for i in range(flattened_composite_transform.GetNumberOfTransforms() - 1, -1, -1):
curr_tx = flattened_composite_transform.GetNthTransform(i).Downcast()
# The TranslationTransform interface is different from other
# global transformations.
if curr_tx.GetTransformEnum() == sitk.sitkTranslation:
A_curr = np.eye(tx_dim)
t_curr = np.asarray(curr_tx.GetOffset())
c_curr = np.zeros(tx_dim)
else:
A_curr = np.asarray(curr_tx.GetMatrix()).reshape(tx_dim, tx_dim)
c_curr = np.asarray(curr_tx.GetCenter())
# Some global transformations do not have a translation
# (e.g. ScaleTransform, VersorTransform)
get_translation = getattr(curr_tx, "GetTranslation", None)
if get_translation is not None:
t_curr = np.asarray(get_translation())
else:
t_curr = np.zeros(tx_dim)
A = np.dot(A_curr, A)
t = np.dot(A_curr, t + c - c_curr) + t_curr + c_curr - c
return sitk.AffineTransform(A.flatten(), t, c)
# Callback invoked when the StartEvent happens, sets up our new data.
def start_plot():
global metric_values, multires_iterations
metric_values = []
multires_iterations = []
# Callback invoked when the EndEvent happens, do cleanup of data and figure.
def end_plot():
global metric_values, multires_iterations
del metric_values
del multires_iterations
# Close figure, we don't want to get a duplicate of the plot latter on.
plt.close()
# Callback invoked when the IterationEvent happens, update our data and display new figure.
def plot_values(registration_method):
global metric_values, multires_iterations
metric_values.append(registration_method.GetMetricValue())
# Clear the output area (wait=True, to reduce flickering), and plot current data
clear_output(wait=True)
# Plot the similarity metric values
plt.plot(metric_values, 'r')
plt.plot(multires_iterations, [metric_values[index] for index in multires_iterations], 'b*')
plt.xlabel('Iteration Number',fontsize=12)
plt.ylabel('Metric Value',fontsize=12)
plt.show()
# Callback invoked when the sitkMultiResolutionIterationEvent happens, update the index into the
# metric_values list.
def update_multires_iterations():
global metric_values, multires_iterations
multires_iterations.append(len(metric_values))
def command_iteration(method):
print(
f"{method.GetOptimizerIteration():3} "
+ f"= {method.GetMetricValue():10.5f} "
+ f": {method.GetOptimizerPosition()}"
)
Exercise 1: Load the ImgT1.nii image and visualize its three ortho-views in one plot being the axial, sagittal, and coronal views
dir_in = 'data/'
vol_sitk = sitk.ReadImage(dir_in + 'ImgT1.nii')
# Display the volume
imshow_orthogonal_view(vol_sitk, title='T1.nii')
Exercise 2: Write a function rotation_matrix(pitch, roll, yaw)
which returns the rotation matrix for a given a roll, pitch, yaw. Make a 4x4 affine matrix with a pitch of 25 degrees.
Exercise 3: Apply the rotation to the ImgT1.nii around the central point of the volume and save the rotated images as ImgT1_A.nii. Note that the central point is given in physical units (mm) in the World Coordinate System. You can use the next block of code:
This means that we have to pass the inverse matrix of the one we have defined. This is because the transformation is applied to the moving image and not to the fixed image. It is important to consider when we want to apply the transformation to the fixed image.
Note that the inverse or the rotation matrix is the same as the transpose of the rotation matrix, then, when we set the rotation matrix:
transform.SetMatrix(rot_matrix.T.flatten())
For a more general transformation matrix (no only rotations involved), you should compute the inverse matrix:
transform.SetMatrix(np.linealg.inv(rot_matrix).flatten())
# Define the roll rotation in radians
angle = 25 # degrees
pitch_radians = np.deg2rad(angle)
# Create the Affine transform and set the rotation
transform = sitk.AffineTransform(3)
centre_image = np.array(vol_sitk.GetSize()) / 2 - 0.5 # Image Coordinate System
centre_world = vol_sitk.TransformContinuousIndexToPhysicalPoint(centre_image) # World Coordinate System
rot_matrix = rotation_matrix(pitch_radians, 0, 0)[:3, :3] # SimpleITK inputs the rotation and the translation separately
transform.SetCenter(centre_world) # Set the rotation centre
transform.SetMatrix(rot_matrix.T.flatten())
# Apply the transformation to the image
ImgT1_A = sitk.Resample(vol_sitk, transform)
# Save the rotated image
sitk.WriteImage(ImgT1_A, dir_in + 'ImgT1_A.nii')
Exercise 4: Visualise ImgT1_A.nii in ortho view and show the rotated image.
imshow_orthogonal_view(ImgT1_A, title='T1_A.nii')
overlay_slices(vol_sitk, ImgT1_A, title = 'ImgT1 (red) vs. ImgT1_A (green)')
Exercise 5: Find the geometrical transformation of the moving image to the fixed image. The moving image is ImgT1_A.nii and the fixed image is ImgT1.nii. The new rotated image is named ImgT1_B.nii and the optimal affine transformation matrix text file is named A1.txt. You can try to modify the metric and optimizer step length.
The following code is a template for the registration. You can relate it to the figure 1 in the theory note. You can modify it to your needs.
If the computing time is excesive, increase the shrink factor.
# Set the registration - Fig. 1 from the Theory Note
R = sitk.ImageRegistrationMethod()
# Set a one-level the pyramid scheule. [Pyramid step]
R.SetShrinkFactorsPerLevel(shrinkFactors = [2])
R.SetSmoothingSigmasPerLevel(smoothingSigmas=[0])
R.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
# Set the interpolator [Interpolation step]
R.SetInterpolator(sitk.sitkLinear)
# Set the similarity metric [Metric step]
R.SetMetricAsMeanSquares()
# Set the sampling strategy [Sampling step]
R.SetMetricSamplingStrategy(R.RANDOM)
R.SetMetricSamplingPercentage(0.50)
# Set the optimizer [Optimization step]
R.SetOptimizerAsPowell(stepLength=0.1, numberOfIterations=25)
# Initialize the transformation type to rigid
initTransform = sitk.Euler3DTransform()
R.SetInitialTransform(initTransform, inPlace=False)
# Some extra functions to keep track to the optimization process
# R.AddCommand(sitk.sitkIterationEvent, lambda: command_iteration(R)) # Print the iteration number and metric value
R.AddCommand(sitk.sitkStartEvent, start_plot) # Plot the similarity metric values across iterations
R.AddCommand(sitk.sitkEndEvent, end_plot)
R.AddCommand(sitk.sitkMultiResolutionIterationEvent, update_multires_iterations)
R.AddCommand(sitk.sitkIterationEvent, lambda: plot_values(R))
# Estimate the registration transformation [metric, optimizer, transform]
tform_reg = R.Execute(fixed_image, moving_image)
# Apply the estimated transformation to the moving image
ImgT1_B = sitk.Resample(moving_image, tform_reg)
# Save
sitk.WriteImage(ImgT1_B, dir_in + 'ImgT1_B.nii')
Exercise 6: Show the ortho-view of the ImgT1_B.nii. Display the optimal affine matrix found. Does it agree with the expected and what is expected? Why? You can use the following snippets of code:
You can get the estimated transformation using the following code:
estimated_tform = tform_reg.GetNthTransform(0).GetMatrix() # Transform matrix
estimated_translation = tform_reg.GetNthTransform(0).GetTranslation() # Translation vector
params = tform_reg.GetParameters() # Parameters (Rx, Ry, Rz, Tx, Ty, Tz)
You can also convert the transformation to a homogeneous matrix using the following code:
def homogeneous_matrix_from_transform(transform):
"""Convert a SimpleITK transform to a homogeneous matrix."""
matrix = np.zeros((4, 4))
matrix[:3, :3] = np.reshape(np.array(transform.GetMatrix()), (3, 3))
matrix[:3, 3] = transform.GetTranslation()
matrix[3, 3] = 1
return matrix
matrix_estimated = homogeneous_matrix_from_transform(tform_reg.GetNthTransform(0))
matrix_applied = homogeneous_matrix_from_transform(transform)
And store and load the transformation matrix using the following code:
tform_reg.WriteTransform(dir_in + 'A1.tfm')
tform_loaded = sitk.ReadTransform(dir_in + 'A1.tfm')
Exercise 7: By default, SimpleITK uses the fixed image’s origin as the rotation center. Change the rotation center to the center of the fixed image and repeat the registration. Compare the results.
Change the rotation center to the center of the image using the following code and repeating the registration (Exercise 5):
initTransform = sitk.CenteredTransformInitializer(fixed_image, moving_image, sitk.Euler3DTransform(), sitk.CenteredTransformInitializerFilter.GEOMETRY)
Exercise 8: Make four rotation matrices that rotate the ImgT1nii in steps of 60 degrees starting from 60 degrees. Apply the rotation to ImgT1.nii, reslice and store the resulting images as ImgT1_60.nii, ImgT1_120.nii etc. Show in ortho-view that the rotations are applied as expected for each new image.
Exercise 9: Use ImgT1_120.nii as the fixed image, and the other three rotated images from Exercise 8 as the moving images. Run the registration to find the affine matrix and include the reslicing procedure for each of the moving images. Show in ortho-view the resliced images and describe what the rotation angles are. Save the transforms with the name "Ex9_60.tfm, Ex10_180.tfm, Ex10_240.tfm" Do the rotations agree with those in Exercise 8?
Note: You will need to change the step length to handle the larger rotations. A too small step would lead to the convergence in a local minimum. A good value may be 10. You may also benefit from modifying the pyramid schedule.
Often, we wish to combine affine matrixes from a series of images with different registration but apply reslicing only once. The reason is that every time applying reslicing it introduces blurring, and if the image is registered and resliced at each step in a series of rotations, the final image will become very affected by blurring. This we can avoid by first finding the transformation matrix per registration step, then combining them into one matrix, and then applying the reslicing as the final step to the combined affine transformation.
Exercise 10: Use the ImgT1_240.nii as the fixed image and use the ImgT1.nii as the moving image. Make an affine matrix clockwise by combining the estimated transformation and the affine matrix obtained at each rotation step in exercise 10 and apply reslicing. Show in ortho views that the ImgT1.nii after applying the combined affine matrix is registered as expected. Show the combined affine matrix and explains if it applies the expected rotation angle.
# Load the transforms from file
tform_60 = ...
tform_180 = ...
tform_240 = ...
tform_0 = ...
# Option A: Combine the transforms using the sitk.CompositeTransform(3) function
# Concatenate - The last added transform is applied first
tform_composite = sitk.CompositeTransform(3)
tform_composite.AddTransform(tform_240.GetNthTransform(0))
tform_composite.AddTransform(tform_180.GetNthTransform(0))
tform_composite.AddTransform(tform_60.GetNthTransform(0))
tform_composite.AddTransform(tform_0.GetNthTransform(0))
# Transform the composite transform to an affine transform
affine_composite = composite2affine(tform_composite, centre_world)
# Option B: Combine the transforms manually through multiplication of the homogeneous matrices
A = np.eye(4)
for i in range(tform_composite.GetNumberOfTransforms()-1,-1,-1):
tform = tform_composite.GetNthTransform(i)
A_curr = homogeneous_matrix_from_transform(tform)
A = np.dot(A_curr, A)
tform = sitk.Euler3DTransform()
tform.SetMatrix(A[:3,:3].flatten())
tform.SetTranslation(A[:3,3])
tform.SetCenter(centre_world)
If the moving image becomes too noisy the registration becomes unstable due to the appealingly many local minima in the cost function - in other words, there exist many sub-optimal solutions to the “optimal” affine matrix. In this case, the registration will be very sensitive to the selection of the hyperparameters such as the step length and the number of iterations. Moreover, depending on the optimizer the estimated affine matrix may be very unstable and will change significantly if we repeat the registration at the same noise level (This is not a critical issue for the Powell optimizer in this problem).
We can increase the noise level in an image by setting the noise standard deviation say to sigma = 200. We use a normal distributed random generator to generate the noise which is added to the image.
moving_image_noisy = sitk.AdditiveGaussianNoise(moving_image, mean=0, standardDeviation=200)
imshow_orthogonal_view(moving_image_noisy, title='Moving image with noise')
Exercise 11: Use the ImgT1.nii as the fixed image and ImgT1_240.nii as the moving image. Increase the noise level of the moving image and register it to the fixed image and repeat the registration at the same noise-level for different step length. For what standard deviation level and step length does the optimization algorithm cannot find the global minimum? Show the ortho-views of the noisy moving image.
Note: When the noise is added, the optimizer becomes more sensitive to the step length. We suggest to try, at least, standardDeviation=200, and step lengths = [10, 50, 150, 200].
By using the pyramidal multi-resolution registration strategy, we can make the registration of the noisy moving image to the fixed image more robust. Here we use the Gaussian pyramid procedure where we keep the image resolution (i.e., the shrink factor) in the different steps of the pyramid but introduce blurring by using a Gaussian filter at different levels. At the highest level most blurring is added, and we only see the coarse details in the image to be registered. Then, we go to finer and finer levels of details by reducing the blurring factor. The pyramidal procedure is implemented in the registration function and typically three levels are used and we just set the sigma = [3.0, 1.0, 0.0].
R.SetShrinkFactorsPerLevel(shrinkFactors = [2,2,2])
R.SetSmoothingSigmasPerLevel(smoothingSigmas=[3,1,0])
R.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
Exercise 12: Register the noisy moving image using the pyramidal procedure. Try three levels of setting sigma=[3.0, 1.0, 0.0]. Repeat the registration procedure with different step lengths. Does the image registration become more insensitive to the step length? If not try increasing sigma=[5.0, 1.0, 0.0]. Can one use only 2 levels of the pyramid? What do you suggest of sigma values? Show the optimal affine matrices for each of the repeats to check robustness.