Skip to content

Commit 38202bb

Browse files
committed
fix: finishing up this PR
With the generous support from Paul Taylor <@mrneont> and his answers in afni/afni#353, I have managed to get all tests to PASS. I am not resolving the problem of oblique datasets for displacements fields this time. Resolves: #45. Resolves: #150. Continues: #157.
1 parent f753ff0 commit 38202bb

File tree

4 files changed

+87
-12
lines changed

4 files changed

+87
-12
lines changed

nitransforms/io/afni.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
DisplacementsField,
1212
LinearParameters,
1313
TransformFileError,
14+
_ensure_image,
1415
)
1516

1617
LPS = np.diag([-1, -1, 1, 1])
@@ -38,6 +39,15 @@ def to_string(self, banner=True):
3839
def from_ras(cls, ras, moving=None, reference=None):
3940
"""Create an AFNI affine from a nitransform's RAS+ matrix."""
4041
# swapaxes is necessary, as axis 0 encodes series of transforms
42+
43+
reference = _ensure_image(reference)
44+
if reference is not None and _is_oblique(reference.affine):
45+
ras = ras @ _cardinal_rotation(reference.affine, False)
46+
47+
moving = _ensure_image(moving)
48+
if moving is not None and _is_oblique(moving.affine):
49+
ras = _cardinal_rotation(moving.affine, True) @ ras
50+
4151
parameters = np.swapaxes(LPS @ ras @ LPS, 0, 1)
4252

4353
tf = cls()
@@ -71,7 +81,16 @@ def from_string(cls, string):
7181
def to_ras(self, moving=None, reference=None):
7282
"""Return a nitransforms internal RAS+ matrix."""
7383
# swapaxes is necessary, as axis 0 encodes series of transforms
74-
return LPS @ np.swapaxes(self.structarr["parameters"].T, 0, 1) @ LPS
84+
retval = LPS @ np.swapaxes(self.structarr["parameters"].T, 0, 1) @ LPS
85+
reference = _ensure_image(reference)
86+
if reference is not None and _is_oblique(reference.affine):
87+
retval = retval @ _cardinal_rotation(reference.affine, True)
88+
89+
moving = _ensure_image(moving)
90+
if moving is not None and _is_oblique(moving.affine):
91+
retval = _cardinal_rotation(moving.affine, False) @ retval
92+
93+
return retval
7594

7695

7796
class AFNILinearTransformArray(BaseLinearTransformList):
@@ -244,6 +263,28 @@ def _dicom_real_to_card(oblique):
244263
return retval
245264

246265

