Skip to content

Commit 06df85a

Browse files
mattcieslaktsalo
andauthored
Make sure motion parameters are reported in the same convention for tortoise and eddy (#1084)
* Export DWI head-motion parameters in RAS Motion confounds were reported in backend-specific frames: eddy/SHORELine via get_fsl_motion_params (FSL) and DIFFPREP via raw TORTOISE Okan params (LPS). Relative to the RAS world frame this flips trans_x/rot_x (eddy) and trans_x/trans_y/rot_x/rot_y (DIFFPREP), so the two backends disagreed and neither matched RAS-defined ground truth. Add get_ras_motion_params (per-volume ITK/LPS transform -> RAS, decomposed about the image center) and use it in CombineMotions (eddy/SHORELine); negate the x,y components of the DIFFPREP Okan params (LPS -> RAS). The confounds trans_*/rot_* columns are now RAS for every HMC backend. Add test_get_ras_motion_params_no_axis_flip, which verifies no axis flip on a radiological grid. * also make the ecc parameters appear * update diffprep param test --------- Co-authored-by: Taylor Salo <salot@pennmedicine.upenn.edu>
1 parent 76b7606 commit 06df85a

23 files changed

Lines changed: 242 additions & 19 deletions

qsiprep/interfaces/confounds.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
class GatherConfoundsInputSpec(BaseInterfaceInputSpec):
3636
fd = File(exists=True, desc='input framewise displacement')
3737
motion = File(exists=True, desc='input motion parameters')
38+
ec = File(exists=True, desc='eddy-current field parameters (headed TSV)')
3839
sliceqc_file = File(exists=True, desc='output from sliceqc')
3940
original_files = traits.List(desc='original grouping of each volume')
4041
original_bvecs = InputMultiObject(File(exists=True), desc='original bvec files')
@@ -45,6 +46,7 @@ class GatherConfoundsInputSpec(BaseInterfaceInputSpec):
4546
class GatherConfoundsOutputSpec(TraitedSpec):
4647
confounds_file = File(exists=True, desc='output confounds file')
4748
confounds_list = traits.List(traits.Str, desc='list of headers')
49+
confounds_metadata = traits.Dict(desc='per-column descriptions for the confounds JSON sidecar')
4850

4951

5052
class GatherConfounds(SimpleInterface):
@@ -61,6 +63,7 @@ def _run_interface(self, runtime):
6163
fdisp=self.inputs.fd,
6264
sliceqc_file=self.inputs.sliceqc_file,
6365
motion=self.inputs.motion,
66+
ec=self.inputs.ec,
6467
original_files=self.inputs.original_files,
6568
original_bvals=concatenate_bvals(self.inputs.original_bvals, None),
6669
original_bvecs=concatenate_bvecs(self.inputs.original_bvecs),
@@ -69,12 +72,55 @@ def _run_interface(self, runtime):
6972
)
7073
self._results['confounds_file'] = combined_out
7174
self._results['confounds_list'] = confounds_list
75+
columns = pd.read_csv(combined_out, sep='\t', nrows=0).columns.tolist()
76+
self._results['confounds_metadata'] = _confounds_column_metadata(columns)
7277
return runtime
7378

7479

