Skip to content

Commit f1a4948

Browse files
committed
FIX: Make images "plumb" before running ANTs-SyN (and roll-back afterward)
Because ITK handles registration in "physical" coordinates, when the fixed image's affine is not aligned with the array's axis (i.e., is not plumb or aligned to the canonical axis) the transform is obtained with alignment to one axis, but that is not aligned with the data. To ensure the PE (phase-encoding) direction of the data array and the corresponding physical axis are aligned, this PR compensates for potental rotations. Resolves #12.
1 parent 5767c77 commit f1a4948

File tree

4 files changed

+231
-19
lines changed

4 files changed

+231
-19
lines changed

sdcflows/interfaces/bspline.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -241,19 +241,13 @@ def _run_interface(self, runtime):
241241
# Calculate the physical coordinates of target grid
242242
targetnii = nb.load(self.inputs.in_target)
243243
allmask = np.ones_like(targetnii.dataobj, dtype="uint8")
244-
# targetaff = targetnii.affine
245-
# voxels = np.argwhere(allmask == 1).astype("float32")
246-
# points = apply_affine(targetaff.astype("float32"), voxels)
247244

248245
weights = []
249246
coeffs = []
250247

251248
for cname in self.inputs.in_coeff:
252249
coeff_nii = nb.load(cname)
253250
wmat = grid_bspline_weights(targetnii, coeff_nii)
254-
# wmat = bspline_weights(
255-
# points, coeff_nii, mem_percent=0.1 if self.inputs.low_mem else None,
256-
# )
257251
weights.append(wmat)
258252
coeffs.append(coeff_nii.get_fdata(dtype="float32").reshape(-1))
259253

@@ -284,6 +278,7 @@ def _run_interface(self, runtime):
284278
field[..., phaseEncDim] = data.reshape(-1)
285279
aff = targetnii.affine.copy()
286280
aff[:3, 3] = 0.0
281+
# Multiplying by the affine implicitly applies the voxel size to the shift map
287282
field = nb.affines.apply_affine(aff, field).reshape(fieldshape)
288283
warpnii = targetnii.__class__(
289284
field[:, :, :, np.newaxis, :].astype("float32"), targetnii.affine, None

sdcflows/interfaces/tests/test_utils.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44
import nibabel as nb
55

6-
from ..utils import Flatten, ConvertWarp
6+
from ..utils import Flatten, ConvertWarp, Deoblique, Reoblique
77

88

99
def test_Flatten(tmpdir):
@@ -43,3 +43,47 @@ def test_ConvertWarp(tmpdir, shape):
4343
assert nii.header.get_data_dtype() == np.float32
4444
assert nii.header.get_intent() == ("vector", (), "")
4545
assert nii.shape == (10, 10, 10, 1, 3)
46+
47+
48+
@pytest.mark.parametrize(
49+
"angles,oblique",
50+
[
51+
((0, 0, 0), False),
52+
((0.9, 0.001, 0.001), True),
53+
((0, 0, 2 * np.pi), False),
54+
],
55+
)
56+
def test_Xeoblique(tmpdir, angles, oblique):
57+
"""Exercise De/Reoblique interfaces."""
58+
59+
tmpdir.chdir()
60+
61+
affine = nb.affines.from_matvec(nb.eulerangles.euler2mat(*angles))
62+
nb.Nifti1Image(np.zeros((10, 10, 10), dtype="uint8"), affine, None).to_filename(
63+
"epi.nii.gz"
64+
)
65+
66+
result = (
67+
Deoblique(
68+
in_epi="epi.nii.gz",
69+
mask_epi="epi.nii.gz",
70+
in_anat="epi.nii.gz",
71+
mask_anat="epi.nii.gz",
72+
)
73+
.run()
74+
.outputs
75+
)
76+
77+
assert np.allclose(nb.load(result.out_epi).affine, affine) is not oblique
78+
79+
reoblique = (
80+
Reoblique(
81+
in_plumb=result.out_epi,
82+
in_field=result.out_epi,
83+
in_epi="epi.nii.gz",
84+
)
85+
.run()
86+
.outputs
87+
)
88+
89+
assert np.allclose(nb.load(reoblique.out_epi).affine, affine)

sdcflows/interfaces/utils.py

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,30 @@
1111
OutputMultiObject,
1212
)
1313

