11"""Read/write AFNI's transforms."""
22from math import pi
33import 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
79from .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
115122class AFNILinearTransformArray (BaseLinearTransformList ):
@@ -184,4 +191,168 @@ def from_image(cls, imgobj):
184191
185192
186193def _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
0 commit comments