Skip to content

Commit 36c3d79

Browse files
authored
Merge pull request #339 from effigies/fix/fixcoeff_orientations
FIX: Reorient phase-encoding directions along with fieldmaps when preparing inputs to TOPUP
2 parents ab1b128 + ba4a1f6 commit 36c3d79

File tree

5 files changed

+172
-24
lines changed

5 files changed

+172
-24
lines changed

sdcflows/interfaces/bspline.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
)
4242

4343
from sdcflows.transform import grid_bspline_weights
44+
from sdcflows.utils.tools import reorient_pedir
4445

4546

4647
LOW_MEM_BLOCK_SIZE = 1000
@@ -518,38 +519,38 @@ def _fix_topup_fieldcoeff(in_coeff, fmap_ref, pe_dir, out_file=None):
518519
from pathlib import Path
519520
import numpy as np
520521
import nibabel as nb
521-
from sdcflows.utils.tools import ensure_positive_cosines
522522

523523
if out_file is None:
524524
out_file = Path("coefficients.nii.gz").absolute()
525525

526-
# Load reference image
527526
refnii = nb.load(fmap_ref)
528-
529-
# Load coefficients
530527
coeffnii = nb.load(in_coeff)
528+
529+
# Coefficients generated by TOPUP are in LAS space, and we will convert to RAS.
530+
# Reorient the reference image and phase-encoding direction to RAS
531+
ref_ornt = nb.io_orientation(refnii.affine)
532+
refnii_ras = refnii.as_reoriented(ref_ornt)
533+
coeff_pe_dir = reorient_pedir(pe_dir, ref_ornt)
534+
531535
# Coefficients - flip LR and overwrite coeffnii variable
532536
# Internal data orientation of FSL is LAS, so coefficients will be LR flipped,
533537
# and because the affine does not encode orientation (factors instead), this flip
534538
# always is implicit.
535539
# If the PE direction is x/i, the flip in the axis direction causes that the
536540
# fieldmap estimation must also be inverted in direction (multiply by -1.0)
537-
reverse_pe = -1.0 if "i" in pe_dir.replace("x", "i") else 1.0
538-
lr_axis = "".join(nb.aff2axcodes(coeffnii.affine)).index("R")
541+
reverse_pe = -1.0 if coeff_pe_dir[0] == "i" else 1.0
539542
coeffnii = coeffnii.__class__(
540-
reverse_pe * np.flip(np.asanyarray(coeffnii.dataobj), axis=lr_axis),
543+
reverse_pe * np.flip(np.asanyarray(coeffnii.dataobj), axis=0),
541544
coeffnii.affine,
542545
coeffnii.header,
543546
)
544-
# Ensure reference has positive director cosines
545-
refnii, ref_axcodes = ensure_positive_cosines(refnii)
546547

