Skip to content

Commit 7f72285

Browse files
authored
Merge pull request #154 from oesteban/enh/topup-coeff-conformation
ENH: Massage TOPUP's fieldcoeff files to be compatible with ours
2 parents 3361f8a + 6088fe3 commit 7f72285

File tree

6 files changed

+130
-3
lines changed

6 files changed

+130
-3
lines changed

sdcflows/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""py.test configuration"""
1+
"""py.test configuration."""
22
import os
33
from pathlib import Path
44
import numpy

sdcflows/interfaces/bspline.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,60 @@ def _run_interface(self, runtime):
317317
return runtime
318318

319319

320+
class _TOPUPCoeffReorientInputSpec(BaseInterfaceInputSpec):
321+
in_coeff = InputMultiObject(
322+
File(exist=True), mandatory=True, desc="input coefficients file(s) from TOPUP"
323+
)
324+
fmap_ref = File(exists=True, mandatory=True, desc="the fieldmap reference")
325+
326+
327+
class _TOPUPCoeffReorientOutputSpec(TraitedSpec):
328+
out_coeff = OutputMultiObject(File(exists=True), desc="patched coefficients")
329+
330+
331+
class TOPUPCoeffReorient(SimpleInterface):
332+
"""
333+
Revise the orientation of TOPUP-generated B-Spline coefficients.
334+
335+
TOUP-generated "fieldcoeff" files are just B-Spline fields, where the shape
336+
of the field is fixated to be a decimated grid of the original image by an
337+
integer factor and added 3 pixels on each dimension.
338+
This is one root reason why TOPUP errors (FSL 6) or segfaults (FSL5), when the
339+
input image has odd number of voxels along one or more directions.
340+
341+
These "fieldcoeff" are fixated to be zero-centered, and have "plumb" orientation
342+
(as in, aligned with cardinal/imaging axes).
343+
The q-form of these NIfTI files is always diagonal, with the decimation factors
344+
set on the diagonal (and hence, the voxel zooms).
345+
The origin of the q-form is set to the reference image's shape.
346+
347+
This interface modifies these coefficient files to be fully-fledged NIfTI images
348+
aligned with the reference image.
349+
Therefore, the s-form header of the coefficients file is updated to match that
350+
of the reference file.
351+
The s-form header is used because the imaging axes may be oblique.
352+
353+
The q-form retains the original header and is marked with code 0.
354+
355+
"""
356+
357+
input_spec = _TOPUPCoeffReorientInputSpec
358+
output_spec = _TOPUPCoeffReorientOutputSpec
359+
360+
def _run_interface(self, runtime):
361+
self._results["out_coeff"] = [
362+
str(
363+
_fix_topup_fieldcoeff(
364+
in_coeff,
365+
self.inputs.fmap_ref,
366+
fname_presuffix(in_coeff, suffix="_fixed", newpath=runtime.cwd),
367+
)
368+
)
369+
for in_coeff in self.inputs.in_coeff
370+
]
371+
return runtime
372+
373+
320374
def bspline_grid(img, control_zooms_mm=DEFAULT_ZOOMS_MM):
321375
"""Create a :obj:`~nibabel.nifti1.Nifti1Image` embedding the location of control points."""
322376
if isinstance(img, (str, Path)):
@@ -437,3 +491,36 @@ def _move_coeff(in_coeff, fmap_ref, transform):
437491
img.__class__(img.dataobj, newaff, img.header).to_filename(out[-1])
438492

439493
return out
494+
495+
496+
def _fix_topup_fieldcoeff(in_coeff, fmap_ref, out_file=None):
497+
"""Read in a coefficients file generated by TOPUP and fix x-form headers."""
498+
from pathlib import Path
499+
import numpy as np
500+
import nibabel as nb
501+
502+
if out_file is None:
503+
out_file = Path("coefficients.nii.gz").absolute()
504+
505+
coeffnii = nb.load(in_coeff)
506+
refnii = nb.load(fmap_ref)
507+
508+
coeff_shape = np.array(coeffnii.shape[:3])
509+
ref_shape = np.array(refnii.shape[:3])
510+
factors = coeffnii.header.get_zooms()[:3]
511+
if not np.all(coeff_shape == ref_shape // factors + 3):
512+
raise ValueError(
513+
f"Shape of coefficients file {coeff_shape} does not meet the "
514+
f"expectation given the reference's shape {ref_shape}."
515+
)
516+
newaff = np.eye(4)
517+
newaff[:3, :3] = refnii.affine[:3, :3] * factors
518+
c_ref = nb.affines.apply_affine(refnii.affine, 0.5 * (ref_shape - 1))
519+
c_coeff = nb.affines.apply_affine(newaff, 0.5 * (coeff_shape - 1))
520+
newaff[:3, 3] = c_ref - c_coeff
521+
header = coeffnii.header.copy()
522+
coeffnii.header.set_qform(coeffnii.header.get_qform(coded=False), code=0)
523+
coeffnii.header.set_sform(newaff, code=1)
524+
525+
coeffnii.__class__(coeffnii.dataobj, newaff, header).to_filename(out_file)
526+
return out_file

sdcflows/interfaces/tests/test_bspline.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
import nibabel as nb
55
import pytest
66

7-
from ..bspline import bspline_grid, Coefficients2Warp, BSplineApprox
7+
from ..bspline import (
8+
bspline_grid,
9+
Coefficients2Warp,
10+
BSplineApprox,
11+
TOPUPCoeffReorient,
12+
_fix_topup_fieldcoeff,
13+
)
814

915

1016
@pytest.mark.parametrize("testnum", range(100))
@@ -52,3 +58,29 @@ def test_bsplines(tmp_path, testnum):
5258

5359
# Absolute error of the interpolated field is always below 2 Hz
5460
assert np.all(np.abs(nb.load(test2.outputs.out_error).get_fdata()) < 2)
61+
62+
63+
def test_topup_coeffs(tmpdir, testdata_dir):
64+
"""Check the revision of TOPUP headers."""
65+
tmpdir.chdir()
66+
result = TOPUPCoeffReorient(
67+
in_coeff=str(testdata_dir / "topup-coeff.nii.gz"),
68+
fmap_ref=str(testdata_dir / "epi.nii.gz"),
69+
).run()
70+
71+
nii = nb.load(result.outputs.out_coeff)
72+
ctrl = nb.load(testdata_dir / "topup-coeff-fixed.nii.gz")
73+
assert np.allclose(nii.affine, ctrl.affine)
74+
75+
nb.Nifti1Image(nii.get_fdata()[:-1, :-1, :-1], nii.affine, nii.header).to_filename(
76+
"failing.nii.gz"
77+
)
78+
79+
with pytest.raises(ValueError):
80+
TOPUPCoeffReorient(
81+
in_coeff="failing.nii.gz", fmap_ref=str(testdata_dir / "epi.nii.gz"),
82+
).run()
83+
84+
# Test automatic output file name generation, just for coverage
85+
with pytest.raises(ValueError):
86+
_fix_topup_fieldcoeff("failing.nii.gz", str(testdata_dir / "epi.nii.gz"))
29.1 KB
Binary file not shown.
29 KB
Binary file not shown.

sdcflows/workflows/fit/pepolar.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def init_topup_wf(omp_nthreads=1, debug=False, name="pepolar_estimate_wf"):
6969

7070
from ...interfaces.epi import GetReadoutTime, EPIMask
7171
from ...interfaces.utils import Flatten
72+
from ...interfaces.bspline import TOPUPCoeffReorient
7273

7374
workflow = Workflow(name=name)
7475
workflow.__desc__ = f"""\
@@ -113,6 +114,11 @@ def init_topup_wf(omp_nthreads=1, debug=False, name="pepolar_estimate_wf"):
113114
)
114115

