Skip to content

Commit ee71d6e

Browse files
authored
FIX: Vastly improve multi-echo handling (#220)
* FIX: Rework BOLD grouping to support multiecho - Added a new class, `BOLDGrouping`, to better handle binning of BOLD data. This allows the passing of multiple BOLD file to the functional preprocessing workflow, which was treating every echo of a multiecho dataset as a unique run. * FIX: Use more verbose typing for older pythons * FIX: Correct parameter name * FIX: Ensure `sessions` are lists, grouping values are selected * FIX: Update T2star interface, connections * ENH: Add tedana to dependencies * STY: formatting * TST: Add empty echo files for unittest
1 parent e8e9054 commit ee71d6e

File tree

8 files changed

+124
-75
lines changed

8 files changed

+124
-75
lines changed

nibabies/data/sub-01_run-01_echo-1_bold.nii.gz

Whitespace-only changes.

nibabies/data/sub-01_run-01_echo-2_bold.nii.gz

Whitespace-only changes.

nibabies/data/sub-01_run-01_echo-3_bold.nii.gz

Whitespace-only changes.

nibabies/interfaces/multiecho.py

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
1-
#!/usr/bin/env python
2-
# -*- coding: utf-8 -*-
31
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
42
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
#
4+
# Copyright 2021 The NiPreps Developers <[email protected]>
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# We support and encourage derived works from this project, please read
19+
# about our expectations at
20+
#
21+
# https://www.nipreps.org/community/licensing/
22+
#
523
"""
624
Multi-echo EPI
725
~~~~~~~~~~~~~~
@@ -26,42 +44,51 @@
2644
traits,
2745
)
2846

29-
LOGGER = logging.getLogger("nipype.interface")
47+
LOGGER = logging.getLogger('nipype.interface')
3048

3149

3250
class T2SMapInputSpec(CommandLineInputSpec):
3351
in_files = traits.List(
3452
File(exists=True),
35-
argstr="-d %s",
53+
argstr='-d %s',
3654
position=1,
3755
mandatory=True,
3856
minlen=3,
39-
desc="multi-echo BOLD EPIs",
57+
desc='multi-echo BOLD EPIs',
4058
)
4159
echo_times = traits.List(
42-
traits.Float, argstr="-e %s", position=2, mandatory=True, minlen=3, desc="echo times"
60+
traits.Float,
61+
argstr='-e %s',
62+
position=2,
63+
mandatory=True,
64+
minlen=3,
65+
desc='echo times',
4366
)
44-
fittype = traits.Enum(
45-
"curvefit",
46-
"loglin",
47-
argstr="--fittype %s",
67+
mask_file = File(
68+
argstr='--mask %s',
4869
position=3,
70+
desc='mask file',
71+
exists=True,
72+
)
73+
fittype = traits.Enum(
74+
'curvefit',
75+
'loglin',
76+
argstr='--fittype %s',
77+
position=4,
4978
usedefault=True,
50-
desc=(
51-
"Desired fitting method: "
52-
'"loglin" means that a linear model is fit '
53-
"to the log of the data. "
54-
'"curvefit" means that a more computationally '
55-
"demanding monoexponential model is fit "
56-
"to the raw data."
57-
),
79+
desc='Desired fitting method: '
80+
'"loglin" means that a linear model is fit '
81+
'to the log of the data. '
82+
'"curvefit" means that a more computationally '
83+
'demanding monoexponential model is fit '
84+
'to the raw data.',
5885
)
5986

6087

6188
class T2SMapOutputSpec(TraitedSpec):
62-
t2star_map = File(exists=True, desc="limited T2* map")
63-
s0_map = File(exists=True, desc="limited S0 map")
64-
optimal_comb = File(exists=True, desc="optimally combined ME-EPI time series")
89+
t2star_map = File(exists=True, desc='limited T2* map')
90+
s0_map = File(exists=True, desc='limited S0 map')
91+
optimal_comb = File(exists=True, desc='optimally combined ME-EPI time series')
6592

6693

6794
class T2SMap(CommandLine):
@@ -73,30 +100,29 @@ class T2SMap(CommandLine):
73100
=======
74101
>>> from nibabies.interfaces import multiecho
75102
>>> t2smap = multiecho.T2SMap()
76-
>>> t2smap.inputs.in_files = [
77-
... data_dir / 'sub-01_run-01_echo-1_bold.nii.gz',
78-
... data_dir / 'sub-01_run-01_echo-2_bold.nii.gz',
79-
... data_dir / 'sub-01_run-01_echo-3_bold.nii.gz']
103+
>>> t2smap.inputs.in_files = ['sub-01_run-01_echo-1_bold.nii.gz', \
104+
'sub-01_run-01_echo-2_bold.nii.gz', \
105+
'sub-01_run-01_echo-3_bold.nii.gz']
80106
>>> t2smap.inputs.echo_times = [0.013, 0.027, 0.043]
81-
>>> t2smap.cmdline
82-
't2smap -d .../sub-01_run-01_echo-1_bold.nii.gz \
83-
.../sub-01_run-01_echo-2_bold.nii.gz \
84-
.../sub-01_run-01_echo-3_bold.nii.gz -e 13.0 27.0 43.0 --fittype curvefit'
107+
>>> t2smap.cmdline # doctest: +ELLIPSIS
108+
't2smap -d sub-01_run-01_echo-1_bold.nii.gz sub-01_run-01_echo-2_bold.nii.gz \
109+
sub-01_run-01_echo-3_bold.nii.gz -e 13.0 27.0 43.0 --fittype curvefit'
110+
85111
"""
86112

87-
_cmd = "t2smap"
113+
_cmd = 't2smap'
88114
input_spec = T2SMapInputSpec
89115
output_spec = T2SMapOutputSpec
90116

91117
def _format_arg(self, name, trait_spec, value):
92-
if name == "echo_times":
118+
if name == 'echo_times':
93119
value = [te * 1000 for te in value]
94120
return super(T2SMap, self)._format_arg(name, trait_spec, value)
95121

96122
def _list_outputs(self):
97123
outputs = self._outputs().get()
98124
out_dir = os.getcwd()
99-
outputs["t2star_map"] = os.path.join(out_dir, "T2starmap.nii.gz")
100-
outputs["s0_map"] = os.path.join(out_dir, "S0map.nii.gz")
101-
outputs["optimal_comb"] = os.path.join(out_dir, "desc-optcom_bold.nii.gz")
125+
outputs['t2star_map'] = os.path.join(out_dir, 'T2starmap.nii.gz')
126+
outputs['s0_map'] = os.path.join(out_dir, 'S0map.nii.gz')
127+
outputs['optimal_comb'] = os.path.join(out_dir, 'desc-optcom_bold.nii.gz')
102128
return outputs

nibabies/utils/bids.py

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,27 @@
44
import json
55
import os
66
import sys
7+
from dataclasses import dataclass, field
78
from pathlib import Path
9+
from typing import IO, List, Union
10+
11+
12+
@dataclass
13+
class BOLDGrouping:
14+
"""This class is used to facilitate the grouping of BOLD series."""
15+
16+
session: Union[str, None]
17+
pe_dir: str
18+
readout: float
19+
multiecho_id: str = None
20+
files: List[IO] = field(default_factory=list)
21+
22+
@property
23+
def name(self) -> str:
24+
return f"{self.session}-{self.pe_dir}-{self.readout}-{self.multiecho_id}"
25+
26+
def add_file(self, fl) -> None:
27+
self.files.append(fl)
828

929

1030
def write_bidsignore(deriv_dir):
@@ -107,7 +127,7 @@ def _unique(inlist):
107127
return {k: _unique(v) for k, v in entities.items()}
108128

109129

110-
def group_bolds_ref(*, layout, subject, session_id):
130+
def group_bolds_ref(*, layout, subject, sessions=None):
111131
"""
112132
Extracts BOLD files from a BIDS dataset and combines them into buckets.
113133
Files in a bucket share:
@@ -123,8 +143,7 @@ def group_bolds_ref(*, layout, subject, session_id):
123143
Initialized BIDSLayout
124144
subject : str
125145
The subject ID
126-
session_id : :obj:`str`, None, or ``False``
127-
The session identifier. If ``False``, all sessions will be used (default).
146+
sessions : None
128147
129148
Outputs
130149
-------
@@ -136,8 +155,8 @@ def group_bolds_ref(*, layout, subject, session_id):
136155
Limitations
137156
-----------
138157
Single-band reference (sbref) are excluded.
139-
Does not group multi-echo data.
140158
"""
159+
import re
141160
from contextlib import suppress
142161
from itertools import product
143162

@@ -148,19 +167,12 @@ def group_bolds_ref(*, layout, subject, session_id):
148167
"extension": (".nii", ".nii.gz"),
149168
"scope": "raw", # Ensure derivatives are not captured
150169
}
151-
# list of tuples with unique combinations
152-
combinations = []
153-
# list of lists containing filenames that apply per combination
154-
files = []
170+
# dictionary containing unique Groupings and files
171+
groupings = {}
155172
# list of all BOLDS encountered
156173
all_bolds = []
157174

