Skip to content

Commit 2106034

Browse files
authored
fix: Detect and apply precomputed fieldmaps (#3439)
This is the last step for using pre-computed derivatives without recomputing. I'm able to target native, T1w, MNI and fsLR spaces using this. This is mainly about fieldmaps in `fmriprep.workflows.base`, but other things fixed in passing: 1) Reporting for bold2anat registration now says "precomputed". This is necessary because fallbacks are not attempted if no registration is attempted. 2) Consolidates populating precomputed derivative buffers. `--use-syn-sdc` still needs to be declared if SyN-SDC fieldmaps are to be used. There's still a minor bug, or missed opportunity, in `workflows.bold.fit` to make sure that fieldmaps are reported even if coregistration is already done. It doesn't feel like it's worth holding this PR up to make that happen.
2 parents f81a304 + 00fee36 commit 2106034

File tree

7 files changed

+206
-54
lines changed

7 files changed

+206
-54
lines changed

fmriprep/data/fmap_spec.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"queries": {
3+
"fieldmaps": {
4+
"fieldmap": {
5+
"datatype": "fmap",
6+
"desc": "preproc",
7+
"suffix": "fieldmap",
8+
"extension": [
9+
".nii.gz",
10+
".nii"
11+
]
12+
},
13+
"coeffs": {
14+
"datatype": "fmap",
15+
"desc": ["coeff", "coeff0", "coeff1"],
16+
"suffix": "fieldmap",
17+
"extension": [
18+
".nii.gz",
19+
".nii"
20+
]
21+
},
22+
"magnitude": {
23+
"datatype": "fmap",
24+
"desc": "magnitude",
25+
"suffix": "fieldmap",
26+
"extension": [
27+
".nii.gz",
28+
".nii"
29+
]
30+
}
31+
}
32+
}
33+
}

