Skip to content

Commit 7b3640e

Browse files
authored
Merge pull request #117 from poldracklab/enh/afni-deoblique
FIX: Implement AFNI's deoblique operations
2 parents 6fbcbc1 + bea9c27 commit 7b3640e

13 files changed

+1298
-2360
lines changed

Dockerfile

+1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ RUN conda install -y -c anaconda -c conda-forge \
111111
python=3.7 \
112112
libxml2=2.9 \
113113
libxslt=1.1 \
114+
lxml \
114115
mkl \
115116
mkl-service \
116117
numpy=1.20 \

docs/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"sphinx.ext.napoleon",
3939
"sphinx.ext.viewcode",
4040
"nbsphinx",
41+
"IPython.sphinxext.ipython_console_highlighting",
4142
]
4243

4344
autodoc_mock_imports = [

docs/examples.rst

+1-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,5 @@ A collection of Jupyter Notebooks to serve as interactive tutorials.
77
.. toctree::
88
:maxdepth: 1
99

10-
notebooks/01_preparing_images
11-
notebooks/02_afni_deoblique
1210
notebooks/isbi2020
11+
notebooks/Reading and Writing transforms.ipynb

docs/notebooks/01_preparing_images.ipynb

-1,035
This file was deleted.

docs/notebooks/02_afni_deoblique.ipynb

-1,271
This file was deleted.

docs/notebooks/Reading and Writing transforms.ipynb

+917
Large diffs are not rendered by default.

docs/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
ipython
12
nbsphinx
23
packaging
34
pydot>=1.2.3

nitransforms/io/afni.py

+209-38
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Read/write AFNI's transforms."""
22
from math import pi
33
import numpy as np
4-
from nibabel.affines import obliquity, voxel_sizes
4+
from nibabel.affines import (
5+
obliquity,
6+
voxel_sizes,
7+
)
58

6-
from ..patched import shape_zoom_affine
79
from .base import (
810
BaseLinearTransformList,
911
DisplacementsField,
@@ -35,34 +37,44 @@ def to_string(self, banner=True):
3537

3638
@classmethod
3739
def from_ras(cls, ras, moving=None, reference=None):
38-
"""Create an AFNI affine from a nitransform's RAS+ matrix."""
39-
pre = LPS
40-
post = LPS
40+
"""Create an AFNI affine from a nitransform's RAS+ matrix.
4141
42-
if reference is not None:
43-
reference = _ensure_image(reference)
42+
AFNI implicitly de-obliques image affine matrices before applying transforms, so
43+
for consistency we update the transform to account for the obliquity of the images.
4444
45-
if reference is not None and _is_oblique(reference.affine):
46-
print("Reference affine axes are oblique.")
47-
M = reference.affine
48-
A = shape_zoom_affine(
49-
reference.shape, voxel_sizes(M), x_flip=False, y_flip=False
50-
)
51-
pre = M.dot(np.linalg.inv(A)).dot(LPS)
45+
.. testsetup:
46+
>>> import pytest
47+
>>> pytest.skip()
5248
53-
if moving is not None:
54-
moving = _ensure_image(moving)
49+
>>> moving.affine == ras @ reference.affine
5550
56-
if moving is not None and _is_oblique(moving.affine):
57-
print("Moving affine axes are oblique.")
58-
M2 = moving.affine
59-
A2 = shape_zoom_affine(
60-
moving.shape, voxel_sizes(M2), x_flip=True, y_flip=True
61-
)
62-
post = A2.dot(np.linalg.inv(M2))
51+
We can decompose the affines into oblique and de-obliqued components:
52+
53+
>>> moving.affine == m_obl @ m_deobl
54+
>>> reference.affine == r_obl @ r_deobl
55+
56+
To generate an equivalent AFNI transform, we need an effective transform (``e_ras``):
6357
58+
>>> m_obl @ m_deobl == ras @ r_obl @ r_deobl
59+
>>> m_deobl == inv(m_obl) @ ras @ r_obl @ r_deobl
60+
61+
Hence,
62+
63+
>>> m_deobl == e_ras @ r_deobl
64+
>>> e_ras == inv(m_obl) @ ras @ r_obl
65+
"""
6466
# swapaxes is necessary, as axis 0 encodes series of transforms
65-
parameters = np.swapaxes(post @ ras @ pre, 0, 1)
67+
68+
reference = _ensure_image(reference)
69+
if reference is not None and _is_oblique(reference.affine):
70+
ras = ras @ _cardinal_rotation(reference.affine, False)
71+
72+
moving = _ensure_image(moving)
73+
if moving is not None and _is_oblique(moving.affine):
74+
ras = _cardinal_rotation(moving.affine, True) @ ras
75+
76+
# AFNI represents affine transformations as LPS-to-LPS
77+
parameters = np.swapaxes(LPS @ ras @ LPS, 0, 1)
6678

6779
tf = cls()
6880
tf.structarr["parameters"] = parameters.T
@@ -76,7 +88,8 @@ def from_string(cls, string):
7688
lines = [
7789
line
7890
for line in string.splitlines()
79-
if line.strip() and not (line.startswith("#") or "3dvolreg matrices" in line)
91+
if line.strip()
92+
and not (line.startswith("#") or "3dvolreg matrices" in line)
8093
]
8194

8295
if not lines:
@@ -93,23 +106,17 @@ def from_string(cls, string):
93106

94107
def to_ras(self, moving=None, reference=None):
95108
"""Return a nitransforms internal RAS+ matrix."""
96-
pre = LPS
97-
post = LPS
98-
99-
if reference is not None:
100-
reference = _ensure_image(reference)
101-
109+
# swapaxes is necessary, as axis 0 encodes series of transforms
110+
retval = LPS @ np.swapaxes(self.structarr["parameters"].T, 0, 1) @ LPS
111+
reference = _ensure_image(reference)
102112
if reference is not None and _is_oblique(reference.affine):
103-
raise NotImplementedError
104-
105-
if moving is not None:
106-
moving = _ensure_image(moving)
113+
retval = retval @ _cardinal_rotation(reference.affine, True)
107114

115+
moving = _ensure_image(moving)
108116
if moving is not None and _is_oblique(moving.affine):
109-
raise NotImplementedError
117+
retval = _cardinal_rotation(moving.affine, False) @ retval
110118

111-
# swapaxes is necessary, as axis 0 encodes series of transforms
112-
return post @ np.swapaxes(self.structarr["parameters"].T, 0, 1) @ pre
119+
return retval
113120

114121

115122
class AFNILinearTransformArray(BaseLinearTransformList):
@@ -184,4 +191,168 @@ def from_image(cls, imgobj):
184191

185192

186193
def _is_oblique(affine, thres=OBLIQUITY_THRESHOLD_DEG):
194+
"""
195+
Determine whether the dataset is oblique.
196+
197+
Examples
198+
--------
199+
>>> _is_oblique(np.eye(4))
200+
False
201+
202+
>>> _is_oblique(nb.affines.from_matvec(
203+
... nb.eulerangles.euler2mat(x=0.9, y=0.001, z=0.001),
204+
... [4.0, 2.0, -1.0],
205+
... ))
206+
True
207+
208+
"""
187209
return (obliquity(affine).min() * 180 / pi) > thres
210+
211+
212+
def _afni_deobliqued_grid(oblique, shape):
213+
"""
214+
Calculate AFNI's target deobliqued image grid.
215+
216+
Maps the eight images corners to the new coordinate system to ensure
217+
coverage of the full extent after rotation, as AFNI does.
218+
219+
See also
220+
--------
221+
https://github.com/afni/afni/blob/75766463758e5806d938c8dd3bdcd4d56ab5a485/src/mri_warp3D.c#L941-L1010
222+
223+
Parameters
224+
----------
225+
oblique : 4x4 numpy.array
226+
affine that is not aligned to the cardinal axes.
227+
shape : numpy.array
228+
sizes of the (oblique) image grid
229+
230+
Returns
231+
-------
232+
affine : 4x4 numpy.array
233+
plumb affine (i.e., aligned to the cardinal axes).
234+
shape : numpy.array
235+
sizes of the target, plumb image grid
236+
237+
"""
238+
shape = np.array(shape[:3])
239+
vs = voxel_sizes(oblique)
240+
241+
# Calculate new shape of deobliqued grid
242+
corners_ijk = (
243+
np.array(
244+
[
245+
(i, j, k)
246+
for k in (0, shape[2])
247+
for j in (0, shape[1])
248+
for i in (0, shape[0])
249+
]
250+
)
251+
- 0.5
252+
)
253+
corners_xyz = oblique @ np.hstack((corners_ijk, np.ones((len(corners_ijk), 1)))).T
254+
extent = corners_xyz.min(1)[:3], corners_xyz.max(1)[:3]
255+
nshape = ((extent[1] - extent[0]) / vs + 0.999).astype(int)
256+
257+
# AFNI deobliqued target will be in LPS+ orientation
258+
plumb = LPS * ([vs.min()] * 3 + [1.0])
259+
260+
# Coordinates of center voxel do not change
261+
obliq_c = oblique @ np.hstack((0.5 * (shape - 1), 1.0))
262+
plumb_c = plumb @ np.hstack((0.5 * (nshape - 1), 1.0))
263+
264+
# Rebase the origin of the new, plumb affine
265+
plumb[:3, 3] -= plumb_c[:3] - obliq_c[:3]
266+
267+
return plumb, nshape
268+
269+
270+
def _dicom_real_to_card(oblique):
271+
"""
272+
Calculate the corresponding "DICOM cardinal" for "DICOM real" (AFNI jargon).
273+
274+
Implements the internal "deobliquing" operation of ``3drefit`` and other tools, which
275+
just *drop* the obliquity from the input affine.
276+
277+
Parameters
278+
----------
279+
oblique : 4x4 numpy.array
280+
affine that may not be aligned to the cardinal axes ("IJK_DICOM_REAL" for AFNI).
281+
282+
Returns
283+
-------
284+
plumb : 4x4 numpy.array
285+
affine aligned to the cardinal axes ("IJK_DICOM_CARD" for AFNI).
286+
287+
"""
288+
# Origin is kept from input
289+
retval = np.eye(4)
290+
retval[:3, 3] = oblique[:3, 3]
291+
292+
# Calculate director cosines and project to closest canonical
293+
cosines = oblique[:3, :3] / np.abs(oblique[:3, :3]).max(0)
294+
cosines[np.abs(cosines) < 1.0] = 0
295+
# Once director cosines are calculated, scale by voxel sizes
296+
retval[:3, :3] = np.round(voxel_sizes(oblique), decimals=4) * cosines
297+
return retval
298+
299+
300+
def _cardinal_rotation(oblique, real_to_card=True):
301+
"""
302+
Calculate the rotation matrix to undo AFNI's deoblique operation.
303+
304+
Parameters
305+
----------
306+
oblique : 4x4 numpy.array
307+
affine that may not be aligned to the cardinal axes ("IJK_DICOM_REAL" for AFNI).
308+
309+
Returns
310+
-------
311+
plumb : 4x4 numpy.array
312+
affine aligned to the cardinal axes ("IJK_DICOM_CARD" for AFNI).
313+
314+
"""
315+
card = _dicom_real_to_card(oblique)
316+
return (
317+
card @ np.linalg.inv(oblique) if real_to_card else oblique @ np.linalg.inv(card)
318+
)
319+
320+
321+
def _afni_warpdrive(oblique, forward=True):
322+
"""
323+
Calculate AFNI's ``WARPDRIVE_MATVEC_FOR_000000`` (de)obliquing affine.
324+
325+
Parameters
326+
----------
327+
oblique : 4x4 numpy.array
328+
affine that is not aligned to the cardinal axes.
329+
forward : :obj:`bool`
330+
Returns the forward transformation if True, i.e.,
331+
the matrix to convert an oblique affine into an AFNI's plumb (if ``True``)
332+
or viceversa plumb -> oblique (if ``false``).
333+
334+
Returns
335+
-------
336+
warpdrive : 4x4 numpy.array
337+
AFNI's *warpdrive* forward or inverse matrix.
338+
339+
"""
340+
ijk_to_dicom_real = np.diag(LPS) * oblique
341+
ijk_to_dicom = _dicom_real_to_card(oblique)
342+
R = np.linalg.inv(ijk_to_dicom) @ ijk_to_dicom_real
343+
return np.linalg.inv(R) if forward else R
344+
345+
346+
def _afni_header(nii, field="WARPDRIVE_MATVEC_FOR_000000", to_ras=False):
347+
from lxml import etree
348+
349+
root = etree.fromstring(nii.header.extensions[0].get_content().decode())
350+
retval = np.fromstring(
351+
root.find(f".//*[@atr_name='{field}']").text, sep="\n", dtype="float32"
352+
)
353+
if retval.size == 12:
354+
retval = np.vstack((retval.reshape((3, 4)), (0, 0, 0, 1)))
355+
if to_ras:
356+
retval = LPS @ retval @ LPS
357+
358+
return retval
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
affine-RAS.afni

0 commit comments

Comments
 (0)