158-
# TODO: Simplify with pybids.layout.Query.OPTIONAL
159-
sessions = (
160-
[session_id]
161-
if session_id is not False
162-
else layout.get_sessions(subject=subject, scope="raw")
163-
)
175+
sessions = sessions if sessions else layout.get_sessions(subject=subject, scope="raw")
164176

165177
for ses, suffix in sorted(product(sessions or (None,), {"bold"})):
166178
# bold files same session
@@ -169,7 +181,13 @@ def group_bolds_ref(*, layout, subject, session_id):
169181
if bolds is None:
170182
continue
171183

172-
for bold in bolds:
184+
for i, bold in enumerate(bolds):
185+
multiecho_id = None
186+
# multi-echo should be grouped together
187+
if 'echo' in bold.entities:
188+
# create unique id by dropping "_echo-{i}"
189+
multiecho_id = re.sub(r"_echo-\d+", "", bold.filename)
190+
173191
# session, pe, ro
174192
meta = bold.get_metadata()
175193
pe_dir = meta.get("PhaseEncodingDirection")
@@ -180,36 +198,38 @@ def group_bolds_ref(*, layout, subject, session_id):
180198
if ro is not None:
181199
meta.update({"TotalReadoutTime": ro})
182200

183-
comb = (ses, pe_dir, ro)
201+
grouping = BOLDGrouping(
202+
session=ses,
203+
pe_dir=pe_dir,
204+
readout=ro,
205+
multiecho_id=multiecho_id,
206+
)
184207

