Skip to content

Commit f753ff0

Browse files
committed
fix: implement AFNI's dicom real to dicom card and test
1 parent 2dcce40 commit f753ff0

File tree

3 files changed

+98
-67
lines changed

3 files changed

+98
-67
lines changed

nitransforms/io/afni.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -214,16 +214,44 @@ def _afni_deobliqued_grid(oblique, shape):
214214
return plumb, nshape
215215

216216

217-
def _afni_warpdrive(oblique, cardinal, forward=True, ras=False):
217+
def _dicom_real_to_card(oblique):
218+
"""
219+
Calculate the corresponding "DICOM cardinal" for "DICOM real" (AFNI jargon).
220+
221+
Implements the internal "deobliquing" operation of ``3drefit`` and other tools, which
222+
just *drop* the obliquity from the input affine.
223+
224+
Parameters
225+
----------
226+
oblique : 4x4 numpy.array
227+
affine that may not be aligned to the cardinal axes ("IJK_DICOM_REAL" for AFNI).
228+
229+
Returns
230+
-------
231+
plumb : 4x4 numpy.array
232+
affine aligned to the cardinal axes ("IJK_DICOM_CARD" for AFNI).
233+
234+
"""
235+
# Origin is kept from input
236+
retval = np.eye(4)
237+
retval[:3, 3] = oblique[:3, 3]
238+
239+
# Calculate director cosines and project to closest canonical
240+
cosines = oblique[:3, :3] / np.abs(oblique[:3, :3]).max(0)
241+
cosines[np.abs(cosines) < 1.0] = 0
242+
# Once director cosines are calculated, scale by voxel sizes
243+
retval[:3, :3] = np.round(voxel_sizes(oblique), decimals=4) * cosines
244+
return retval
245+
246+
247+
def _afni_warpdrive(oblique, forward=True, ras=False):
218248
"""
219249
Calculate AFNI's ``WARPDRIVE_MATVEC_FOR_000000`` (de)obliquing affine.
220250
221251
Parameters
222252
----------
223253
oblique : 4x4 numpy.array
224254
affine that is not aligned to the cardinal axes.
225-
cardinal : 4x4 numpy.array
226-
corresponding affine that is aligned to the cardinal axes.
227255
forward : :obj:`bool`
228256
Returns the forward transformation if True, i.e.,
229257
the matrix to convert an oblique affine into an AFNI's plumb (if ``True``)
@@ -237,18 +265,8 @@ def _afni_warpdrive(oblique, cardinal, forward=True, ras=False):
237265
AFNI's *warpdrive* forward or inverse matrix.
238266
239267
"""
240-
# Rotate the oblique affine to align with imaging axes
241-
# Calculate director cosines and project to closest canonical
242-
243-
# plumb_r = oblique[:3, :3] / np.abs(oblique[:3, :3]).max(0)
244-
# plumb_r[np.abs(plumb_r) < 1.0] = 0
245-
# # Scale by min voxel size (AFNI's default)
246-
# plumb_r *= vs.min()
247-
# plumb = np.eye(4)
248-
# plumb[:3, :3] = plumb_r
249-
250268
ijk_to_dicom_real = np.diag(LPS) * oblique
251-
ijk_to_dicom = cardinal
269+
ijk_to_dicom = _dicom_real_to_card(oblique)
252270
R = np.linalg.inv(ijk_to_dicom) @ ijk_to_dicom_real
253271
return np.linalg.inv(R) if forward else R
254272

nitransforms/tests/test_io.py

