Skip to content

Commit 7fae796

Browse files
committed
ENH: Add transform analysis utils
Add transform analysis utils. Transfer contents from the `NiFreeze` projects so that hey can be reused across projects requiring transform analysis: https://github.com/nipreps/nifreeze/blob/d27ba7552bbd9095c3c13b46443d87a4b5504c4c/src/nifreeze/analysis/motion.py https://github.com/nipreps/nifreeze/blob/d27ba7552bbd9095c3c13b46443d87a4b5504c4c/src/nifreeze/data/utils.py
1 parent 0c2ad9f commit 7fae796

File tree

6 files changed

+497
-0
lines changed

6 files changed

+497
-0
lines changed

nitransforms/analysis/__init__.py

Whitespace-only changes.

nitransforms/analysis/utils.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

nitransforms/tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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+
import numpy as np
5+
import pytest
6+
7+
@pytest.fixture(autouse=True)
8+
def random_number_generator(request):
9+
"""Automatically set a fixed-seed random number generator for all tests."""
10+
request.node.rng = np.random.default_rng(1234)

0 commit comments

Comments
 (0)