185208
if any(v is None for v in (pe_dir, ro)):
186209
# cannot be certain so treat as unique
187-
combinations.append(comb)
188-
files.append([bold.path])
189-
elif comb in combinations:
190-
# do not add a new entry to the combinations
191-
# instead append the file to the existing bucket
192-
idx = combinations.index(comb)
193-
files[idx].append(bold.path)
210+
grouping.add_file(bold.path)
211+
groupings[f'unknown{i}'] = grouping
194212
else:
195-
# add a new entry and start a file bucket
196-
combinations.append(comb)
197-
files.append([bold.path])
213+
try:
214+
grouping = groupings[grouping.name]
215+
except KeyError:
216+
groupings[grouping.name] = grouping
217+
218+
grouping.add_file(bold.path)
198219

199220
all_bolds += bolds
200221

201-
if (len(combinations) != len(files)) or (len(all_bolds) != sum([len(x) for x in files])):
222+
if len(all_bolds) != sum([len(g.files) for _, g in groupings.items()]):
202223
msg = f"""Error encountered when grouping BOLD runs.
203-
Combinations: {combinations}
204-
Sorted files: {files}
224+
Combinations: {groupings}
205225
BOLD files: {bolds}
206226
207-
Please share this information with the nibabies developers at:
227+
Please file a bug-report with the nibabies developers at:
208228
https://github.com/nipreps/nibabies/issues/new/choose
209229
"""
210230
raise RuntimeError(msg)
211231