14+
OBLIQUE_THRESHOLD_DEG = 0.5
15+
1416

1517
class _FlattenInputSpec(BaseInterfaceInputSpec):
1618
in_data = InputMultiObject(
17-
File(exists=True), mandatory=True, desc="list of input data",
19+
File(exists=True),
20+
mandatory=True,
21+
desc="list of input data",
1822
)
1923
in_meta = InputMultiObject(
20-
traits.DictStrAny, mandatory=True, desc="list of metadata",
24+
traits.DictStrAny,
25+
mandatory=True,
26+
desc="list of metadata",
2127
)
2228
max_trs = traits.Int(50, usedefault=True, desc="only pick first TRs")
2329

2430

2531
class _FlattenOutputSpec(TraitedSpec):
2632
out_list = OutputMultiObject(
27-
traits.Tuple(File(exists=True), traits.DictStrAny,), desc="list of output files"
33+
traits.Tuple(
34+
File(exists=True),
35+
traits.DictStrAny,
36+
),
37+
desc="list of output files",
2838
)
2939
out_data = OutputMultiObject(File(exists=True))
3040
out_meta = OutputMultiObject(traits.DictStrAny)
@@ -71,6 +81,97 @@ def _run_interface(self, runtime):
7181
return runtime
7282

7383

84+
class _DeobliqueInputSpec(BaseInterfaceInputSpec):
85+
in_epi = File(
86+
exists=True, mandatory=True, desc="the EPI dataset potentially oblique"
87+
)
88+
mask_epi = File(
89+
exists=True,
90+
mandatory=True,
91+
desc="a binary mask corresponding to the EPI dataset",
92+
)
93+
in_anat = File(
94+
exists=True,
95+
mandatory=True,
96+
desc="the corresponging anatomical dataset potentially oblique",
97+
)
98+
mask_anat = File(
99+
exists=True,
100+
mandatory=True,
101+
desc="a binary mask corresponding to the anatomical dataset",
102+
)
103+
104+
105+
class _DeobliqueOutputSpec(TraitedSpec):
106+
out_epi = File(exists=True, desc="the plumb, EPI dataset")
107+
out_anat = File(exists=True, desc="the plumb, anatomical dataset")
108+
mask_epi = File(
109+
exists=True,
110+
mandatory=True,
111+
desc="a binary mask corresponding to the EPI dataset",
112+
)
113+
mask_anat = File(
114+
exists=True,
115+
mandatory=True,
116+
desc="a binary mask corresponding to the anatomical dataset",
117+
)
118+
119+
120+
class Deoblique(SimpleInterface):
121+
"""Make a dataset plumb."""
122+
123+
input_spec = _DeobliqueInputSpec
124+
output_spec = _DeobliqueOutputSpec
125+
126+
def _run_interface(self, runtime):
127+
(
128+
self._results["out_epi"],
129+
self._results["out_anat"],
130+
self._results["mask_epi"],
131+
self._results["mask_anat"],
132+
) = _deoblique(
133+
self.inputs.in_epi,
134+
self.inputs.in_anat,
135+
self.inputs.mask_epi,
136+
self.inputs.mask_anat,
137+
newpath=runtime.cwd,
138+
)
139+
return runtime
140+
141+
142+
class _ReobliqueInputSpec(BaseInterfaceInputSpec):
143+
in_plumb = File(exists=True, mandatory=True, desc="the plumb EPI image")
144+
in_field = File(
145+
exists=True,
146+
mandatory=True,
147+
desc="the plumb field map, extracted from the displacements field estimated by SyN",
148+
)
149+
in_epi = File(
150+
exists=True, mandatory=True, desc="the original, potentially oblique EPI image"
151+
)
152+
153+
154+
class _ReobliqueOutputSpec(TraitedSpec):
155+
out_epi = File(exists=True, desc="the reoblique'd EPI image")
156+
out_field = File(exists=True, desc="the reoblique'd EPI image")
157+
158+
159+
class Reoblique(SimpleInterface):
160+
"""Make a dataset plumb."""
161+
162+
input_spec = _ReobliqueInputSpec
163+
output_spec = _ReobliqueOutputSpec
164+
165+
def _run_interface(self, runtime):
166+
(self._results["out_epi"], self._results["out_field"],) = _reoblique(
167+
self.inputs.in_epi,
168+
self.inputs.in_plumb,
169+
self.inputs.in_field,
170+
newpath=runtime.cwd,
171+
)
172+
return runtime
173+
174+
74175
def _flatten(inlist, max_trs=50, out_dir=None):
75176
"""
76177
Split the input EPIs and generate a flattened list with corresponding metadata.
@@ -120,3 +221,63 @@ def _qwarp2ants(in_file, newpath=None):
120221
data = np.squeeze(nii.get_fdata(dtype="float32"))[..., np.newaxis, :]
121222
nb.Nifti1Image(data, nii.affine, hdr).to_filename(out_file)
122223
return out_file
224+
225+
226+
def _deoblique(in_epi, in_anat, mask_epi, mask_anat, newpath=None):
227+
import numpy as np
228+
import nibabel as nb
229+
from nipype.utils.filemanip import fname_presuffix
230+
231+
epinii = nb.load(in_epi)
232+
if np.all(nb.affines.obliquity(epinii.affine)) < OBLIQUE_THRESHOLD_DEG:
233+
return in_epi, in_anat, mask_epi, mask_anat
234+
235+
newaff = np.eye(4)
236+
newaff[:3, :3] = np.diag(np.array(epinii.header.get_zooms()[:3]))
237+
newaff[:3, 3] -= (np.array(epinii.shape[:3]) - 1) * 0.5
238+
239+
hdr = epinii.header.copy()
240+
hdr.set_qform(newaff, code=1)
241+
hdr.set_sform(newaff, code=1)
242+
newepi = epinii.__class__(epinii.dataobj, newaff, hdr)
243+
out_epi = fname_presuffix(in_epi, suffix="_plumb", newpath=newpath)
244+
newepi.to_filename(out_epi)
245+
246+
out_files = [out_epi]
247+
for fname in (in_anat, mask_epi, mask_anat):
248+
nii = nb.load(fname)
249+
hdr = nii.header.copy()
250+
hdr.set_qform(newaff, code=1)
251+
hdr.set_sform(newaff, code=1)
252+
newnii = nii.__class__(np.asanyarray(nii.dataobj), newaff, hdr)
253+
out = fname_presuffix(fname, suffix="_plumb", newpath=newpath)
254+
newnii.to_filename(out)
255+
out_files.append(out)
256+
257+
return tuple(out_files)
258+
259+
260+
def _reoblique(in_epi, in_plumb, in_field, newpath=None):
261+
import numpy as np
262+
import nibabel as nb
263+
from nipype.utils.filemanip import fname_presuffix
264+
265+
epinii = nb.load(in_epi)
266+
if np.all(nb.affines.obliquity(epinii.affine)) < OBLIQUE_THRESHOLD_DEG:
267+
return in_plumb, in_field
268+
269+
out_files = [
270+
fname_presuffix(f, suffix="_oriented", newpath=newpath)
271+
for f in (in_plumb, in_field)
272+
]
273+
plumbnii = nb.load(in_plumb)
274+
plumbnii.__class__(plumbnii.dataobj, epinii.affine, epinii.header).to_filename(
275+
out_files[0]
276+
)
277+
278+
fmapnii = nb.load(in_field)
279+
hdr = fmapnii.header.copy()
280+
hdr.set_qform(*epinii.header.get_qform(coded=True))
281+
hdr.set_sform(*epinii.header.get_sform(coded=True))
282+
fmapnii.__class__(fmapnii.dataobj, epinii.affine, hdr).to_filename(out_files[1])
283+
return out_files

sdcflows/workflows/fit/syn.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def init_syn_sdc_wf(
135135
from niworkflows.interfaces.nibabel import Binarize
136136
from ...interfaces.epi import EPIMask
137137
from ...utils.misc import front as _pop
138+
from ...interfaces.utils import Deoblique, Reoblique
138139
from ...interfaces.bspline import (
139140
BSplineApprox,
140141
DEFAULT_LF_ZOOMS_MM,
@@ -188,6 +189,9 @@ def init_syn_sdc_wf(
188189
)
189190
atlas_msk = pe.Node(Binarize(thresh_low=atlas_threshold), name="atlas_msk")
190191

192+
deoblique = pe.Node(Deoblique(), name="deoblique")
193+
reoblique = pe.Node(Reoblique(), name="reoblique")
194+
191195
# SyN Registration Core
192196
syn = pe.Node(
193197
Registration(from_file=pkgrf("sdcflows", "data/susceptibility_syn.json")),
@@ -221,24 +225,31 @@ def init_syn_sdc_wf(
221225
(("epi_ref", _pop), "ref_file")]),
222226
(inputnode, anat2epi, [(("epi_ref", _pop), "reference_image"),
223227
("anat2epi_xfm", "transforms")]),
224-
(inputnode, syn, [(("epi_ref", _pop), "moving_image"),
225-
("epi_mask", "moving_image_masks"),
226-
(("epi_ref", _warp_dir), "restrict_deformation")]),
228+
(inputnode, deoblique, [(("epi_ref", _pop), "in_epi"),
229+
("epi_mask", "mask_epi")]),
230+
(inputnode, reoblique, [(("epi_ref", _pop), "in_epi")]),
231+
(inputnode, syn, [(("epi_ref", _warp_dir), "restrict_deformation")]),
227232
(inputnode, unwarp_ref, [(("epi_ref", _pop), "reference_image"),
228233
(("epi_ref", _pop), "input_image")]),
229234
(inputnode, prior2epi, [(("epi_ref", _pop), "reference_image")]),
230235
(inputnode, extract_field, [("epi_ref", "epi_meta")]),
231236
(invert_t1w, anat2epi, [("out_file", "input_image")]),
232237
(transform_list, prior2epi, [("out", "transforms")]),
233238
(prior2epi, atlas_msk, [("output_image", "in_file")]),
234-
(anat2epi, syn, [("output_image", "fixed_image")]),
235-
(atlas_msk, syn, [(("out_mask", _fixed_masks_arg), "fixed_image_masks")]),
239+
(anat2epi, deoblique, [("output_image", "in_anat")]),
240+
(atlas_msk, deoblique, [("out_mask", "mask_anat")]),
241+
(deoblique, syn, [("out_epi", "moving_image"),
242+
("out_anat", "fixed_image"),
243+
("mask_epi", "moving_image_masks"),
244+
(("mask_anat", _fixed_masks_arg), "fixed_image_masks")]),
236245
(syn, extract_field, [("forward_transforms", "in_file")]),
237246
(syn, unwarp_ref, [("forward_transforms", "transforms")]),
238-
(unwarp_ref, epi_mask, [("output_image", "in_file")]),
239-
(extract_field, bs_filter, [("out", "in_data")]),
247+
(unwarp_ref, reoblique, [("output_image", "in_plumb")]),
248+
(reoblique, epi_mask, [("out_epi", "in_file")]),
249+
(extract_field, reoblique, [("out", "in_field")]),
250+
(reoblique, bs_filter, [("out_field", "in_data")]),
240251
(epi_mask, bs_filter, [("out_file", "in_mask")]),
241-
(unwarp_ref, outputnode, [("output_image", "fmap_ref")]),
252+
(reoblique, outputnode, [("out_epi", "fmap_ref")]),
242253
(epi_mask, outputnode, [("out_file", "fmap_mask")]),
243254
(bs_filter, outputnode, [
244255
("out_extrapolated" if not debug else "out_field", "fmap"),
@@ -308,7 +319,8 @@ def _extract_field(in_file, epi_meta):
308319
np.squeeze(fieldnii.get_fdata(dtype="float32"))[
309320
..., "ijk".index(epi_meta[1]["PhaseEncodingDirection"][0])
310321
]
311-
/ trt * (-1.0 if epi_meta[1]["PhaseEncodingDirection"].endswith("-") else 1.0)
322+
/ trt
323+
* (-1.0 if epi_meta[1]["PhaseEncodingDirection"].endswith("-") else 1.0)
312324
)
313325
out_file = fname_presuffix(in_file[0], suffix="_fieldmap")
314326
nii = nb.Nifti1Image(data, fieldnii.affine, None)

0 commit comments

Comments
 (0)