|
| 1 | +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- |
| 2 | +# vi: set ft=python sts=4 ts=4 sw=4 et: |
| 3 | +""" |
| 4 | +Utilities to aid in performing and evaluating image registration. |
| 5 | +
|
| 6 | +This module provides functions to compute displacements of image coordinates |
| 7 | +under a transformation, useful for assessing the accuracy of image registration |
| 8 | +processes. |
| 9 | +
|
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +from itertools import product |
| 15 | +from typing import Tuple |
| 16 | + |
| 17 | +import nibabel as nb |
| 18 | +import numpy as np |
| 19 | +from scipy.stats import zscore |
| 20 | + |
| 21 | +from nitransforms.base import TransformBase |
| 22 | + |
| 23 | + |
| 24 | +RADIUS = 50.0 |
| 25 | +"""Typical radius (in mm) of a sphere mimicking the size of a typical human brain.""" |
| 26 | + |
| 27 | + |
| 28 | +def compute_fd_from_motion(motion_parameters: np.ndarray, radius: float = RADIUS) -> np.ndarray: |
| 29 | + """Compute framewise displacement (FD) from motion parameters. |
| 30 | +
|
| 31 | + Each row in the motion parameters represents one frame, and columns |
| 32 | + represent each coordinate axis ``x``, `y``, and ``z``. Translation |
| 33 | + parameters are followed by rotation parameters column-wise. |
| 34 | +
|
| 35 | + Parameters |
| 36 | + ---------- |
| 37 | + motion_parameters : :obj:`numpy.ndarray` |
| 38 | + Motion parameters. |
| 39 | + radius : :obj:`float`, optional |
| 40 | + Radius (in mm) of a sphere mimicking the size of a typical human brain. |
| 41 | +
|
| 42 | + Returns |
| 43 | + ------- |
| 44 | + :obj:`numpy.ndarray` |
| 45 | + The framewise displacement (FD) as the sum of absolute differences |
| 46 | + between consecutive frames. |
| 47 | + """ |
| 48 | + |
| 49 | + translations = motion_parameters[:, :3] |
| 50 | + rotations_deg = motion_parameters[:, 3:] |
| 51 | + rotations_rad = np.deg2rad(rotations_deg) |
| 52 | + |
| 53 | + # Compute differences between consecutive frames |
| 54 | + d_translations = np.vstack([np.zeros((1, 3)), np.diff(translations, axis=0)]) |
| 55 | + d_rotations = np.vstack([np.zeros((1, 3)), np.diff(rotations_rad, axis=0)]) |
| 56 | + |
| 57 | + # Convert rotations from radians to displacement on a sphere |
| 58 | + rotation_displacement = d_rotations * radius |
| 59 | + |
| 60 | + # Compute FD as sum of absolute differences |
| 61 | + return np.sum(np.abs(d_translations) + np.abs(rotation_displacement), axis=1) |
| 62 | + |
| 63 | + |
| 64 | +def compute_fd_from_transform( |
| 65 | + img: nb.spatialimages.SpatialImage, |
| 66 | + test_xfm: TransformBase, |
| 67 | + radius: float = RADIUS, |
| 68 | +) -> float: |
| 69 | + """ |
| 70 | + Compute the framewise displacement (FD) for a given transformation. |
| 71 | +
|
| 72 | + Parameters |
| 73 | + ---------- |
| 74 | + img : :obj:`~nibabel.spatialimages.SpatialImage` |
| 75 | + The reference image. Used to extract the center coordinates. |
| 76 | + test_xfm : :obj:`~nitransforms.base.TransformBase` |
| 77 | + The transformation to test. Applied to coordinates around the image center. |
| 78 | + radius : :obj:`float`, optional |
| 79 | + The radius (in mm) of the spherical neighborhood around the center of the image. |
| 80 | +
|
| 81 | + Returns |
| 82 | + ------- |
| 83 | + :obj:`float` |
| 84 | + The average framewise displacement (FD) for the test transformation. |
| 85 | +
|
| 86 | + """ |
| 87 | + affine = img.affine |
| 88 | + # Compute the center of the image in voxel space |
| 89 | + center_ijk = 0.5 * (np.array(img.shape[:3]) - 1) |
| 90 | + # Convert to world coordinates |
| 91 | + center_xyz = nb.affines.apply_affine(affine, center_ijk) |
| 92 | + # Generate coordinates of points at radius distance from center |
| 93 | + fd_coords = np.array(list(product(*((radius, -radius),) * 3))) + center_xyz |
| 94 | + # Compute the average displacement from the test transformation |
| 95 | + return np.mean(np.linalg.norm(test_xfm.map(fd_coords) - fd_coords, axis=-1)) |
| 96 | + |
| 97 | + |
| 98 | +def compute_percentage_change( |
| 99 | + reference: np.ndarray, |
| 100 | + test: np.ndarray, |
| 101 | + mask: np.ndarray, |
| 102 | +) -> np.ndarray: |
| 103 | + """Compute motion change between reference and test as a percentage. |
| 104 | +
|
| 105 | + If a mask is provided, the computation is only provided within the mask. |
| 106 | + Also, null values are ignored. |
| 107 | +
|
| 108 | + Parameters |
| 109 | + ---------- |
| 110 | + reference : :obj:`~numpy.ndarray` |
| 111 | + Reference imaging volume. |
| 112 | + test : :obj:`~numpy.ndarray` |
| 113 | + Test (shifted) imaging volume. |
| 114 | + mask : :obj:`~numpy.ndarray` |
| 115 | + Mask for value consideration. |
| 116 | +
|
| 117 | + Returns |
| 118 | + ------- |
| 119 | + rel_diff : :obj:`~numpy.ndarray` |
| 120 | + Motion change between reference and test. |
| 121 | + """ |
| 122 | + |
| 123 | + # Avoid divide-by-zero errors |
| 124 | + eps = 1e-5 |
| 125 | + rel_diff = np.zeros_like(reference) |
| 126 | + mask = mask.copy() |
| 127 | + mask[reference <= eps] = False |
| 128 | + rel_diff[mask] = 100 * (test[mask] - reference[mask]) / reference[mask] |
| 129 | + |
| 130 | + return rel_diff |
| 131 | + |
| 132 | + |
| 133 | +def displacements_within_mask( |
| 134 | + mask_img: nb.spatialimages.SpatialImage, |
| 135 | + test_xfm: TransformBase, |
| 136 | + reference_xfm: TransformBase | None = None, |
| 137 | +) -> np.ndarray: |
| 138 | + """ |
| 139 | + Compute the distance between voxel coordinates mapped through two transforms. |
| 140 | +
|
| 141 | + Parameters |
| 142 | + ---------- |
| 143 | + mask_img : :obj:`~nibabel.spatialimages.SpatialImage` |
| 144 | + A mask image that defines the region of interest. Voxel coordinates |
| 145 | + within the mask are transformed. |
| 146 | + test_xfm : :obj:`~nitransforms.base.TransformBase` |
| 147 | + The transformation to test. This transformation is applied to the |
| 148 | + voxel coordinates. |
| 149 | + reference_xfm : :obj:`~nitransforms.base.TransformBase`, optional |
| 150 | + A reference transformation to compare with. If ``None``, the identity |
| 151 | + transformation is assumed (no transformation). |
| 152 | +
|
| 153 | + Returns |
| 154 | + ------- |
| 155 | + :obj:`~numpy.ndarray` |
| 156 | + An array of displacements (in mm) for each voxel within the mask. |
| 157 | +
|
| 158 | + """ |
| 159 | + # Mask data as boolean (True for voxels inside the mask) |
| 160 | + maskdata = np.asanyarray(mask_img.dataobj) > 0 |
| 161 | + # Convert voxel coordinates to world coordinates using affine transform |
| 162 | + xyz = nb.affines.apply_affine( |
| 163 | + mask_img.affine, |
| 164 | + np.argwhere(maskdata), |
| 165 | + ) |
| 166 | + # Apply the test transformation |
| 167 | + targets = test_xfm.map(xyz) |
| 168 | + |
| 169 | + # Compute the difference (displacement) between the test and reference transformations |
| 170 | + diffs = targets - xyz if reference_xfm is None else targets - reference_xfm.map(xyz) |
| 171 | + return np.linalg.norm(diffs, axis=-1) |
| 172 | + |
| 173 | + |
| 174 | +def extract_motion_parameters(affine: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: |
| 175 | + """Extract translation (mm) and rotation (degrees) parameters from an affine matrix. |
| 176 | +
|
| 177 | + Parameters |
| 178 | + ---------- |
| 179 | + affine : :obj:`~numpy.ndarray` |
| 180 | + The affine transformation matrix. |
| 181 | +
|
| 182 | + Returns |
| 183 | + ------- |
| 184 | + :obj:`tuple` |
| 185 | + Extracted translation and rotation parameters. |
| 186 | + """ |
| 187 | + |
| 188 | + translation = affine[:3, 3] |
| 189 | + rotation_rad = np.arctan2( |
| 190 | + [affine[2, 1], affine[0, 2], affine[1, 0]], [affine[2, 2], affine[0, 0], affine[1, 1]] |
| 191 | + ) |
| 192 | + rotation_deg = np.rad2deg(rotation_rad) |
| 193 | + return *translation, *rotation_deg |
| 194 | + |
| 195 | + |
| 196 | +def identify_spikes(fd: np.ndarray, threshold: float = 2.0): |
| 197 | + """Identify motion spikes in framewise displacement data. |
| 198 | +
|
| 199 | + Identifies high-motion frames as timepoint exceeding a given threshold value |
| 200 | + based on z-score normalized framewise displacement (FD) values. |
| 201 | +
|
| 202 | + Parameters |
| 203 | + ---------- |
| 204 | + fd : :obj:`~numpy.ndarray` |
| 205 | + Framewise displacement data. |
| 206 | + threshold : :obj:`float`, optional |
| 207 | + Threshold value to determine motion spikes. |
| 208 | +
|
| 209 | + Returns |
| 210 | + ------- |
| 211 | + indices : :obj:`~numpy.ndarray` |
| 212 | + Indices of identified motion spikes. |
| 213 | + mask : :obj:`~numpy.ndarray` |
| 214 | + Mask of identified motion spikes. |
| 215 | + """ |
| 216 | + |
| 217 | + # Normalize (z-score) |
| 218 | + fd_norm = zscore(fd) |
| 219 | + |
| 220 | + mask = fd_norm > threshold |
| 221 | + indices = np.where(mask)[0] |
| 222 | + |
| 223 | + return indices, mask |
0 commit comments