80+
def _confounds_column_metadata(columns):
81+
"""Per-column descriptions for the confounds JSON sidecar.
82+
83+
Motion columns are RAS+ (translation mm, rotation rad). The eddy-current
84+
columns are the raw per-volume field coefficients each backend fits; they are
85+
described at the block level (linear / quadratic / centre) with a model
86+
reference rather than a per-column basis, since the exact ordering is defined
87+
by FSL eddy (``--flm``) and TORTOISE (``OkanQuadraticTransform``).
88+
"""
89+
motion = {
90+
'trans_x': 'Translation along RAS+ x (mm)',
91+
'trans_y': 'Translation along RAS+ y (mm)',
92+
'trans_z': 'Translation along RAS+ z (mm)',
93+
'rot_x': 'Rotation about RAS+ x (radians)',
94+
'rot_y': 'Rotation about RAS+ y (radians)',
95+
'rot_z': 'Rotation about RAS+ z (radians)',
96+
}
97+
eddy_block = (
98+
'FSL eddy first-level-model eddy-current field coefficient (from '
99+
'.eddy_parameters). For --flm=quadratic there are 10 per volume: ~3 linear '
100+
'(x, y, z), ~6 quadratic/cross, and 1 spare/constant. The exact per-column '
101+
'basis is defined by FSL eddy; see its documentation.'
102+
)
103+
okan_block = (
104+
'TORTOISE DIFFPREP OkanQuadraticTransform eddy parameter (cols 6-23 of the '
105+
'24-parameter transform): the quadratic eddy-current field (~3 linear x/y/z '
106+
'plus quadratic) and the rotation/eddy centres. The exact per-column basis '
107+
'is defined by TORTOISE.'
108+
)
109+
meta = {}
110+
for col in columns:
111+
if col in motion:
112+
meta[col] = {'Description': motion[col]}
113+
elif col.startswith('eddy_ec_'):
114+
meta[col] = {'Description': eddy_block, 'Source': 'FSL eddy'}
115+
elif col.startswith('diffprep_ec_'):
116+
meta[col] = {'Description': okan_block, 'Source': 'TORTOISE DIFFPREP'}
117+
return meta
118+
119+
75120
def _gather_confounds(
76121
fdisp=None,
77122
motion=None,
123+
ec=None,
78124
sliceqc_file=None,
79125
newpath=None,
80126
original_files=None,
@@ -126,7 +172,11 @@ def _adjust_indices(left_df, right_df):
126172

127173
all_files = []
128174
confounds_list = []
129-
for confound, name in ((fdisp, 'Framewise displacement'), (motion, 'Motion parameters')):
175+
for confound, name in (
176+
(fdisp, 'Framewise displacement'),
177+
(motion, 'Motion parameters'),
178+
(ec, 'Eddy-current parameters'),
179+
):
130180
if confound is not None and isdefined(confound):
131181
confounds_list.append(name)
132182
if os.path.exists(confound) and os.stat(confound).st_size > 0:

qsiprep/interfaces/eddy.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,28 +302,65 @@ def _format_arg(self, name, spec, value):
302302
return super()._format_arg(name, spec, value)
303303

304304

305+
def _fsl_to_ras_axis_flip(ref_file):
306+
"""Per-axis ±1 to convert FSL rigid params to RAS+ for ``ref_file``.
307+
308+
FSL reports motion in its radiological voxel frame; the RAS direction of each
309+
axis follows the sign of the affine diagonal, with FSL flipping x for a
310+
neurological (positive-determinant) image. Applied to both translation and
311+
(in-frame Euler) rotation components. Assumes an axis-aligned affine, which
312+
holds for essentially all DWI acquisitions.
313+
"""
314+
aff = nb.load(ref_file).affine[:3, :3]
315+
flip = np.sign(np.diag(aff))
316+
if np.linalg.det(aff) > 0:
317+
flip[0] *= -1
318+
return flip
319+
320+
305321
class Eddy2SPMMotionInputSpec(BaseInterfaceInputSpec):
306322
eddy_motion = File(exists=True)
323+
ref_file = File(exists=True, desc='reference image defining the FSL<->RAS axis convention')
307324

308325

309326
class Eddy2SPMMotionOututSpec(TraitedSpec):
310327
spm_motion_file = File(exists=True)
328+
eddy_ec_file = File(exists=True)
311329

312330

313331
class Eddy2SPMMotion(SimpleInterface):
314332
input_spec = Eddy2SPMMotionInputSpec
315333
output_spec = Eddy2SPMMotionOututSpec
316334

317335
def _run_interface(self, runtime):
318-
# Load the eddy motion params File
336+
# eddy_parameters columns: 0-5 are rigid motion (3 translation mm, 3 rotation rad) in
337+
# FSL's radiological frame; the remaining columns are the eddy-current field
338+
# coefficients (10 for --flm=quadratic: ~3 linear x/y/z + ~6 quadratic + 1 spare).
319339
eddy_motion = np.loadtxt(self.inputs.eddy_motion)
320-
spm_motion = eddy_motion[:, :6]
340+
if eddy_motion.ndim == 1:
341+
eddy_motion = eddy_motion[np.newaxis, :]
342+
343+
# Rigid motion, converted FSL -> RAS+ so it matches the SHORELine/DIFFPREP export.
344+
spm_motion = eddy_motion[:, :6].astype(float)
345+
if isdefined(self.inputs.ref_file):
346+
flip = _fsl_to_ras_axis_flip(self.inputs.ref_file)
347+
spm_motion[:, :3] *= flip
348+
spm_motion[:, 3:6] *= flip
321349
spm_motion_file = fname_presuffix(
322350
self.inputs.eddy_motion, suffix='spm_rp.txt', use_ext=False, newpath=runtime.cwd
323351
)
324352
np.savetxt(spm_motion_file, spm_motion)
325353
self._results['spm_motion_file'] = spm_motion_file
326354

355+
# Eddy-current field coefficients -> headed TSV confounds columns (eddy_ec_NN).
356+
ec = np.atleast_2d(eddy_motion[:, 6:])
357+
ec_file = fname_presuffix(
358+
self.inputs.eddy_motion, suffix='eddy_ec.tsv', use_ext=False, newpath=runtime.cwd
359+
)
360+
header = '\t'.join(f'eddy_ec_{i:02d}' for i in range(ec.shape[1]))
361+
np.savetxt(ec_file, ec, delimiter='\t', header=header, comments='')
362+
self._results['eddy_ec_file'] = ec_file
363+
327364
return runtime
328365

329366

qsiprep/interfaces/gradients.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def _run_interface(self, runtime):
243243
output_spm_fname = os.path.join(runtime.cwd, 'spm_movpar.txt')
244244
ref_file = self.inputs.ref_file
245245
for motion_file in self.inputs.transform_files:
246-
collected_motion.append(get_fsl_motion_params(motion_file, ref_file, runtime.cwd))
246+
collected_motion.append(get_ras_motion_params(motion_file, ref_file))
247247

248248
final_motion = np.row_stack(collected_motion)
249249
cols = [
@@ -722,6 +722,48 @@ def get_trans_from_offset(image_center, rotmat):
722722
return np.concatenate([scale, shear, rotation, translation])
723723

724724

725+
def get_ras_motion_params(itk_file, ref_file):
726+
"""Decompose a per-volume rigid transform into **RAS+** motion parameters.
727+
728+
Mirror of :func:`get_fsl_motion_params`, but the translation (mm) and
729+
rotation (rotation-vector radians) are expressed in the scanner-independent
730+
RAS+ world frame instead of FSL's radiological frame. eddy/SHORELine report
731+
FSL and DIFFPREP reports LPS; decomposing every backend's transform in RAS
732+
removes those per-backend axis flips, so the exported motion is directly
733+
comparable to RAS-defined ground truth regardless of grid orientation.
734+
"""
735+
import SimpleITK as sitk
736+
737+
tfm = sitk.ReadTransform(itk_file)
738+
try:
739+
aff = sitk.AffineTransform(tfm)
740+
except RuntimeError: # composite with a single affine
741+
aff = sitk.AffineTransform(sitk.CompositeTransform(tfm).GetNthTransform(0))
742+
mat = np.array(aff.GetMatrix()).reshape(3, 3)
743+
center = np.array(aff.GetCenter())
744+
offset = np.array(aff.GetTranslation())
745+
# ITK stores transforms in LPS: y = mat @ (x - center) + center + offset
746+
m_lps = np.eye(4)
747+
m_lps[:3, :3] = mat
748+
m_lps[:3, 3] = offset + center - mat @ center
749+
# LPS -> RAS flips x and y
750+
conv = np.diag([-1.0, -1.0, 1.0, 1.0])
751+
m_ras = conv @ m_lps @ conv
752+
753+
_, rotmat, scale, shear = decompose44(m_ras)
754+
rotation = R.from_matrix(rotmat).as_rotvec()
755+
756+
src_img = nb.load(ref_file)
757+
src_center = (np.array(src_img.shape[:3]) - 1) / 2
758+
center_mm = nb.affines.apply_affine(src_img.affine, src_center) - src_img.affine[:3, 3]
759+
translation = np.zeros(3)
760+
for i in range(3):
761+
translation[i] = (m_ras[i, 3] - center_mm[i]) + (
762+
m_ras[i, 0] * center_mm[0] + m_ras[i, 1] * center_mm[1] + m_ras[i, 2] * center_mm[2]
763+
)
764+
return np.concatenate([scale, shear, rotation, translation])
765+
766+
725767
def match_transforms(dwi_files, transforms, b0_indices):
726768
original_b0_indices = np.array(b0_indices)
727769
num_dwis = len(dwi_files)

qsiprep/interfaces/tortoise.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,7 @@ class _DIFFPREPMotionParamsInputSpec(BaseInterfaceInputSpec):
11311131

11321132
class _DIFFPREPMotionParamsOutputSpec(TraitedSpec):
11331133
spm_motion_file = File(exists=True)
1134+
diffprep_ec_file = File(exists=True)
11341135

11351136

11361137
class DIFFPREPMotionParams(SimpleInterface):
@@ -1139,11 +1140,12 @@ class DIFFPREPMotionParams(SimpleInterface):
11391140
11401141
The output columns are the leading 6 parameters of TORTOISE's
11411142
``OkanQuadraticTransform`` in SPM realignment-parameter order
1142-
(translation_x/y/z in mm of LPS physical coordinate, rotation_x/y/z as
1143-
Euler angles in radians). The remaining 18 Okan parameters encode the
1144-
eddy-current polynomial + rotation/eddy centre and are intentionally
1145-
dropped -- they are not rigid head motion. Units match the eddy and
1146-
SHORELine SPM motion files (translation mm, rotation radians).
1143+
(translation_x/y/z in mm, rotation_x/y/z as Euler angles in radians),
1144+
converted from TORTOISE's native LPS to **RAS+** so they match the
1145+
eddy/SHORELine motion files (which qsiprep now also exports in RAS via
1146+
:func:`~qsiprep.interfaces.gradients.get_ras_motion_params`). The remaining
1147+
18 Okan parameters encode the eddy-current polynomial + rotation/eddy
1148+
centre and are intentionally dropped -- they are not rigid head motion.
11471149
"""
11481150

11491151
input_spec = _DIFFPREPMotionParamsInputSpec
@@ -1152,7 +1154,10 @@ class DIFFPREPMotionParams(SimpleInterface):
11521154
def _run_interface(self, runtime):
11531155
rows = _read_okan_transformations(self.inputs.transformations_file)
11541156
params = np.asarray(rows, dtype=float)
1155-
spm_motion = params[:, :6]
1157+
# Okan params are LPS physical; LPS->RAS is a 180deg rotation about z,
1158+
# so negate the x and y components of both translation and rotation.
1159+
spm_motion = params[:, :6].copy()
1160+
spm_motion[:, [0, 1, 3, 4]] *= -1.0
11561161
spm_motion_file = fname_presuffix(
11571162
self.inputs.transformations_file,
11581163
suffix='_spm_rp.txt',
@@ -1161,6 +1166,16 @@ def _run_interface(self, runtime):
11611166
)
11621167
np.savetxt(spm_motion_file, spm_motion)
11631168
self._results['spm_motion_file'] = spm_motion_file
1169+
1170+
# Okan eddy-current + rotation/eddy-centre parameters (cols 6-23) -> headed TSV
1171+
# confounds columns (diffprep_ec_NN): ~3 linear x/y/z + quadratic + centres.
1172+
ec = params[:, 6:24]
1173+
ec_file = fname_presuffix(
1174+
self.inputs.transformations_file, suffix='_ec.tsv', use_ext=False, newpath=runtime.cwd
1175+
)
1176+
header = '\t'.join(f'diffprep_ec_{i:02d}' for i in range(ec.shape[1]))
1177+
np.savetxt(ec_file, ec, delimiter='\t', header=header, comments='')
1178+
self._results['diffprep_ec_file'] = ec_file
11641179
return runtime
11651180

11661181

qsiprep/tests/data/diffprep_outputs.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ sub-tester/anat/sub-tester_space-ACPC_desc-preproc_T1w.json
1818
sub-tester/anat/sub-tester_space-ACPC_desc-preproc_T1w.nii.gz
1919
sub-tester/anat/sub-tester_space-ACPC_dseg.nii.gz
2020
sub-tester/dwi
21+
sub-tester/dwi/sub-tester_acq-HASC55AP_desc-confounds_timeseries.json
2122
sub-tester/dwi/sub-tester_acq-HASC55AP_desc-confounds_timeseries.tsv
2223
sub-tester/dwi/sub-tester_acq-HASC55AP_space-ACPC_desc-brain_mask.nii.gz
2324
sub-tester/dwi/sub-tester_acq-HASC55AP_space-ACPC_desc-image_qc.tsv

qsiprep/tests/data/drbuddi_rpe_outputs.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ logs/CITATION.tex
77
sub-tinytensors
88
sub-tinytensors.html
99
sub-tinytensors/dwi
10+
sub-tinytensors/dwi/sub-tinytensors_desc-confounds_timeseries.json
1011
sub-tinytensors/dwi/sub-tinytensors_desc-confounds_timeseries.tsv
1112
sub-tinytensors/dwi/sub-tinytensors_space-ACPC_desc-brain_mask.nii.gz
1213
sub-tinytensors/dwi/sub-tinytensors_space-ACPC_desc-image_qc.tsv

qsiprep/tests/data/drbuddi_shoreline_epi_outputs.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ logs/CITATION.tex
77
sub-tinytensors
88
sub-tinytensors.html
99
sub-tinytensors/dwi
10+
sub-tinytensors/dwi/sub-tinytensors_dir-PA_desc-confounds_timeseries.json
1011
sub-tinytensors/dwi/sub-tinytensors_dir-PA_desc-confounds_timeseries.tsv
1112
sub-tinytensors/dwi/sub-tinytensors_dir-PA_space-ACPC_desc-brain_mask.nii.gz
1213
sub-tinytensors/dwi/sub-tinytensors_dir-PA_space-ACPC_desc-image_qc.tsv

qsiprep/tests/data/drbuddi_tensorline_epi_outputs.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ logs/CITATION.tex
77
sub-PNC
88
sub-PNC.html
99
sub-PNC/dwi
10+
sub-PNC/dwi/sub-PNC_acq-realistic_desc-confounds_timeseries.json
1011
sub-PNC/dwi/sub-PNC_acq-realistic_desc-confounds_timeseries.tsv
1112
sub-PNC/dwi/sub-PNC_acq-realistic_space-ACPC_desc-brain_mask.nii.gz
1213
sub-PNC/dwi/sub-PNC_acq-realistic_space-ACPC_desc-image_qc.tsv

qsiprep/tests/data/dscsdsi_outputs.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ sub-tester/anat/sub-tester_space-ACPC_desc-preproc_T1w.json
1818
sub-tester/anat/sub-tester_space-ACPC_desc-preproc_T1w.nii.gz
1919
sub-tester/anat/sub-tester_space-ACPC_dseg.nii.gz
2020
sub-tester/dwi
21+
sub-tester/dwi/sub-tester_acq-HASC55AP_desc-confounds_timeseries.json
2122
sub-tester/dwi/sub-tester_acq-HASC55AP_desc-confounds_timeseries.tsv
2223
sub-tester/dwi/sub-tester_acq-HASC55AP_space-ACPC_desc-brain_mask.nii.gz
2324
sub-tester/dwi/sub-tester_acq-HASC55AP_space-ACPC_desc-image_qc.tsv

qsiprep/tests/data/dsdti_nofmap_outputs.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ sub-PNC/anat/sub-PNC_space-ACPC_desc-preproc_T1w.json
1818
sub-PNC/anat/sub-PNC_space-ACPC_desc-preproc_T1w.nii.gz
1919
sub-PNC/anat/sub-PNC_space-ACPC_dseg.nii.gz
2020
sub-PNC/dwi
21+
sub-PNC/dwi/sub-PNC_acq-realistic_desc-confounds_timeseries.json
2122
sub-PNC/dwi/sub-PNC_acq-realistic_desc-confounds_timeseries.tsv
2223
sub-PNC/dwi/sub-PNC_acq-realistic_desc-pepolar_qc.tsv
2324
sub-PNC/dwi/sub-PNC_acq-realistic_space-ACPC_desc-brain_mask.nii.gz

0 commit comments

Comments
 (0)