Skip to content

Commit f53185e

Browse files
authored
rf: Reconstruct motion confounds from minimal derivatives (#3424)
After an exploration in nipreps/fmripost-aroma#94, we now know how to reconstruct the original motion parameters (to at least 0.001 mm/rad precision) from the ITK transforms we generate. To ensure consistency between single-shot and fit+apply runs, I propose to use this function in place of the original outputs.
2 parents e933636 + da5a025 commit f53185e

9 files changed

+259
-40
lines changed

fmriprep/interfaces/confounds.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import re
3535

3636
import nibabel as nb
37+
import nitransforms as nt
3738
import numpy as np
3839
import pandas as pd
3940
from nipype import logging
@@ -50,6 +51,8 @@
5051
from nipype.utils.filemanip import fname_presuffix
5152
from nireports.reportlets.modality.func import fMRIPlot
5253
from niworkflows.utils.timeseries import _cifti_timeseries, _nifti_timeseries
54+
from scipy import ndimage as ndi
55+
from scipy.spatial import transform as sst
5356

5457
LOGGER = logging.getLogger('nipype.interface')
5558

@@ -87,6 +90,93 @@ def _run_interface(self, runtime):
8790
return runtime
8891

8992

93+
class _FSLMotionParamsInputSpec(BaseInterfaceInputSpec):
94+
xfm_file = File(exists=True, desc='Head motion transform file')
95+
boldref_file = File(exists=True, desc='BOLD reference file')
96+
97+
98+
class _FSLMotionParamsOutputSpec(TraitedSpec):
99+
out_file = File(desc='Output motion parameters file')
100+
101+
102+
class FSLMotionParams(SimpleInterface):
103+
"""Reconstruct FSL motion parameters from affine transforms."""
104+
105+
input_spec = _FSLMotionParamsInputSpec
106+
output_spec = _FSLMotionParamsOutputSpec
107+
108+
def _run_interface(self, runtime):
109+
self._results['out_file'] = fname_presuffix(
110+
self.inputs.boldref_file, suffix='_motion.tsv', newpath=runtime.cwd
111+
)
112+
113+
boldref = nb.load(self.inputs.boldref_file)
114+
hmc = nt.linear.load(self.inputs.xfm_file)
115+
116+
# FSL's "center of gravity" is the center of mass scaled by zooms
117+
# No rotation is applied.
118+
center_of_gravity = np.matmul(
119+
np.diag(boldref.header.get_zooms()),
120+
ndi.center_of_mass(np.asanyarray(boldref.dataobj)),
121+
)
122+
123+
# Revert to vox2vox transforms
124+
fsl_hmc = nt.io.fsl.FSLLinearTransformArray.from_ras(
125+
hmc.matrix, reference=boldref, moving=boldref
126+
)
127+
fsl_matrix = np.stack([xfm['parameters'] for xfm in fsl_hmc.xforms])
128+
129+
# FSL uses left-handed rotation conventions, so transpose
130+
mats = fsl_matrix[:, :3, :3].transpose(0, 2, 1)
131+
132+
# Rotations are recovered directly
133+
rot_xyz = sst.Rotation.from_matrix(mats).as_euler('XYZ')
134+
# Translations are recovered by applying the rotation to the center of gravity
135+
trans_xyz = fsl_matrix[:, :3, 3] - mats @ center_of_gravity + center_of_gravity
136+
137+
params = pd.DataFrame(
138+
data=np.hstack((trans_xyz, rot_xyz)),
139+
columns=['trans_x', 'trans_y', 'trans_z', 'rot_x', 'rot_y', 'rot_z'],
140+
)
141+
142+
params.to_csv(self._results['out_file'], sep='\t', index=False)
143+
144+
return runtime
145+
146+
147+
class _FramewiseDisplacementInputSpec(BaseInterfaceInputSpec):
148+
in_file = File(exists=True, desc='Head motion transform file')
149+
radius = traits.Float(50, usedefault=True, desc='Radius of the head in mm')
150+
151+
152+
class _FramewiseDisplacementOutputSpec(TraitedSpec):
153+
out_file = File(desc='Output framewise displacement file')
154+
155+
156+
class FramewiseDisplacement(SimpleInterface):
157+
"""Calculate framewise displacement estimates."""
158+
159+
input_spec = _FramewiseDisplacementInputSpec
160+
output_spec = _FramewiseDisplacementOutputSpec
161+
162+
def _run_interface(self, runtime):
163+
self._results['out_file'] = fname_presuffix(
164+
self.inputs.in_file, suffix='_fd.tsv', newpath=runtime.cwd
165+
)
166+
167+
motion = pd.read_csv(self.inputs.in_file, delimiter='\t')
168+
169+
# Filter and ensure we have all parameters
170+
diff = motion[['trans_x', 'trans_y', 'trans_z', 'rot_x', 'rot_y', 'rot_z']].diff()
171+
diff[['rot_x', 'rot_y', 'rot_z']] *= self.inputs.radius
172+
173+
fd = pd.DataFrame(diff.abs().sum(axis=1, skipna=False), columns=['FramewiseDisplacement'])
174+
175+
fd.to_csv(self._results['out_file'], sep='\t', index=False)
176+
177+
return runtime
178+
179+
90180
class _FilterDroppedInputSpec(BaseInterfaceInputSpec):
91181
in_file = File(exists=True, desc='input CompCor metadata')
92182

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
trans_x trans_y trans_z rot_x rot_y rot_z framewise_displacement
2+
1.19425e-05 0.0443863 0.00472985 0.000356176 -0.000617445 0.0 n/a
3+
-2.57666e-05 0.0463662 0.0623273 -0.000208795 -0.00012937 0.0 0.1122673591
4+
-2.64055e-05 -0.00438628 0.067513 -2.59508e-05 -0.00012937 0.000173904 0.0737762289
5+
0.0161645 -0.0226134 0.0630764 -2.59508e-05 -0.000199844 0.000279081 0.0476371754999999
6+
0.0161497 -0.0263834 0.0464668 0.000161259 -0.00012937 0.000573335 0.04799129
7+
0.0161482 -0.0226144 0.0345415 6.52323e-05 -7.276439999999998e-05 0.000573335 0.023327415
8+
0.0121946 -0.00426109 0.0671039 -2.59508e-05 -0.00012937 0.000312581 0.075296445
9+
0.0121556 -0.0175135 0.04042 -2.59508e-05 -0.00012937 0.000166363 0.04728621
10+
0.0126526 -0.000813328 0.0778061 -2.59508e-05 -0.00012937 0.000166363 0.054583272
11+
0.012614 0.0250656 0.106248 -0.000320333 0.000149271 0.000166363 0.0830105879999999
12+
0.0126599 -0.0252459 0.0731423999999999 2.99512e-05 -0.000204037 0.000166363 0.11864261
13+
-0.00608005 0.0349207 0.110289 -0.000485119 -0.00012937 8.57718e-05 0.1495695699999999
14+
-0.00607796 -0.00933714 0.0796319999999999 -0.000126125 -0.00012937 0.000166363 0.09689619
15+
0.0124531 0.00996903 0.0986678 -0.000266813 -0.000207949 0.000166363 0.06783638
16+
0.010915 -0.00933714 0.0986667 -0.000126125 -0.000248281 0.000166363 0.02989637
17+
0.0119349 0.00531637 0.0808524 -0.000209587 -0.0 0.000226933 0.0531033599999999
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#Insight Transform File V1.0
2+
#Transform 0
3+
Transform: AffineTransform_float_3_3
4+
Parameters: 1 2.19652e-07 -0.000617 2.19652e-07 1 0.000356 0.000617 -0.000356 0.999999 -0.0198273 0.0558417 -0.00925011
5+
FixedParameters: 0 0 0
6+
7+
#Transform 1
8+
Transform: AffineTransform_float_3_3
9+
Parameters: 1 -2.6961e-08 -0.000129 -2.6961e-08 1 -0.000209 0.000129 0.000209 1 -0.00408853 0.0396441 -0.0579032
10+
FixedParameters: 0 0 0
11+
12+
#Transform 2
13+
Transform: AffineTransform_float_3_3
14+
Parameters: 1 0.000173997 -0.000129005 -0.000174003 1 -2.59776e-05 0.000128995 2.60224e-05 1 -0.000785439 -0.00589913 -0.0665673
15+
FixedParameters: 0 0 0
16+
17+
#Transform 3
18+
Transform: AffineTransform_float_3_3
19+
Parameters: 1 0.000278995 -0.000200007 -0.000279005 1 -2.59442e-05 0.000199993 2.60558e-05 1 -0.0172875 -0.0245076 -0.0618151
20+
FixedParameters: 0 0 0
21+
22+
#Transform 4
23+
Transform: AffineTransform_float_3_3
24+
Parameters: 1 0.000573021 -0.000128908 -0.000572979 1 0.000161074 0.000129092 -0.000160926 1 -0.00936206 -0.0233705 -0.0490914
25+
FixedParameters: 0 0 0
26+
27+
#Transform 5
28+
Transform: AffineTransform_float_3_3
29+
Parameters: 1 0.000573005 -7.29627e-05 -0.000572995 1 6.50418e-05 7.30372e-05 -6.49581e-05 1 -0.0076044 -0.0226739 -0.0354961
30+
FixedParameters: 0 0 0
31+
32+
#Transform 6
33+
Transform: AffineTransform_float_3_3
34+
Parameters: 1 0.000312997 -0.000129008 -0.000313003 1 -2.59596e-05 0.000128992 2.60404e-05 1 -0.0103974 -0.00632903 -0.0661615
35+
FixedParameters: 0 0 0
36+
37+
#Transform 7
38+
Transform: AffineTransform_float_3_3
39+
Parameters: 1 0.000165997 -0.000129004 -0.000166003 1 -2.59786e-05 0.000128996 2.60214e-05 1 -0.0130841 -0.0189498 -0.0394762
40+
FixedParameters: 0 0 0
41+
42+
#Transform 8
43+
Transform: AffineTransform_float_3_3
44+
Parameters: 1 0.000165997 -0.000129004 -0.000166003 1 -2.59786e-05 0.000128996 2.60214e-05 1 -0.0135735 -0.00224875 -0.0768618
45+
FixedParameters: 0 0 0
46+
47+
#Transform 9
48+
Transform: AffineTransform_float_3_3
49+
Parameters: 1 0.000166048 0.000148947 -0.000165952 1 -0.000320025 -0.000149053 0.000319975 1 -0.00466364 0.014244 -0.100674
50+
FixedParameters: 0 0 0
51+
52+
#Transform 10
53+
Transform: AffineTransform_float_3_3
54+
Parameters: 1 0.000166006 -0.000203995 -0.000165994 1 3.00339e-05 0.000204005 -2.99661e-05 1 -0.0160123 -0.0248835 -0.0729365
55+
FixedParameters: 0 0 0
56+
57+
#Transform 11
58+
Transform: AffineTransform_float_3_3
59+
Parameters: 1 8.59374e-05 -0.000129042 -8.60625e-05 1 -0.000484989 0.000128958 0.000485011 1 0.00358861 0.0190405 -0.100594
60+
FixedParameters: 0 0 0
61+
62+
#Transform 12
63+
Transform: AffineTransform_float_3_3
64+
Parameters: 1 0.000165984 -0.000129021 -0.000166016 1 -0.000125979 0.000128979 0.000126021 1 0.00515558 -0.0139722 -0.0767701
65+
FixedParameters: 0 0 0
66+
67+
#Transform 13
68+
Transform: AffineTransform_float_3_3
69+
Parameters: 1 0.000165944 -0.000208044 -0.000166056 1 -0.000266965 0.000207956 0.000267034 1 -0.0159206 0.000794425 -0.0928171
70+
FixedParameters: 0 0 0
71+
72+
#Transform 14
73+
Transform: AffineTransform_float_3_3
74+
Parameters: 1 0.000165969 -0.000248021 -0.000166031 1 -0.000125959 0.000247979 0.000126041 1 -0.0156607 -0.0139734 -0.0953506
75+
FixedParameters: 0 0 0
76+
77+
#Transform 15
78+
Transform: AffineTransform_float_3_3
79+
Parameters: 1 0.000227 -4.767e-08 -0.000227 1 -0.00021 -4.767e-08 0.00021 1 -0.00762153 -0.00231912 -0.0768985
80+
FixedParameters: 0 0 0

fmriprep/interfaces/tests/test_confounds.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from pathlib import Path
22

3+
import numpy as np
4+
import pandas as pd
35
from nipype.pipeline import engine as pe
46

57
from fmriprep.interfaces import confounds
@@ -29,3 +31,53 @@ def test_FilterDropped(tmp_path, data_dir):
2931
target_meta = Path.read_text(data_dir / 'component_metadata_filtered.tsv')
3032
filtered_meta = Path(res.outputs.out_file).read_text()
3133
assert filtered_meta == target_meta
34+
35+
36+
def test_FSLMotionParams(tmp_path, data_dir):
37+
base = 'sub-01_task-mixedgamblestask_run-01'
38+
xfms = data_dir / f'{base}_from-orig_to-boldref_mode-image_desc-hmc_xfm.txt'
39+
boldref = data_dir / f'{base}_desc-hmc_boldref.nii.gz'
40+
orig_timeseries = data_dir / f'{base}_desc-motion_timeseries.tsv'
41+
42+
motion = pe.Node(
43+
confounds.FSLMotionParams(xfm_file=str(xfms), boldref_file=str(boldref)),
44+
name='fsl_motion',
45+
base_dir=str(tmp_path),
46+
)
47+
res = motion.run()
48+
49+
derived_params = pd.read_csv(res.outputs.out_file, sep='\t')
50+
# orig_timeseries includes framewise_displacement
51+
orig_params = pd.read_csv(orig_timeseries, sep='\t')[derived_params.columns]
52+
53+
# Motion parameters are in mm and rad
54+
# These are empirically determined bounds, but they seem reasonable
55+
# for the units
56+
limits = pd.DataFrame(
57+
{
58+
'trans_x': [1e-4],
59+
'trans_y': [1e-4],
60+
'trans_z': [1e-4],
61+
'rot_x': [1e-6],
62+
'rot_y': [1e-6],
63+
'rot_z': [1e-6],
64+
}
65+
)
66+
max_diff = (orig_params - derived_params).abs().max()
67+
assert np.all(max_diff < limits)
68+
69+
70+
def test_FramewiseDisplacement(tmp_path, data_dir):
71+
timeseries = data_dir / 'sub-01_task-mixedgamblestask_run-01_desc-motion_timeseries.tsv'
72+
73+
framewise_displacement = pe.Node(
74+
confounds.FramewiseDisplacement(in_file=str(timeseries)),
75+
name='framewise_displacement',
76+
base_dir=str(tmp_path),
77+
)
78+
res = framewise_displacement.run()
79+
80+
orig = pd.read_csv(timeseries, sep='\t')['framewise_displacement']
81+
derived = pd.read_csv(res.outputs.out_file, sep='\t')['FramewiseDisplacement']
82+
83+
assert np.allclose(orig.values, derived.values, equal_nan=True)

fmriprep/workflows/bold/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,8 @@ def init_bold_wf(
673673
]),
674674
(bold_fit_wf, bold_confounds_wf, [
675675
('outputnode.bold_mask', 'inputnode.bold_mask'),
676-
('outputnode.movpar_file', 'inputnode.movpar_file'),
676+
('outputnode.hmc_boldref', 'inputnode.hmc_boldref'),
677+
('outputnode.motion_xfm', 'inputnode.motion_xfm'),
677678
('outputnode.rmsd_file', 'inputnode.rmsd_file'),
678679
('outputnode.boldref2anat_xfm', 'inputnode.boldref2anat_xfm'),
679680
('outputnode.dummy_scans', 'inputnode.skip_vols'),

fmriprep/workflows/bold/confounds.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
from ...interfaces.confounds import (
3939
FilterDropped,
4040
FMRISummary,
41+
FramewiseDisplacement,
42+
FSLMotionParams,
4143
GatherConfounds,
4244
RenameACompCor,
4345
)
@@ -120,8 +122,8 @@ def init_bold_confs_wf(
120122
when available.
121123
bold_mask
122124
BOLD series mask
123-
movpar_file
124-
SPM-formatted motion parameters file
125+
motion_xfm
126+
ITK-formatted head motion transforms
125127
rmsd_file
126128
Root mean squared deviation as measured by ``fsl_motion_outliers`` [Jenkinson2002]_.
127129
skip_vols
@@ -221,7 +223,8 @@ def init_bold_confs_wf(
221223
fields=[
222224
'bold',
223225
'bold_mask',
224-
'movpar_file',
226+
'hmc_boldref',
227+
'motion_xfm',
225228
'rmsd_file',
226229
'skip_vols',
227230
't1w_mask',
@@ -262,8 +265,11 @@ def init_bold_confs_wf(
262265
mem_gb=mem_gb,
263266
)
264267

268+
# Motion parameters
269+
motion_params = pe.Node(FSLMotionParams(), name='motion_params')
270+
265271
# Frame displacement
266-
fdisp = pe.Node(nac.FramewiseDisplacement(parameter_source='SPM'), name='fdisp', mem_gb=mem_gb)
272+
fdisp = pe.Node(FramewiseDisplacement(), name='fdisp', mem_gb=mem_gb)
267273

268274
# Generate aCompCor probseg maps
269275
acc_masks = pe.Node(aCompCorMasks(is_aseg=freesurfer), name='acc_masks')
@@ -367,12 +373,6 @@ def init_bold_confs_wf(
367373
mem_gb=0.01,
368374
run_without_submitting=True,
369375
)
370-
add_motion_headers = pe.Node(
371-
AddTSVHeader(columns=['trans_x', 'trans_y', 'trans_z', 'rot_x', 'rot_y', 'rot_z']),
372-
name='add_motion_headers',
373-
mem_gb=0.01,
374-
run_without_submitting=True,
375-
)
376376
add_rmsd_header = pe.Node(
377377
AddTSVHeader(columns=['rmsd']),
378378
name='add_rmsd_header',
@@ -518,12 +518,13 @@ def _select_cols(table):
518518
if not col.startswith(('a_comp_cor_', 't_comp_cor_', 'std_dvars'))
519519
]
520520

521-
# fmt:off
522521
workflow.connect([
523522
# connect inputnode to each non-anatomical confound node
524523
(inputnode, dvars, [('bold', 'in_file'),
525524
('bold_mask', 'in_mask')]),
526-
(inputnode, fdisp, [('movpar_file', 'in_file')]),
525+
(inputnode, motion_params, [('motion_xfm', 'xfm_file'),
526+
('hmc_boldref', 'boldref_file')]),
527+
(motion_params, fdisp, [('out_file', 'in_file')]),
527528
# Brain mask
528529
(inputnode, t1w_mask_tfm, [('t1w_mask', 'input_image'),
529530
('bold_mask', 'reference_image'),
@@ -566,7 +567,6 @@ def _select_cols(table):
566567
(merge_rois, signals, [('out', 'label_files')]),
567568

568569
# Collate computed confounds together
569-
(inputnode, add_motion_headers, [('movpar_file', 'in_file')]),
570570
(inputnode, add_rmsd_header, [('rmsd_file', 'in_file')]),
571571
(dvars, add_dvars_header, [('out_nstd', 'in_file')]),
572572
(dvars, add_std_dvars_header, [('out_std', 'in_file')]),
@@ -576,7 +576,7 @@ def _select_cols(table):
576576
('pre_filter_file', 'cos_basis')]),
577577
(rename_acompcor, concat, [('components_file', 'acompcor')]),
578578
(crowncompcor, concat, [('components_file', 'crowncompcor')]),
579-
(add_motion_headers, concat, [('out_file', 'motion')]),
579+
(motion_params, concat, [('out_file', 'motion')]),
580580
(add_rmsd_header, concat, [('out_file', 'rmsd')]),
581581
(add_dvars_header, concat, [('out_file', 'dvars')]),
582582
(add_std_dvars_header, concat, [('out_file', 'std_dvars')]),
@@ -617,8 +617,7 @@ def _select_cols(table):
617617
(concat, conf_corr_plot, [('confounds_file', 'confounds_file'),
618618
(('confounds_file', _select_cols), 'columns')]),
619619
(conf_corr_plot, ds_report_conf_corr, [('out_file', 'in_file')]),
620-
])
621-
# fmt: on
620+
]) # fmt: skip
622621

623622
return workflow
624623

0 commit comments

Comments
 (0)