Skip to content

Commit 3b9ff3a

Browse files
authored
Merge pull request #479 from effigies/enh/estimated-trt
feat: Add workflow arguments for metadata estimates and fallback TRT
2 parents f3b32fc + 76326a8 commit 3b9ff3a

File tree

8 files changed

+123
-40
lines changed

8 files changed

+123
-40
lines changed

sdcflows/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
# https://www.nipreps.org/community/licensing/
2222
#
2323
"""py.test configuration."""
24+
2425
import os
26+
import logging
2527
from pathlib import Path
2628
import numpy
2729
import nibabel
@@ -60,7 +62,7 @@ def pytest_report_header(config):
6062

6163

6264
@pytest.fixture(autouse=True)
63-
def doctest_fixture(doctest_namespace, request):
65+
def doctest_fixture(doctest_namespace, request, caplog):
6466
doctest_plugin = request.config.pluginmanager.getplugin("doctest")
6567
if isinstance(request.node, doctest_plugin.DoctestItem):
6668
doctest_namespace.update(
@@ -73,6 +75,8 @@ def doctest_fixture(doctest_namespace, request):
7375
dsB_dir=data_dir / "dsB",
7476
dsC_dir=data_dir / "dsC",
7577
data_dir=data_dir,
78+
caplog=caplog,
79+
logging=logging,
7680
)
7781
doctest_namespace.update((key, Path(val.root)) for key, val in layouts.items())
7882

sdcflows/fieldmaps.py

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,19 @@
2424
from pathlib import Path
2525
from enum import Enum, auto
2626
from collections import defaultdict
27+
from contextlib import suppress
2728
import re
2829
import attr
2930
from json import loads
3031
from bids.layout import parse_file_entities
3132
from bids.utils import listify
3233
from niworkflows.utils.bids import relative_to_root
3334
from .utils.bimap import EstimatorRegistry
35+
from .utils.misc import create_logger
3436

3537

38+
logger = create_logger('sdcflows.fieldmaps')
39+
3640
_estimators = EstimatorRegistry()
3741
_intents = defaultdict(set)
3842