fmriprep/data/io_spec.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"boldref2anat": {
3535
"datatype": "func",
3636
"from": "boldref",
37-
"to": "anat",
37+
"to": ["anat", "T1w", "T2w"],
3838
"mode": "image",
3939
"suffix": "xfm",
4040
"extension": ".txt"

fmriprep/interfaces/reports.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,11 @@ class FunctionalSummaryInputSpec(TraitedSpec):
204204
desc='Phase-encoding direction detected',
205205
)
206206
registration = traits.Enum(
207-
'FSL', 'FreeSurfer', mandatory=True, desc='Functional/anatomical registration method'
207+
'FSL',
208+
'FreeSurfer',
209+
'Precomputed',
210+
mandatory=True,
211+
desc='Functional/anatomical registration method',
208212
)
209213
fallback = traits.Bool(desc='Boundary-based registration rejected')
210214
registration_dof = traits.Enum(
@@ -239,18 +243,21 @@ def _generate_segment(self):
239243
else:
240244
stc = 'n/a'
241245
# TODO: Add a note about registration_init below?
242-
reg = {
243-
'FSL': [
244-
'FSL <code>flirt</code> with boundary-based registration'
245-
f' (BBR) metric - {dof} dof',
246-
'FSL <code>flirt</code> rigid registration - 6 dof',
247-
],
248-
'FreeSurfer': [
249-
'FreeSurfer <code>bbregister</code> '
250-
f'(boundary-based registration, BBR) - {dof} dof',
251-
f'FreeSurfer <code>mri_coreg</code> - {dof} dof',
252-
],
253-
}[self.inputs.registration][self.inputs.fallback]
246+
if self.inputs.registration == 'Precomputed':
247+
reg = 'Precomputed affine transformation'
248+
else:
249+
reg = {
250+
'FSL': [
251+
'FSL <code>flirt</code> with boundary-based registration'
252+
f' (BBR) metric - {dof} dof',
253+
'FSL <code>flirt</code> rigid registration - 6 dof',
254+
],
255+
'FreeSurfer': [
256+
'FreeSurfer <code>bbregister</code> '
257+
f'(boundary-based registration, BBR) - {dof} dof',
258+
f'FreeSurfer <code>mri_coreg</code> - {dof} dof',
259+
],
260+
}[self.inputs.registration][self.inputs.fallback]
254261

255262
pedir = get_world_pedir(self.inputs.orientation, self.inputs.pe_direction)
256263

fmriprep/interfaces/resampling.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,15 @@ def reconstruct_fieldmap(
671671
target.__class__(target.dataobj, projected_affine, target.header),
672672
)
673673
else:
674+
# Hack. Sometimes the reference array is rotated relative to the fieldmap
675+
# and coefficient grids. As far as I know, coefficients are always RAS,
676+
# but good to check before doing this.
677+
if (
678+
nb.aff2axcodes(coefficients[-1].affine)
679+
== ('R', 'A', 'S')
680+
!= nb.aff2axcodes(fmap_reference.affine)
681+
):
682+
fmap_reference = nb.as_closest_canonical(fmap_reference)
674683
if not aligned(fmap_reference.affine, coefficients[-1].affine):
675684
raise ValueError('Reference passed is not aligned with spline grids')
676685
reference, _ = ensure_positive_cosines(fmap_reference)

fmriprep/utils/bids.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import os
2929
import sys
3030
from collections import defaultdict
31+
from functools import cache
3132
from pathlib import Path
3233

3334
from bids.layout import BIDSLayout
@@ -38,6 +39,15 @@
3839
from ..data import load as load_data
3940

4041

42+
@cache
43+
def _get_layout(derivatives_dir: Path) -> BIDSLayout:
44+
import niworkflows.data
45+
46+
return BIDSLayout(
47+
derivatives_dir, config=[niworkflows.data.load('nipreps.json')], validate=False
48+
)
49+
50+
4151
def collect_derivatives(
4252
derivatives_dir: Path,
4353
entities: dict,
@@ -57,8 +67,7 @@ def collect_derivatives(
5767
patterns = _patterns
5868

5969
derivs_cache = defaultdict(list, {})
60-
layout = BIDSLayout(derivatives_dir, config=['bids', 'derivatives'], validate=False)
61-
derivatives_dir = Path(derivatives_dir)
70+
layout = _get_layout(derivatives_dir)
6271

6372
# search for both boldrefs
6473
for k, q in spec['baseline'].items():
@@ -86,6 +95,31 @@ def collect_derivatives(
8695
return derivs_cache
8796

8897

98+
def collect_fieldmaps(
99+
derivatives_dir: Path,
100+
entities: dict,
101+
spec: dict | None = None,
102+
):
103+
"""Gather existing derivatives and compose a cache."""
104+
if spec is None:
105+
spec = json.loads(load_data.readable('fmap_spec.json').read_text())['queries']
106+
107+
fmap_cache = defaultdict(dict, {})
108+
layout = _get_layout(derivatives_dir)
109+
110+
fmapids = layout.get_fmapids(**entities)
111+
112+
for fmapid in fmapids:
113+
for k, q in spec['fieldmaps'].items():
114+
query = {**entities, **q}
115+
item = layout.get(return_type='filename', fmapid=fmapid, **query)
116+
if not item:
117+
continue
118+
fmap_cache[fmapid][k] = item[0] if len(item) == 1 else item
119+
120+
return fmap_cache
121+
122+
89123
def write_bidsignore(deriv_dir):
90124
bids_ignore = (
91125
'*.html',

fmriprep/workflows/base.py

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"""
3131

3232
import os
33+
import re
3334
import sys
3435
import warnings
3536
from copy import deepcopy
@@ -337,6 +338,9 @@ def init_single_subject_wf(subject_id: str):
337338

338339
# allow to run with anat-fast-track on fMRI-only dataset
339340
if 't1w_preproc' in anatomical_cache and not subject_data['t1w']:
341+
config.loggers.workflow.debug(
342+
'No T1w image found; using precomputed T1w image: %s', anatomical_cache['t1w_preproc']
343+
)
340344
workflow.connect([
341345
(bidssrc, bids_info, [(('bold', fix_multi_T1w_source_name), 'in_file')]),
342346
(anat_fit_wf, summary, [('outputnode.t1w_preproc', 't1w')]),
@@ -533,7 +537,21 @@ def init_single_subject_wf(subject_id: str):
533537
if config.workflow.anat_only:
534538
return clean_datasinks(workflow)
535539

536-
fmap_estimators, estimator_map = map_fieldmap_estimation(
540+
fmap_cache = {}
541+
if config.execution.derivatives:
542+
from fmriprep.utils.bids import collect_fieldmaps
543+
544+
for deriv_dir in config.execution.derivatives.values():
545+
fmaps = collect_fieldmaps(
546+
derivatives_dir=deriv_dir,
547+
entities={'subject': subject_id},
548+
)
549+
config.loggers.workflow.debug(
550+
'Detected precomputed fieldmaps in %s for fieldmap IDs: %s', deriv_dir, list(fmaps)
551+
)
552+
fmap_cache.update(fmaps)
553+
554+
all_estimators, estimator_map = map_fieldmap_estimation(
537555
layout=config.execution.layout,
538556
subject_id=subject_id,
539557
bold_data=bold_runs,
@@ -543,6 +561,48 @@ def init_single_subject_wf(subject_id: str):
543561
filters=config.execution.get().get('bids_filters', {}).get('fmap'),
544562
)
545563

564+
fmap_buffers = {
565+
field: pe.Node(niu.Merge(2), name=f'{field}_merge', run_without_submitting=True)
566+
for field in ['fmap', 'fmap_ref', 'fmap_coeff', 'fmap_mask', 'fmap_id', 'sdc_method']
567+
}
568+
569+
fmap_estimators = []
570+
if all_estimators:
571+
# Find precomputed fieldmaps that apply to this workflow
572+
pared_cache = {}
573+
for est in all_estimators:
574+
if found := fmap_cache.get(re.sub(r'[^a-zA-Z0-9]', '', est.bids_id)):
575+
pared_cache[est.bids_id] = found
576+
else:
577+
fmap_estimators.append(est)
578+
579+
if pared_cache:
580+
config.loggers.workflow.info(
581+
'Precomputed B0 field inhomogeneity maps found for the following '
582+
f'{len(pared_cache)} estimator(s): {list(pared_cache)}.'
583+
)
584+
585+
fieldmaps = [fmap['fieldmap'] for fmap in pared_cache.values()]
586+
refs = [fmap['magnitude'] for fmap in pared_cache.values()]
587+
coeffs = [fmap['coeffs'] for fmap in pared_cache.values()]
588+
config.loggers.workflow.debug('Reusing fieldmaps: %s', fieldmaps)
589+
config.loggers.workflow.debug('Reusing references: %s', refs)
590+
config.loggers.workflow.debug('Reusing coefficients: %s', coeffs)
591+
592+
fmap_buffers['fmap'].inputs.in1 = fieldmaps
593+
fmap_buffers['fmap_ref'].inputs.in1 = refs
594+
fmap_buffers['fmap_coeff'].inputs.in1 = coeffs
595+
596+
# Note that masks are not emitted. The BOLD-fmap transforms cannot be
597+
# computed with precomputed fieldmaps until we either start emitting masks
598+
# or start skull-stripping references on the fly.
599+
fmap_buffers['fmap_mask'].inputs.in1 = [
600+
pared_cache[fmapid].get('mask', 'MISSING') for fmapid in pared_cache
601+
]
602+
fmap_buffers['fmap_id'].inputs.in1 = list(pared_cache)
603+
fmap_buffers['sdc_method'].inputs.in1 = ['precomputed'] * len(pared_cache)
604+
605+
# Estimators without precomputed fieldmaps
546606
if fmap_estimators:
547607
config.loggers.workflow.info(
548608
'B0 field inhomogeneity map will be estimated with the following '
@@ -573,24 +633,31 @@ def init_single_subject_wf(subject_id: str):
573633
if node.split('.')[-1].startswith('ds_'):
574634
fmap_wf.get_node(node).interface.out_path_base = ''
575635

636+
workflow.connect([
637+
(fmap_wf, fmap_buffers[field], [
638+
# We get "sdc_method" as "method" from estimator workflows
639+
# All else stays the same, and no other sdc_ prefixes are used
640+
(f'outputnode.{field.removeprefix("sdc_")}', 'in2'),
641+
])
642+
for field in fmap_buffers
643+
]) # fmt:skip
644+
576645
fmap_select_std = pe.Node(
577646
KeySelect(fields=['std2anat_xfm'], key='MNI152NLin2009cAsym'),
578647
name='fmap_select_std',
579648
run_without_submitting=True,
580649
)
581650
if any(estimator.method == fm.EstimatorType.ANAT for estimator in fmap_estimators):
582-
# fmt:off
583651
workflow.connect([
584652
(anat_fit_wf, fmap_select_std, [
585653
('outputnode.std2anat_xfm', 'std2anat_xfm'),
586654
('outputnode.template', 'keys')]),
587-
])
588-
# fmt:on
655+
]) # fmt:skip
589656

590657
for estimator in fmap_estimators:
591658
config.loggers.workflow.info(
592659
f"""\
593-
Setting-up fieldmap "{estimator.bids_id}" ({estimator.method}) with \
660+
Setting up fieldmap "{estimator.bids_id}" ({estimator.method}) with \
594661
<{', '.join(s.path.name for s in estimator.sources)}>"""
595662
)
596663

@@ -633,7 +700,6 @@ def init_single_subject_wf(subject_id: str):
633700
syn_preprocessing_wf.inputs.inputnode.in_epis = sources
634701
syn_preprocessing_wf.inputs.inputnode.in_meta = source_meta
635702

636-
# fmt:off
637703
workflow.connect([
638704
(anat_fit_wf, syn_preprocessing_wf, [
639705
('outputnode.t1w_preproc', 'inputnode.in_anat'),
@@ -649,8 +715,7 @@ def init_single_subject_wf(subject_id: str):
649715
('outputnode.anat_mask', f'in_{estimator.bids_id}.anat_mask'),
650716
('outputnode.sd_prior', f'in_{estimator.bids_id}.sd_prior'),
651717
]),
652-
])
653-
# fmt:on
718+
]) # fmt:skip
654719

655720
# Append the functional section to the existing anatomical excerpt
656721
# That way we do not need to stream down the number of bold datasets
@@ -718,17 +783,11 @@ def init_single_subject_wf(subject_id: str):
718783
),
719784
]),
720785
]) # fmt:skip
721-
if fieldmap_id:
722-
workflow.connect([
723-
(fmap_wf, bold_wf, [
724-
('outputnode.fmap', 'inputnode.fmap'),
725-
('outputnode.fmap_ref', 'inputnode.fmap_ref'),
726-
('outputnode.fmap_coeff', 'inputnode.fmap_coeff'),
727-
('outputnode.fmap_mask', 'inputnode.fmap_mask'),
728-
('outputnode.fmap_id', 'inputnode.fmap_id'),
729-
('outputnode.method', 'inputnode.sdc_method'),
730-
]),
731-
]) # fmt:skip
786+
787+
workflow.connect([
788+
(buffer, bold_wf, [('out', f'inputnode.{field}')])
789+
for field, buffer in fmap_buffers.items()
790+
]) # fmt:skip
732791

733792
if config.workflow.level == 'full':
734793
if template_iterator_wf is not None:

0 commit comments

Comments
 (0)