115116
epi_mask = pe.Node(EPIMask(), name="epi_mask")
117+
118+
fix_coeff = pe.Node(
119+
TOPUPCoeffReorient(), name="fix_coeff", run_without_submitting=True
120+
)
121+
116122
# fmt: off
117123
workflow.connect([
118124
(inputnode, flatten, [("in_data", "in_data"),
@@ -124,14 +130,16 @@ def init_topup_wf(omp_nthreads=1, debug=False, name="pepolar_estimate_wf"):
124130
(readout_time, topup, [("readout_time", "readout_times")]),
125131
(concat_blips, topup, [("out_file", "in_file")]),
126132
(topup, merge_corrected, [("out_corrected", "in_files")]),
133+
(topup, fix_coeff, [("out_fieldcoef", "in_coeff"),
134+
("out_corrected", "fmap_ref")]),
127135
(topup, outputnode, [("out_field", "fmap"),
128-
("out_fieldcoef", "fmap_coeff"),
129136
("out_jacs", "jacobians"),
130137
("out_mats", "xfms"),
131138
("out_warps", "out_warps")]),
132139
(merge_corrected, epi_mask, [("out_avg", "in_file")]),
133140
(merge_corrected, outputnode, [("out_avg", "fmap_ref")]),
134141
(epi_mask, outputnode, [("out_file", "fmap_mask")]),
142+
(fix_coeff, outputnode, [("out_coeff", "fmap_coeff")]),
135143
])
136144
# fmt: on
137145

0 commit comments

Comments
 (0)