547548
# Get matrix of B-Spline control knots
548549
coeff_shape = np.array(coeffnii.shape[:3])
549550
# Get factors (w.r.t. reference's pixel sizes) to calculate separation btw control points
550551
factors = np.array(coeffnii.header.get_zooms()[:3])
551552
# Shape checking
552-
ref_shape = np.array(refnii.shape[:3])
553+
ref_shape = np.array(refnii_ras.shape[:3])
553554
exp_shape = ref_shape // factors + 3 * (factors > 1)
554555
if not np.all(coeff_shape == exp_shape):
555556
raise ValueError(
@@ -559,8 +560,8 @@ def _fix_topup_fieldcoeff(in_coeff, fmap_ref, pe_dir, out_file=None):
559560

560561
# Contextualize the control points in space with a proper NIfTI affine
561562
newaff = np.eye(4)
562-
newaff[:3, :3] = refnii.affine[:3, :3] * factors
563-
c_ref = nb.affines.apply_affine(refnii.affine, 0.5 * (ref_shape - 1))
563+
newaff[:3, :3] = refnii_ras.affine[:3, :3] * factors
564+
c_ref = nb.affines.apply_affine(refnii_ras.affine, 0.5 * (ref_shape - 1))
564565
c_coeff = nb.affines.apply_affine(newaff, 0.5 * (coeff_shape - 1))
565566
newaff[:3, 3] = c_ref - c_coeff
566567

@@ -621,18 +622,24 @@ def _b0_resampler(in_file, coeffs, pe, ro, hmc_xfm=None, unwarp=None, newpath=No
621622
# Load distorted image
622623
distorted_img = nb.load(in_file)
623624

624-
if unwarp.fit(distorted_img):
625+
# Reorient to RAS to ensure consistency with coefficients
626+
# The b-spline weight matrix is sensitive to orientation
627+
ornt = nb.io_orientation(distorted_img.affine)
628+
distorted_ras = distorted_img.as_reoriented(ornt)
629+
pe_ras = reorient_pedir(pe, ornt)
630+
631+
if unwarp.fit(distorted_ras):
625632
unwarp.mapped.to_filename(retval[2])
626633
else:
627634
retval[2] = None
628635

629636
# Unwarp
630-
unwarped_img = unwarp.apply(distorted_img, ro_time=ro, pe_dir=pe)
637+
unwarped_img = unwarp.apply(distorted_ras, ro_time=ro, pe_dir=pe_ras)
631638

632639
# Write out to disk
633640
unwarped_img.to_filename(retval[0])
634641

635642
# Store the corresponding spatial transformation
636-
unwarp.to_displacements(ro_time=ro, pe_dir=pe).to_filename(retval[1])
643+
unwarp.to_displacements(ro_time=ro, pe_dir=pe_ras).to_filename(retval[1])
637644

638645
return retval

sdcflows/interfaces/tests/test_bspline.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ def test_bsplines(tmp_path, testnum):
7272
# Approximate the interpolated target
7373
test2 = BSplineApprox(
7474
in_data=test1.outputs.out_field,
75-
in_mask=str(tmp_path / "target.nii.gz"),
7675
bs_spacing=[(4, 6, 8)],
7776
zooms_min=0,
7877
recenter=False,

sdcflows/interfaces/utils.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
# https://www.nipreps.org/community/licensing/
2222
#
2323
"""Utilities."""
24+
from itertools import product
2425
from nipype.interfaces.base import (
2526
BaseInterfaceInputSpec,
2627
TraitedSpec,
@@ -37,6 +38,8 @@
3738
)
3839
from nipype.interfaces.mixins import CopyHeaderInterface as _CopyHeaderInterface
3940

41+
from sdcflows.utils.tools import reorient_pedir
42+
4043
OBLIQUE_THRESHOLD_DEG = 0.5
4144

4245

@@ -147,6 +150,75 @@ def _run_interface(self, runtime):
147150
return runtime
148151

149152

153+
class _ReorientImageAndMetadataInputSpec(TraitedSpec):
154+
in_file = File(exists=True, mandatory=True, desc="Input 3- or 4D image")
155+
target_orientation = traits.Str(
156+
desc="Axis codes of coordinate system to reorient to"
157+
)
158+
pe_dir = InputMultiObject(
159+
traits.Enum(
160+
*["".join(p) for p in product("ijkxyz", ("", "-"))],
161+
mandatory=True,
162+
desc="Phase encoding direction",
163+
)
164+
)
165+
166+
167+
class _ReorientImageAndMetadataOutputSpec(TraitedSpec):
168+
out_file = File(desc="Reoriented image")
169+
pe_dir = OutputMultiObject(
170+
traits.Enum(
171+
*["".join(p) for p in product("ijkxyz", ("", "-"))],
172+
desc="Phase encoding direction in reoriented image",
173+
)
174+
)
175+
176+
177+
class ReorientImageAndMetadata(SimpleInterface):
178+
input_spec = _ReorientImageAndMetadataInputSpec
179+
output_spec = _ReorientImageAndMetadataOutputSpec
180+
181+
def _run_interface(self, runtime):
182+
import numpy as np
183+
import nibabel as nb
184+
from nipype.utils.filemanip import fname_presuffix
185+
186+
target = self.inputs.target_orientation.upper()
187+
if not all(code in "RASLPI" for code in target):
188+
raise ValueError(
189+
f"Invalid orientation code {self.inputs.target_orientation}"
190+
)
191+
192+
img = nb.load(self.inputs.in_file)
193+
img2target = nb.orientations.ornt_transform(
194+
nb.io_orientation(img.affine),
195+
nb.orientations.axcodes2ornt(self.inputs.target_orientation),
196+
).astype(int)
197+
198+
# Identity transform
199+
if np.array_equal(img2target, [[0, 1], [1, 1], [2, 1]]):
200+
self._results = dict(
201+
out_file=self.inputs.in_file,
202+
pe_dir=self.inputs.pe_dir,
203+
)
204+
return runtime
205+
206+
reoriented = img.as_reoriented(img2target)
207+
208+
pe_dirs = [reorient_pedir(pe_dir, img2target) for pe_dir in self.inputs.pe_dir]
209+
210+
self._results = dict(
211+
out_file=fname_presuffix(
212+
self.inputs.in_file, suffix=target, newpath=runtime.cwd
213+
),
214+
pe_dir=pe_dirs,
215+
)
216+
217+
reoriented.to_filename(self._results["out_file"])
218+
219+
return runtime
220+
221+
150222
class _ConvertWarpInputSpec(BaseInterfaceInputSpec):
151223
in_file = File(exists=True, mandatory=True, desc="output of 3dQwarp")
152224

sdcflows/utils/tools.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ def ensure_positive_cosines(img):
7070
"""
7171
import nibabel as nb
7272

73-
img_axcodes = nb.aff2axcodes(img.affine)
74-
in_ornt = nb.orientations.axcodes2ornt(img_axcodes)
73+
in_ornt = nb.io_orientation(img.affine)
74+
img_axcodes = nb.orientations.ornt2axcodes(in_ornt)
7575
out_ornt = in_ornt.copy()
7676
out_ornt[:, 1] = 1
7777
ornt_xfm = nb.orientations.ornt_transform(in_ornt, out_ornt)
@@ -154,3 +154,70 @@ def brain_masker(in_file, out_file=None, padding=5):
154154
img.__class__(data, img.affine, img.header).to_filename(out_brain)
155155

156156
return str(out_brain), str(out_probseg), str(out_mask)
157+
158+
159+
def reorient_pedir(pe_dir, source_ornt, target_ornt=None):
160+
"""Return updated PhaseEncodingDirection to account for an image data array rotation
161+
162+
Orientations form a natural group with identity, product and inverse.
163+
This function thus has the following properties (here ``e`` is the identity,
164+
``*`` the product and ``-`` the inverse; ``a`` and ``b`` are arbitrary orientations):
165+
166+
reorient(pe_dir, e, e) == pe_dir
167+
reorient(pe_dir, a, e) == reorient(pe_dir, a)
168+
reorient(pe_dir, e, b) == reorient(pe_dir, -b)
169+
reorient(pe_dir, a, b) == reorient(pe_dir, a * -b, e)
170+
171+
Therefore, this function accepts one or two orientations, and precomputed transforms
172+
from a to b can be passed as the "source" orientation.
173+
174+
Passing only a source orientation will rotate into RAS:
175+
176+
>>> from nibabel.orientations import axcodes2ornt
177+
>>> reorient_pedir("j", axcodes2ornt("RAS"))
178+
'j'
179+
>>> reorient_pedir("i", axcodes2ornt("PSL"))
180+
'j-'
181+
182+
Passing only a target_ornt will rotate from RAS:
183+
184+
>>> reorient_pedir("j", source_ornt=None, target_ornt=axcodes2ornt("RAS"))
185+
'j'
186+
>>> reorient_pedir("i", source_ornt=None, target_ornt=axcodes2ornt("PSL"))
187+
'k-'
188+
189+
Passing both will rotate from source to target.
190+
191+
>>> reorient_pedir("j", axcodes2ornt("LPS"), axcodes2ornt("AIR"))
192+
'i-'
193+
194+
Passing a transform orientation as source_ornt will perform the transform,
195+
and passing it as ``target_ornt`` will invert the transform:
196+
197+
>>> from nibabel.orientations import ornt_transform
198+
>>> xfm = ornt_transform(axcodes2ornt("LPS"), axcodes2ornt("AIR"))
199+
>>> reorient_pedir("j", xfm)
200+
'i-'
201+
>>> reorient_pedir("j", source_ornt=None, target_ornt=xfm)
202+
'k-'
203+
"""
204+
from nibabel.orientations import ornt_transform
205+
206+
if source_ornt is None:
207+
source_ornt = [[0, 1], [1, 1], [2, 1]]
208+
if target_ornt is None:
209+
target_ornt = [[0, 1], [1, 1], [2, 1]]
210+
211+
xfm = ornt_transform(source_ornt, target_ornt).astype(int) # shape: (3, 2)
212+
213+
directions = "ijk" if pe_dir[0] in "ijk" else "xyz"
214+
215+
source_axis = directions.index(pe_dir[0])
216+
source_flip = pe_dir[1:] == "-"
217+
218+
axis_xfm = xfm[source_axis, :] # shape: (2,)
219+
220+
target_axis = directions[axis_xfm[0]]
221+
target_flip = source_flip ^ (axis_xfm[1] == -1)
222+
223+
return f"{target_axis}-" if target_flip else target_axis

sdcflows/workflows/fit/pepolar.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def init_topup_wf(
9292

9393
from ...utils.misc import front as _front
9494
from ...interfaces.epi import GetReadoutTime, SortPEBlips
95-
from ...interfaces.utils import UniformGrid, PadSlices
95+
from ...interfaces.utils import UniformGrid, PadSlices, ReorientImageAndMetadata
9696
from ...interfaces.bspline import TOPUPCoeffReorient
9797
from ..ancillary import init_brainextraction_wf
9898

@@ -145,7 +145,7 @@ def init_topup_wf(
145145
setwise_avg = pe.Node(RobustAverage(num_threads=omp_nthreads), name="setwise_avg")
146146
# The core of the implementation
147147
# Feed the input images in LAS orientation, so FSL does not run funky reorientations
148-
to_las = pe.Node(ReorientImage(target_orientation="LAS"), name="to_las")
148+
to_las = pe.Node(ReorientImageAndMetadata(target_orientation="LAS"), name="to_las")
149149
topup = pe.Node(
150150
TOPUP(
151151
config=_pkg_fname("sdcflows", f"data/flirtsch/b02b0{'_quick' * sloppy}.cnf")
@@ -172,16 +172,19 @@ def init_topup_wf(
172172
(regrid, sort_pe_blips, [("out_data", "in_data")]),
173173
(readout_time, sort_pe_blips, [("readout_time", "readout_times"),
174174
("pe_dir_fsl", "pe_dirs_fsl")]),
175-
(sort_pe_blips, topup, [("readout_times", "readout_times"),
176-
("pe_dirs_fsl", "encoding_direction")]),
177-
(sort_pe_blips, fix_coeff, [(("pe_dirs", _front), "pe_dir")]),
175+
(sort_pe_blips, topup, [("readout_times", "readout_times")]),
178176
(setwise_avg, fix_coeff, [("out_file", "fmap_ref")]),
179177
(sort_pe_blips, concat_blips, [("out_data", "in_files")]),
180178
(concat_blips, pad_blip_slices, [("out_file", "in_file")]),
181179
(pad_blip_slices, setwise_avg, [("out_file", "in_file")]),
182180
(setwise_avg, to_las, [("out_hmc_volumes", "in_file")]),
183-
(to_las, topup, [("out_file", "in_file")]),
181+
(sort_pe_blips, to_las, [("pe_dirs_fsl", "pe_dir")]),
182+
(to_las, topup, [
183+
("out_file", "in_file"),
184+
("pe_dir", "encoding_direction"),
185+
]),
184186
(topup, fix_coeff, [("out_fieldcoef", "in_coeff")]),
187+
(to_las, fix_coeff, [(("pe_dir", _front), "pe_dir")]),
185188
(topup, outputnode, [("out_jacs", "jacobians"),
186189
("out_mats", "xfms")]),
187190
(ref_average, brainextraction_wf, [("out_file", "inputnode.in_file")]),

0 commit comments

Comments
 (0)