266+
def _cardinal_rotation(oblique, real_to_card=True):
267+
"""
268+
Calculate the rotation matrix to undo AFNI's deoblique operation.
269+
270+
Parameters
271+
----------
272+
oblique : 4x4 numpy.array
273+
affine that may not be aligned to the cardinal axes ("IJK_DICOM_REAL" for AFNI).
274+
275+
Returns
276+
-------
277+
plumb : 4x4 numpy.array
278+
affine aligned to the cardinal axes ("IJK_DICOM_CARD" for AFNI).
279+
280+
"""
281+
card = _dicom_real_to_card(oblique)
282+
return (
283+
card @ np.linalg.inv(oblique) if real_to_card else
284+
oblique @ np.linalg.inv(card)
285+
)
286+
287+
247288
def _afni_warpdrive(oblique, forward=True, ras=False):
248289
"""
249290
Calculate AFNI's ``WARPDRIVE_MATVEC_FOR_000000`` (de)obliquing affine.

nitransforms/tests/test_io.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,30 @@ def test_afni_oblique(tmpdir, parameters, swapaxes, testdata_path, dir_x, dir_y,
471471
card_aff = afni._dicom_real_to_card(img.affine)
472472
assert np.allclose(card_aff, nb.load("deob_3drefit.nii.gz").affine)
473473

474+
# Check that nitransforms can emulate 3drefit -deoblique
475+
nt3drefit = Affine(
476+
afni._cardinal_rotation(img.affine, False),
477+
reference="deob_3drefit.nii.gz",
478+
).apply("orig.nii.gz")
479+
480+
diff = (
481+
np.asanyarray(img.dataobj, dtype="uint8")
482+
- np.asanyarray(nt3drefit.dataobj, dtype="uint8")
483+
)
484+
assert np.sqrt((diff[10:-10, 10:-10, 10:-10] ** 2).mean()) < 0.1
485+
486+
# Check that nitransforms can revert 3drefit -deoblique
487+
nt_undo3drefit = Affine(
488+
afni._cardinal_rotation(img.affine, True),
489+
reference="orig.nii.gz",
490+
).apply("deob_3drefit.nii.gz")
491+
492+
diff = (
493+
np.asanyarray(img.dataobj, dtype="uint8")
494+
- np.asanyarray(nt_undo3drefit.dataobj, dtype="uint8")
495+
)
496+
assert np.sqrt((diff[10:-10, 10:-10, 10:-10] ** 2).mean()) < 0.1
497+
474498
# Check the target grid by 3dWarp and the affine & size interpolated by NiTransforms
475499
cmd = f"3dWarp -verb -deoblique -NN -prefix {tmpdir}/deob.nii.gz {tmpdir}/orig.nii.gz"
476500
assert check_call([cmd], shell=True) == 0

nitransforms/tests/test_linear.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,14 @@ def test_linear_save(tmpdir, data_path, get_testdata, image_orientation, sw_tool
149149
# Account for the fact that FS defines LTA transforms reversed
150150
T = np.linalg.inv(T)
151151

152-
xfm = nitl.Affine(T)
152+
xfm = (
153+
nitl.Affine(T) if (sw_tool, image_orientation) != ("afni", "oblique") else
154+
# AFNI is special when moving or reference are oblique - let io do the magic
155+
nitl.Affine(nitl.io.afni.AFNILinearTransform.from_ras(T).to_ras(
156+
reference=img,
157+
moving=img,
158+
))
159+
)
153160
xfm.reference = img
154161

155162
ext = ""
@@ -190,7 +197,15 @@ def test_apply_linear_transform(tmpdir, get_testdata, get_testmask, image_orient
190197

191198
# Write out transform file (software-dependent)
192199
xfm_fname = "M.%s%s" % (sw_tool, ext)
193-
xfm.to_filename(xfm_fname, fmt=sw_tool)
200+
# Change reference dataset for AFNI & oblique
201+
if (sw_tool, image_orientation) == ("afni", "oblique"):
202+
nitl.io.afni.AFNILinearTransform.from_ras(
203+
T,
204+
moving=img,
205+
reference=img,
206+
).to_filename(xfm_fname)
207+
else:
208+
xfm.to_filename(xfm_fname, fmt=sw_tool)
194209

195210
cmd = APPLY_LINEAR_CMD[sw_tool](
196211
transform=os.path.abspath(xfm_fname),
@@ -209,19 +224,11 @@ def test_apply_linear_transform(tmpdir, get_testdata, get_testmask, image_orient
209224
assert exit_code == 0
210225
sw_moved_mask = nb.load("resampled_brainmask.nii.gz")
211226

212-
# Change reference dataset for AFNI & oblique
213-
if (sw_tool, image_orientation) == ("afni", "oblique"):
214-
xfm.reference = "resampled_brainmask.nii.gz"
215-
216227
nt_moved_mask = xfm.apply(msk, order=0)
217228
nt_moved_mask.set_data_dtype(msk.get_data_dtype())
218-
nt_moved_mask.to_filename("nt_resampled_brainmask.nii.gz")
229+
nt_moved_mask.to_filename("ntmask.nii.gz")
219230
diff = np.asanyarray(sw_moved_mask.dataobj) - np.asanyarray(nt_moved_mask.dataobj)
220231

221-
nt_moved_mask.__class__(
222-
diff, sw_moved_mask.affine, sw_moved_mask.header
223-
).to_filename("diff.nii.gz")
224-
225232
assert np.sqrt((diff ** 2).mean()) < RMSE_TOL
226233
brainmask = np.asanyarray(nt_moved_mask.dataobj, dtype=bool)
227234

nitransforms/tests/test_nonlinear.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ def test_displacements_field1(
112112
axis,
113113
):
114114
"""Check a translation-only field on one or more axes, different image orientations."""
115+
if (image_orientation, sw_tool) == ("oblique", "afni"):
116+
pytest.skip("AFNI obliques are not yet implemented for displacements fields")
117+
115118
os.chdir(str(tmp_path))
116119
nii = get_testdata[image_orientation]
117120
msk = get_testmask[image_orientation]

0 commit comments

Comments
 (0)