Lines changed: 63 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from nibabel.eulerangles import euler2mat
1313
from nibabel.affines import from_matvec
1414
from scipy.io import loadmat
15+
from nitransforms.linear import Affine
1516
from ..io import (
1617
afni,
1718
fsl,
@@ -446,59 +447,36 @@ def test_regressions(file_type, test_file, data_path):
446447
@pytest.mark.parametrize("dir_y", (-1, 1))
447448
@pytest.mark.parametrize("dir_z", (1, -1))
448449
@pytest.mark.parametrize("swapaxes", [
449-
None, # (0, 1), (1, 2), (0, 2),
450+
None, (0, 1), (1, 2), (0, 2),
450451
])
451452
def test_afni_oblique(tmpdir, parameters, swapaxes, testdata_path, dir_x, dir_y, dir_z):
452453
tmpdir.chdir()
453-
img = nb.load(testdata_path / "someones_anatomy.nii.gz")
454-
shape = np.array(img.shape[:3])
455-
hdr = img.header.copy()
456-
aff = img.affine.copy()
457-
data = np.asanyarray(img.dataobj, dtype="uint8")
458-
459-
directions = (dir_x, dir_y, dir_z)
460-
if directions != (1, 1, 1):
461-
last_ijk = np.hstack((shape - 1, 1.0))
462-
last_xyz = aff @ last_ijk
463-
aff = np.diag((dir_x, dir_y, dir_z, 1)) @ aff
464-
465-
for ax in range(3):
466-
if (directions[ax] == -1):
467-
aff[ax, 3] = last_xyz[ax]
468-
data = np.flip(data, ax)
469-
470-
hdr.set_qform(aff, code=1)
471-
hdr.set_sform(aff, code=1)
472-
img.__class__(data, aff, hdr).to_filename("flips.nii.gz")
473-
474-
if swapaxes is not None:
475-
data = np.swapaxes(data, swapaxes[0], swapaxes[1])
476-
aff[list(reversed(swapaxes)), :] = aff[(swapaxes), :]
477-
478-
hdr.set_qform(aff, code=1)
479-
hdr.set_sform(aff, code=1)
480-
img.__class__(data, aff, hdr).to_filename("swaps.nii.gz")
454+
img, R = _generate_reoriented(
455+
testdata_path / "someones_anatomy.nii.gz",
456+
(dir_x, dir_y, dir_z),
457+
swapaxes,
458+
parameters
459+
)
460+
img.to_filename("orig.nii.gz")
481461

482-
R = from_matvec(euler2mat(**parameters), [0.0, 0.0, 0.0])
462+
# Run AFNI's 3drefit -deoblique
463+
if not shutil.which("3drefit"):
464+
pytest.skip("Command 3drefit not found on host")
483465

484-
# img_center = aff @ np.hstack((shape * 0.5, 1.0))
485-
# R[:3, 3] += (img_center - (R @ aff @ np.hstack((shape * 0.5, 1.0))))[:3]
486-
newaff = R @ aff
487-
hdr.set_qform(newaff, code=1)
488-
hdr.set_sform(newaff, code=1)
489-
img = img.__class__(data, newaff, hdr)
490-
img.to_filename("oblique.nii.gz")
466+
shutil.copy(f"{tmpdir}/orig.nii.gz", f"{tmpdir}/deob_3drefit.nii.gz")
467+
cmd = f"3drefit -deoblique {tmpdir}/deob_3drefit.nii.gz"
468+
assert check_call([cmd], shell=True) == 0
491469

492-
# Run AFNI's 3dDeoblique
493-
if not shutil.which("3dWarp"):
494-
pytest.skip("Command 3dWarp not found on host")
470+
# Check that nitransforms can make out the deoblique affine:
471+
card_aff = afni._dicom_real_to_card(img.affine)
472+
assert np.allclose(card_aff, nb.load("deob_3drefit.nii.gz").affine)
495473

496-
cmd = f"3dWarp -verb -deoblique -NN -prefix {tmpdir}/deob.nii.gz {tmpdir}/oblique.nii.gz"
474+
# Check the target grid by 3dWarp and the affine & size interpolated by NiTransforms
475+
cmd = f"3dWarp -verb -deoblique -NN -prefix {tmpdir}/deob.nii.gz {tmpdir}/orig.nii.gz"
497476
assert check_call([cmd], shell=True) == 0
498477

499-
# Check the target grid by 3dWarp and the affine & size interpolated by NiTransforms
500-
deobaff, deobshape = afni._afni_deobliqued_grid(newaff, shape)
501478
deobnii = nb.load("deob.nii.gz")
479+
deobaff, deobshape = afni._afni_deobliqued_grid(img.affine, img.shape)
502480

503481
assert np.all(deobshape == deobnii.shape[:3])
504482
assert np.allclose(deobaff, deobnii.affine)
@@ -509,13 +487,21 @@ def test_afni_oblique(tmpdir, parameters, swapaxes, testdata_path, dir_x, dir_y,
509487
deobaff,
510488
deobnii.header
511489
)).apply(img, order=0)
512-
ntdeobnii.to_filename("ntdeob.nii.gz")
490+
491+
# Generate an internal box to exclude border effects
492+
box = np.zeros(img.shape, dtype="uint8")
493+
box[10:-10, 10:-10, 10:-10] = 1
494+
ntdeobmask = Affine(np.eye(4), reference=ntdeobnii).apply(
495+
nb.Nifti1Image(box, img.affine, img.header),
496+
order=0,
497+
)
498+
mask = np.asanyarray(ntdeobmask.dataobj, dtype=bool)
499+
513500
diff = (
514501
np.asanyarray(deobnii.dataobj, dtype="uint8")
515502
- np.asanyarray(ntdeobnii.dataobj, dtype="uint8")
516503
)
517-
deobnii.__class__(diff, deobnii.affine, deobnii.header).to_filename("diff.nii.gz")
518-
assert np.sqrt((diff[20:-20, 20:-20, 20:-20] ** 2).mean()) < 0.1
504+
assert np.sqrt((diff[mask] ** 2).mean()) < 0.1
519505

