1
1
"""Read/write AFNI's transforms."""
2
2
from math import pi
3
3
import numpy as np
4
- from nibabel .affines import obliquity , voxel_sizes
4
+ from nibabel .affines import (
5
+ obliquity ,
6
+ voxel_sizes ,
7
+ )
5
8
6
- from ..patched import shape_zoom_affine
7
9
from .base import (
8
10
BaseLinearTransformList ,
9
11
DisplacementsField ,
@@ -35,34 +37,44 @@ def to_string(self, banner=True):
35
37
36
38
@classmethod
37
39
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.
41
41
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.
44
44
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()
52
48
53
- if moving is not None :
54
- moving = _ensure_image (moving )
49
+ >>> moving.affine == ras @ reference.affine
55
50
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``):
63
57
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
+ """
64
66
# 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 )
66
78
67
79
tf = cls ()
68
80
tf .structarr ["parameters" ] = parameters .T
@@ -76,7 +88,8 @@ def from_string(cls, string):
76
88
lines = [
77
89
line
78
90
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 )
80
93
]
81
94
82
95
if not lines :
@@ -93,23 +106,17 @@ def from_string(cls, string):
93
106
94
107
def to_ras (self , moving = None , reference = None ):
95
108
"""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 )
102
112
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 )
107
114
115
+ moving = _ensure_image (moving )
108
116
if moving is not None and _is_oblique (moving .affine ):
109
- raise NotImplementedError
117
+ retval = _cardinal_rotation ( moving . affine , False ) @ retval
110
118
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
113
120
114
121
115
122
class AFNILinearTransformArray (BaseLinearTransformList ):
@@ -184,4 +191,168 @@ def from_image(cls, imgobj):
184
191
185
192
186
193
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
+ """
187
209
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