212-
return combinations, files
232+
return groupings
213233

214234

215235
def validate_input_dir(exec_env, bids_dir, participant_label):

nibabies/workflows/base.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ def init_single_subject_wf(subject_id, session_id=None):
400400
fmap_estimators = find_estimators(
401401
layout=config.execution.layout,
402402
subject=subject_id,
403-
sessions=session_id,
403+
sessions=[session_id],
404404
fmapless=False, # config.workflow.use_syn,
405405
force_fmapless=False, # config.workflow.force_syn,
406406
)
@@ -425,24 +425,25 @@ def init_single_subject_wf(subject_id, session_id=None):
425425
# 3) total readout time
426426
from niworkflows.workflows.epi.refmap import init_epi_reference_wf
427427

428-
_, bold_groupings = group_bolds_ref(
428+
bold_groupings = group_bolds_ref(
429429
layout=config.execution.layout,
430430
subject=subject_id,
431-
session_id=session_id,
431+
sessions=[session_id],
432432
)
433-
if any(not x for x in bold_groupings):
434-
print("No BOLD files found for one or more reference groupings")
435-
return workflow
436433

437434
func_preproc_wfs = []
438435
has_fieldmap = bool(fmap_estimators)
439-
for idx, bold_files in enumerate(bold_groupings):
436+
for idx, grouping in enumerate(bold_groupings.values()):
440437
bold_ref_wf = init_epi_reference_wf(
441438
auto_bold_nss=True,
442439
name=f"bold_reference_wf{idx}",
443440
omp_nthreads=config.nipype.omp_nthreads,
444441
)
445-
bold_ref_wf.inputs.inputnode.in_files = bold_files
442+
bold_files = grouping.files
443+
bold_ref_wf.inputs.inputnode.in_files = grouping.files
444+
445+
if grouping.multiecho_id is not None:
446+
bold_files = [bold_files]
446447
for idx, bold_file in enumerate(bold_files):
447448
func_preproc_wf = init_func_preproc_wf(
448449
bold_file,

nibabies/workflows/bold/t2s.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def init_bold_t2s_wf(echo_times, mem_gb, omp_nthreads, name="bold_t2s_wf"):
6868
The optimally combined time series was carried forward as the *preprocessed BOLD*.
6969
"""
7070

71-
inputnode = pe.Node(niu.IdentityInterface(fields=["bold_file"]), name="inputnode")
71+
inputnode = pe.Node(niu.IdentityInterface(fields=["bold_file", "bold_mask"]), name="inputnode")
7272

7373
outputnode = pe.Node(niu.IdentityInterface(fields=["bold"]), name="outputnode")
7474

@@ -78,7 +78,8 @@ def init_bold_t2s_wf(echo_times, mem_gb, omp_nthreads, name="bold_t2s_wf"):
7878

7979
# fmt: off
8080
workflow.connect([
81-
(inputnode, t2smap_node, [('bold_file', 'in_files')]),
81+
(inputnode, t2smap_node, [('bold_file', 'in_files'),
82+
('bold_mask', 'mask_file')]),
8283
(t2smap_node, outputnode, [('optimal_comb', 'bold')]),
8384
])
8485
# fmt: on

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ install_requires =
3030
scipy ~= 1.6.0; python_version<'3.8'
3131
sdcflows @ git+https://github.com/nipreps/sdcflows.git@master
3232
smriprep ~= 0.8.1
33+
tedana ~= 0.0.12
3334
templateflow >= 0.6.0
3435
toml
3536
test_requires =

0 commit comments

Comments
 (0)