520506
# Confirm AFNI's rotation of axis is consistent with the one we introduced
521507
afni_warpdrive_inv = afni._afni_header(
@@ -526,9 +512,34 @@ def test_afni_oblique(tmpdir, parameters, swapaxes, testdata_path, dir_x, dir_y,
526512
assert np.allclose(afni_warpdrive_inv[:3, :3], R[:3, :3])
527513

528514
# Check nitransforms' estimation of warpdrive with header
529-
# Still haven't gotten my head around orientation, this test should not fail
530-
# nt_warpdrive_inv = afni._afni_warpdrive(newaff, deobaff, forward=False)
531-
# assert not np.allclose(
532-
# afni_warpdrive_inv[:3, :3],
533-
# nt_warpdrive_inv[:3, :3],
534-
# )
515+
nt_warpdrive_inv = afni._afni_warpdrive(img.affine, forward=False)
516+
assert not np.allclose(afni_warpdrive_inv, nt_warpdrive_inv)
517+
518+
519+
def _generate_reoriented(path, directions, swapaxes, parameters):
520+
img = nb.load(path)
521+
shape = np.array(img.shape[:3])
522+
hdr = img.header.copy()
523+
aff = img.affine.copy()
524+
data = np.asanyarray(img.dataobj, dtype="uint8")
525+
526+
if directions != (1, 1, 1):
527+
last_ijk = np.hstack((shape - 1, 1.0))
528+
last_xyz = aff @ last_ijk
529+
aff = np.diag((*directions, 1)) @ aff
530+
531+
for ax in range(3):
532+
if (directions[ax] == -1):
533+
aff[ax, 3] = last_xyz[ax]
534+
data = np.flip(data, ax)
535+
536+
if swapaxes is not None:
537+
data = np.swapaxes(data, swapaxes[0], swapaxes[1])
538+
aff[list(reversed(swapaxes)), :] = aff[(swapaxes), :]
539+
540+
R = from_matvec(euler2mat(**parameters), [0.0, 0.0, 0.0])
541+
542+
newaff = R @ aff
543+
hdr.set_qform(newaff, code=1)
544+
hdr.set_sform(newaff, code=1)
545+
return img.__class__(data, newaff, hdr), R

nitransforms/tests/test_linear.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,9 @@ def test_apply_linear_transform(tmpdir, get_testdata, get_testmask, image_orient
218218
nt_moved_mask.to_filename("nt_resampled_brainmask.nii.gz")
219219
diff = np.asanyarray(sw_moved_mask.dataobj) - np.asanyarray(nt_moved_mask.dataobj)
220220

221-
nt_moved_mask.__class__(diff, sw_moved_mask.affine, sw_moved_mask.header).to_filename("diff.nii.gz")
221+
nt_moved_mask.__class__(
222+
diff, sw_moved_mask.affine, sw_moved_mask.header
223+
).to_filename("diff.nii.gz")
222224

223225
assert np.sqrt((diff ** 2).mean()) < RMSE_TOL
224226
brainmask = np.asanyarray(nt_moved_mask.dataobj, dtype=bool)

0 commit comments

Comments
 (0)