@@ -109,12 +113,7 @@ class FieldmapFile:
109113
>>> f.suffix
110114
'T1w'
111115
112-
>>> FieldmapFile(
113-
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
114-
... find_meta=False
115-
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
116-
Traceback (most recent call last):
117-
MetadataError:
116+
By default, the immediate JSON sidecar file is consulted for metadata.
118117
119118
>>> f = FieldmapFile(
120119
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
@@ -123,11 +122,26 @@ class FieldmapFile:
123122
0.005
124123
125124
>>> f = FieldmapFile(
126-
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
127-
... metadata={"TotalReadoutTime": None},
128-
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
129-
Traceback (most recent call last):
130-
MetadataError:
125+
... dsA_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz"
126+
... )
127+
>>> f.metadata['EchoTime2']
128+
0.00746
129+
130+
>>> f = FieldmapFile(
131+
... dsA_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz"
132+
... )
133+
>>> f.metadata['EchoTime']
134+
0.00746
135+
136+
>>> f = FieldmapFile(
137+
... dsA_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz"
138+
... )
139+
>>> f.metadata['Units']
140+
'rad/s'
141+
142+
However, it is possible to provide alternative metadata.
143+
It is recommended to load the metadata with an external tool that
144+
fully implements the BIDS inheritance rules to ensure correctness.
131145
132146
>>> f = FieldmapFile(
133147
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
@@ -136,44 +150,56 @@ class FieldmapFile:
136150
>>> f.metadata['TotalReadoutTime']
137151
0.006
138152
153+
If metadata is present, warnings or errors may be presented.
154+
155+
>>> FieldmapFile(
156+
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
157+
... find_meta=False
158+
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
159+
Traceback (most recent call last):
160+
MetadataError: Missing 'PhaseEncodingDirection' ...
161+
139162
>>> FieldmapFile(
140163
... dsA_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz",
141164
... find_meta=False
142165
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
143166
Traceback (most recent call last):
144167
MetadataError:
145168
146-
>>> f = FieldmapFile(
147-
... dsA_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz"
148-
... )
149-
>>> f.metadata['EchoTime2']
150-
0.00746
151-
152169
>>> FieldmapFile(
153170
... dsA_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz",
154171
... find_meta=False
155172
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
156173
Traceback (most recent call last):
157174
MetadataError:
158175
159-
>>> f = FieldmapFile(
160-
... dsA_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz"
161-
... )
162-
>>> f.metadata['EchoTime']
163-
0.00746
164-
165176
>>> FieldmapFile(
166177
... dsA_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz",
167178
... find_meta=False
168179
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
169180
Traceback (most recent call last):
170181
MetadataError:
171182
172-
>>> f = FieldmapFile(
173-
... dsA_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz"
174-
... )
175-
>>> f.metadata['Units']
176-
'rad/s'
183+
Readout timing information is required to estimate PEPOLAR fieldmaps,
184+
but it is possible to provide a fallback values.
185+
Therefore, warnings are logged if the metadata are missing.
186+
187+
>>> with caplog.at_level(logging.WARNING, "sdcflows.fieldmaps"):
188+
... f = FieldmapFile(
189+
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
190+
... metadata={"TotalReadoutTime": None},
191+
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
192+
>>> print(caplog.text)
193+
WARNING ... Missing readout timing information ... Explicit fallback must be provided.
194+
195+
>>> with caplog.at_level(logging.WARNING, "sdcflows.fieldmaps"):
196+
... f = FieldmapFile(
197+
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
198+
... metadata={"PhaseEncodingDirection": "i", "EstimatedTotalReadoutTime": 0.05},
199+
... find_meta=False,
200+
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
201+
>>> print(caplog.text)
202+
WARNING ... Missing readout timing information ... Estimated timing is available.
177203
178204
"""
179205

@@ -251,12 +277,19 @@ def __attrs_post_init__(self):
251277

252278
from .utils.epimanip import get_trt
253279

280+
msg = "Missing readout timing information for <%s>. %s"
281+
extra = "Explicit fallback must be provided."
282+
have_trt = False
254283
try:
255284
get_trt(self.metadata, in_file=self.path)
256-
except ValueError as exc:
257-
raise MetadataError(
258-
f"Missing readout timing information for <{self.path}>."
259-
) from exc
285+
have_trt = True
286+
except ValueError:
287+
with suppress(ValueError):
288+
get_trt(self.metadata, in_file=self.path, use_estimate=True)
289+
extra = "Estimated timing is available."
290+
291+
if not have_trt:
292+
logger.warning(msg, self.path, extra)
260293

261294
elif self.suffix == "fieldmap" and "Units" not in self.metadata:
262295
raise MetadataError(f"Missing 'Units' for <{self.path}>.")

sdcflows/utils/epimanip.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,13 @@ def get_trt(
244244

245245
return trt
246246

247-
if "PhaseEncodingDirection" in in_meta:
247+
if in_meta.keys() & {
248+
"PhaseEncodingDirection",
249+
"EffectiveEchoSpacing",
250+
"EstimatedEffectiveEchoSpacing",
251+
"EchoSpacing",
252+
"WaterFatShift",
253+
} > {"PhaseEncodingDirection"}:
248254
# npe = N voxels PE direction
249255
pe_index = "ijk".index(in_meta["PhaseEncodingDirection"][0])
250256
npe = nb.load(in_file).shape[pe_index]

sdcflows/utils/wrangler.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -623,10 +623,8 @@ def find_anatomical_estimators(
623623
if not meta.get("PhaseEncodingDirection"):
624624
continue
625625

626-
trt = 1.0
627626
with suppress(ValueError):
628-
trt = get_trt(meta, candidate.path)
629-
meta.update({"TotalReadoutTime": trt})
627+
meta.update({"TotalReadoutTime": get_trt(meta, candidate.path)})
630628
epi_targets.append(fm.FieldmapFile(candidate, metadata=meta))
631629

632630
def sort_key(fmap):

sdcflows/workflows/apply/correction.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
def init_unwarp_wf(
3030
*,
3131
jacobian=True,
32+
use_metadata_estimates=False,
33+
fallback_total_readout_time=None,
3234
free_mem=None,
3335
omp_nthreads=1,
3436
debug=False,
@@ -117,8 +119,16 @@ def init_unwarp_wf(
117119
name="outputnode",
118120
)
119121

120-
rotime = pe.Node(GetReadoutTime(), name="rotime")
122+
rotime = pe.Node(
123+
GetReadoutTime(
124+
use_estimate=use_metadata_estimates,
125+
),
126+
name="rotime",
127+
run_without_submitting=True,
128+
)
121129
rotime.interface._always_run = debug
130+
if fallback_total_readout_time is not None:
131+
rotime.inputs.fallback = fallback_total_readout_time
122132

123133
# resample is memory-hungry; choose a smaller number of threads
124134
# if we know how much memory we have to work with

sdcflows/workflows/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@
2121
# https://www.nipreps.org/community/licensing/
2222
#
2323
"""Estimate fieldmaps for :abbr:`SDC (susceptibility distortion correction)`."""
24+
2425
from nipype import logging
2526
from nipype.pipeline import engine as pe
2627
from nipype.interfaces import utility as niu
2728
from niworkflows.engine.workflows import LiterateWorkflow as Workflow
2829

30+
from ..utils.epimanip import get_trt
31+
2932
LOGGER = logging.getLogger("nipype.workflow")
3033
DEFAULT_MEMORY_MIN_GB = 0.01
3134

@@ -36,6 +39,8 @@ def init_fmap_preproc_wf(
3639
omp_nthreads,
3740
output_dir,
3841
subject,
42+
use_metadata_estimates=False,
43+
fallback_total_readout_time=None,
3944
sloppy=False,
4045
debug=False,
4146
name="fmap_preproc_wf",
@@ -107,7 +112,22 @@ def init_fmap_preproc_wf(
107112
)
108113

109114
for n, estimator in enumerate(estimators, 1):
115+
if fallback_total_readout_time is None:
116+
for f in estimator.sources:
117+
if f.suffix in ("bold", "dwi", "epi", "sbref", "asl", "m0scan"):
118+
try:
119+
get_trt(
120+
f.metadata,
121+
in_file=f.path,
122+
use_estimate=use_metadata_estimates,
123+
)
124+
except ValueError:
125+
msg = f"Missing readout timing information for <{f.path}>."
126+
raise RuntimeError(msg)
127+
110128
est_wf = estimator.get_workflow(
129+
use_metadata_estimates=use_metadata_estimates,
130+
fallback_total_readout_time=fallback_total_readout_time,
111131
omp_nthreads=omp_nthreads,
112132
debug=debug,
113133
sloppy=sloppy,

sdcflows/workflows/fit/pepolar.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636

3737
def init_topup_wf(
3838
grid_reference=0,
39+
use_metadata_estimates=False,
40+
fallback_total_readout_time=None,
3941
omp_nthreads=1,
4042
sloppy=False,
4143
debug=False,
@@ -123,11 +125,15 @@ def init_topup_wf(
123125

124126
# Calculate the total readout time of each run
125127
readout_time = pe.MapNode(
126-
GetReadoutTime(),
128+
GetReadoutTime(
129+
use_estimate=use_metadata_estimates,
130+
),
127131
name="readout_time",
128132
iterfield=["metadata", "in_file"],
129133
run_without_submitting=True,
130134
)
135+
if fallback_total_readout_time is not None:
136+
readout_time.inputs.fallback = fallback_total_readout_time
131137
# Average each run so that topup is not overwhelmed (see #279)
132138
runwise_avg = pe.MapNode(
133139
RobustAverage(num_threads=omp_nthreads),

sdcflows/workflows/fit/syn.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444

4545
def init_syn_sdc_wf(
4646
*,
47+
use_metadata_estimates=False,
48+
fallback_total_readout_time=None,
4749
sloppy=False,
4850
debug=False,
4951
name="syn_sdc_wf",
@@ -157,10 +159,14 @@ def init_syn_sdc_wf(
157159
outputnode.inputs.method = 'FLB ("fieldmap-less", SyN-based)'
158160

159161
readout_time = pe.Node(
160-
GetReadoutTime(),
162+
GetReadoutTime(
163+
use_estimate=use_metadata_estimates,
164+
),
161165
name="readout_time",
162166
run_without_submitting=True,
163167
)
168+
if fallback_total_readout_time is not None:
169+
readout_time.inputs.fallback = fallback_total_readout_time
164170

165171
warp_dir = pe.Node(
166172
niu.Function(function=_warp_dir),

0 commit comments

Comments
 (0)