Skip to content

Commit f4c0ca6

Browse files
committed
ENH: New objects for better representation of fieldmap estimation
This PR attempts to provide a more reliable framework to build fieldmap estimation workflows. Implicitly, it will help addressing issues regarding data conformity (e.g., #63, #64, #65) and also ease larger refactors such as #20, #21, #26, and #101.
1 parent 797eccb commit f4c0ca6

38 files changed

+431
-0
lines changed

sdcflows/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
layouts = {p.name: BIDSLayout(str(p), validate=False, derivatives=True)
1414
for p in Path(test_data_env).glob('*') if p.is_dir()}
1515

16+
data_dir = Path(__file__).parent / "tests" / "data" / "dsA"
17+
1618

1719
def pytest_report_header(config):
1820
msg = "Datasets found: %s" % ', '.join([v.root for v in layouts.values()])
@@ -30,6 +32,8 @@ def add_np(doctest_namespace):
3032
for key, val in list(layouts.items()):
3133
doctest_namespace[key] = Path(val.root)
3234

35+
doctest_namespace['testdata_dir'] = data_dir
36+
3337

3438
@pytest.fixture
3539
def workdir():
@@ -49,3 +53,8 @@ def bids_layouts():
4953
@pytest.fixture
5054
def datadir():
5155
return Path(test_data_env)
56+
57+
58+
@pytest.fixture
59+
def testdata_dir():
60+
return data_dir

sdcflows/fieldmaps.py

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
"""Utilities for fieldmap estimation."""
2+
from pathlib import Path
3+
from enum import Enum, auto
4+
import attr
5+
from json import loads
6+
from bids.layout import BIDSFile, parse_file_entities
7+
from bids.utils import listify
8+
from niworkflows.utils.bids import relative_to_root
9+
10+
11+
class MetadataError(ValueError):
12+
"""A better name for a specific value error."""
13+
14+
15+
class EstimatorType(Enum):
16+
"""Represents different types of fieldmap estimation approach."""
17+
18+
UNKNOWN = auto()
19+
PEPOLAR = auto()
20+
PHASEDIFF = auto()
21+
MAPPED = auto()
22+
ANAT = auto()
23+
24+
25+
MODALITIES = {
26+
"bold": EstimatorType.PEPOLAR,
27+
"dwi": EstimatorType.PEPOLAR,
28+
"epi": EstimatorType.PEPOLAR,
29+
"fieldmap": EstimatorType.MAPPED,
30+
"magnitude": None,
31+
"magnitude1": None,
32+
"magnitude2": None,
33+
"phase1": EstimatorType.PHASEDIFF,
34+
"phase2": EstimatorType.PHASEDIFF,
35+
"phasediff": EstimatorType.PHASEDIFF,
36+
"sbref": EstimatorType.PEPOLAR,
37+
"T1w": EstimatorType.ANAT,
38+
"T2w": EstimatorType.ANAT,
39+
}
40+
41+
42+
def _type_setter(obj, attribute, value):
43+
"""Make sure the type of estimation is not changed."""
44+
if obj.method == value:
45+
return value
46+
47+
if obj.method != EstimatorType.UNKNOWN and obj.method != value:
48+
raise TypeError(
49+
f"Cannot change determined method {obj.method} to {value}."
50+
)
51+
52+
if value not in (
53+
EstimatorType.PEPOLAR,
54+
EstimatorType.PHASEDIFF,
55+
EstimatorType.MAPPED,
56+
EstimatorType.ANAT,
57+
):
58+
raise ValueError(f"Invalid estimation method type {value}.")
59+
60+
return value
61+
62+
63+
@attr.s(slots=True)
64+
class FieldmapFile:
65+
"""
66+
Represent a file that can be used in some fieldmap estimation method.
67+
68+
The class will read metadata from a sidecar JSON with filename matching that
69+
of the file.
70+
This class may receive metadata as a keyword argument at initialization.
71+
72+
Examples
73+
--------
74+
>>> f = FieldmapFile(testdata_dir / "sub-01" / "anat" / "sub-01_T1w.nii.gz")
75+
>>> f.suffix
76+
'T1w'
77+
78+
>>> FieldmapFile(
79+
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
80+
... find_meta=False
81+
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
82+
Traceback (most recent call last):
83+
MetadataError:
84+
85+
>>> f = FieldmapFile(
86+
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
87+
... )
88+
>>> f.metadata['TotalReadoutTime']
89+
0.005
90+
91+
>>> f = FieldmapFile(
92+
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
93+
... metadata={'TotalReadoutTime': 0.006}
94+
... )
95+
>>> f.metadata['TotalReadoutTime']
96+
0.006
97+
98+
>>> FieldmapFile(
99+
... testdata_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz",
100+
... find_meta=False
101+
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
102+
Traceback (most recent call last):
103+
MetadataError:
104+
105+
>>> f = FieldmapFile(
106+
... testdata_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz"
107+
... )
108+
>>> f.metadata['EchoTime2']
109+
0.00746
110+
111+
>>> FieldmapFile(
112+
... testdata_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz",
113+
... find_meta=False
114+
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
115+
Traceback (most recent call last):
116+
MetadataError:
117+
118+
>>> f = FieldmapFile(
119+
... testdata_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz"
120+
... )
121+
>>> f.metadata['EchoTime']
122+
0.00746
123+
124+
>>> FieldmapFile(
125+
... testdata_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz",
126+
... find_meta=False
127+
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
128+
Traceback (most recent call last):
129+
MetadataError:
130+
131+
>>> f = FieldmapFile(
132+
... testdata_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz"
133+
... )
134+
>>> f.metadata['Units']
135+
'rad/s'
136+
137+
"""
138+
139+
path = attr.ib(converter=Path, repr=str, on_setattr=attr.setters.NO_OP)
140+
"""Path to a fieldmap file."""
141+
142+
entities = attr.ib(init=False, repr=False)
143+
"""BIDS entities extracted from filepath."""
144+
145+
suffix = attr.ib(init=False, repr=False)
146+
"""Extracted suffix from input file."""
147+
148+
bids_root = attr.ib(init=False, default=None, repr=False)
149+
"""Path of the BIDS root."""
150+
151+
metadata = attr.ib(kw_only=True, default=attr.Factory(dict))
152+
"""
153+
Metadata associated to this file. When provided as keyword argument in initialization,
154+
will overwrite metadata read from sidecar JSON.
155+
"""
156+
157+
find_meta = attr.ib(kw_only=True, default=True, type=bool, repr=False)
158+
"""Enable/disable automated search for corresponding metadata."""
159+
160+
@path.validator
161+
def check_path(self, attribute, value):
162+
"""Validate a fieldmap path."""
163+
if isinstance(value, BIDSFile):
164+
value = Path(value.path)
165+
if isinstance(value, str):
166+
value = Path(value)
167+
168+
if not value.is_file():
169+
raise FileNotFoundError(
170+
f"File path <{value}> does not exist, is a broken link, or it is not a file"
171+
)
172+
173+
if not str(value).endswith((".nii", ".nii.gz")):
174+
raise ValueError(f"File path <{value}> does not look like a NIfTI file.")
175+
176+
suffix = str(value).rpartition(".nii")[0].rpartition("_")[-1]
177+
if suffix not in tuple(MODALITIES.keys()):
178+
raise ValueError(
179+
f"File path <{value}> with suffix <{suffix}> is not a valid "
180+
"fieldmap sourcefile."
181+
)
182+
183+
def __attrs_post_init__(self):
184+
"""Validate metadata and additional checks."""
185+
self.entities = parse_file_entities(str(self.path))
186+
self.suffix = self.entities.pop("suffix")
187+
extension = self.entities.pop('extension').lstrip('.')
188+
189+
# Automatically fill metadata in when possible
190+
# TODO: implement BIDS hierarchy of metadata
191+
if self.find_meta:
192+
sidecar = Path(
193+
str(self.path).replace(extension, "json")
194+
)
195+
if sidecar.is_file():
196+
_meta = self.metadata or {}
197+
self.metadata = loads(sidecar.read_text())
198+
self.metadata.update(_meta)
199+
200+
# Attempt to infer a bids_root folder
201+
relative_path = relative_to_root(self.path)
202+
if str(relative_path) != str(self.path):
203+
self.bids_root = Path(str(self.path)[:-len(str(relative_path))])
204+
205+
# Check for REQUIRED metadata (depends on suffix.)
206+
if self.suffix in ("bold", "dwi", "epi", "sbref"):
207+
if "PhaseEncodingDirection" not in self.metadata:
208+
raise MetadataError(f"Missing 'PhaseEncodingDirection' for <{self.path}>.")
209+
if not (
210+
set(("TotalReadoutTime", "EffectiveEchoSpacing")).intersection(
211+
self.metadata.keys())
212+
):
213+
raise MetadataError(f"Missing readout timing information for <{self.path}>.")
214+
215+
if self.suffix == "fieldmap" and "Units" not in self.metadata:
216+
raise MetadataError(f"Missing 'Units' for <{self.path}>.")
217+
218+
if (
219+
self.suffix == "phasediff"
220+
and ("EchoTime1" not in self.metadata or "EchoTime2" not in self.metadata)
221+
):
222+
raise MetadataError(f"Missing 'EchoTime1' and/or 'EchoTime2' for <{self.path}>.")
223+
224+
if self.suffix in ("phase1", "phase2") and ("EchoTime" not in self.metadata):
225+
raise MetadataError(f"Missing 'EchoTime' for <{self.path}>.")
226+
227+
228+
@attr.s(slots=True)
229+
class FieldmapEstimation:
230+
"""
231+
Represent fieldmap estimation strategies.
232+
233+
This class provides a consistent interface to all types of fieldmap estimation
234+
strategies.
235+
The actual type of method for estimation is inferred from the ``sources`` input,
236+
and collects all the available metadata.
237+
238+
"""
239+
240+
sources = attr.ib(
241+
default=None,
242+
converter=lambda v: [
243+
FieldmapFile(f) if not isinstance(f, FieldmapFile) else f
244+
for f in listify(v)
245+
],
246+
repr=lambda v: f"<{len(v)} files>",
247+
)
248+
"""File path or list of paths indicating the source data to estimate a fieldmap."""
249+
250+
method = attr.ib(init=False, default=EstimatorType.UNKNOWN, on_setattr=_type_setter)
251+
"""Flag indicating the estimator type inferred from the input sources."""
252+
253+
def __attrs_post_init__(self):
254+
"""Determine the inteded fieldmap estimation type and check for data completeness."""
255+
suffix_list = [f.suffix for f in self.sources]
256+
suffix_set = set(suffix_list)
257+
258+
# Fieldmap option 1: actual field-mapping sequences
259+
fmap_types = suffix_set.intersection(("fieldmap", "phasediff", "phase1", "phase2"))
260+
if len(fmap_types) > 1 and fmap_types - set(("phase1", "phase2")):
261+
raise TypeError(f"Incompatible suffices found: <{','.join(fmap_types)}>.")
262+
263+
if fmap_types:
264+
sources = sorted(
265+
str(f.path) for f in self.sources
266+
if f.suffix in ("fieldmap", "phasediff", "phase1", "phase2")
267+
)
268+
269+
# Automagically add the corresponding phase2 file if missing as argument
270+
missing_phases = ("phase1" not in fmap_types, "phase2" not in fmap_types)
271+
if sum(missing_phases) == 1:
272+
mis_ph = "phase1" if missing_phases[0] else "phase2"
273+
hit_ph = "phase2" if missing_phases[0] else "phase1"
274+
new_source = sources[0].replace(hit_ph, mis_ph)
275+
self.sources.append(FieldmapFile(new_source))
276+
sources.insert(int(missing_phases[1]), new_source)
277+
278+
# Set method, this cannot be undone
279+
self.method = MODALITIES[fmap_types.pop()]
280+
281+
# Determine the name of the corresponding (first) magnitude file(s)
282+
magnitude = f"magnitude{'' if self.method == EstimatorType.MAPPED else '1'}"
283+
if magnitude not in suffix_set:
284+
try:
285+
self.sources.append(FieldmapFile(
286+
sources[0].replace("fieldmap", "magnitude").replace(
287+
"diff", "1").replace("phase", "magnitude")
288+
))
289+
except Exception:
290+
raise ValueError(
291+
"A fieldmap or phase-difference estimation type was found, "
292+
f"but an anatomical reference ({magnitude} file) is missing."
293+
)
294+
295+
# Check presence and try to find (if necessary) the second magnitude file
296+
if self.method == EstimatorType.PHASEDIFF and "magnitude2" not in suffix_set:
297+
try:
298+
self.sources.append(FieldmapFile(
299+
sources[-1].replace("diff", "2").replace("phase", "magnitude")
300+
))
301+
except Exception:
302+
if "phase2" in suffix_set:
303+
raise ValueError(
304+
"A phase-difference estimation (phase1/2) type was found, "
305+
"but an anatomical reference (magnitude2 file) is missing."
306+
)
307+
308+
# Fieldmap option 2: PEPOLAR (and fieldmap-less or ANAT)
309+
# IMPORTANT NOTE: fieldmap-less approaches can be considered PEPOLAR with RO = 0.0s
310+
pepolar_types = suffix_set.intersection(("bold", "dwi", "epi", "sbref"))
311+
_pepolar_estimation = len([
312+
f for f in suffix_list if f in ("bold", "dwi", "epi", "sbref")
313+
]) > 1
314+
315+
if _pepolar_estimation:
316+
self.method = MODALITIES[pepolar_types.pop()]
317+
318+
anat_types = suffix_set.intersection(("T1w", "T2w"))
319+
if anat_types:
320+
self.method = MODALITIES[anat_types.pop()]
321+
322+
if not pepolar_types:
323+
raise ValueError(
324+
"Only anatomical sources were found, cannot estimate fieldmap."
325+
)
326+
327+
# No method has been identified -> fail.
328+
if self.method == EstimatorType.UNKNOWN:
329+
raise ValueError("Insufficient sources to estimate a fieldmap.")

sdcflows/tests/data/dsA/sub-01/anat/sub-01_T1w.nii.gz

Whitespace-only changes.

sdcflows/tests/data/dsA/sub-01/anat/sub-01_T2w.nii.gz

Whitespace-only changes.

sdcflows/tests/data/dsA/sub-01/dwi/sub-01_dir-AP_dwi.nii.gz

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"PhaseEncodingDirection": "j-",
3+
"TotalReadoutTime": 0.005
4+
}

sdcflows/tests/data/dsA/sub-01/dwi/sub-01_dir-AP_sbref.nii.gz

Whitespace-only changes.

sdcflows/tests/data/dsA/sub-01/dwi/sub-01_dir-LR_dwi.nii.gz

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"PhaseEncodingDirection": "i",
3+
"TotalReadoutTime": 0.005
4+
}

sdcflows/tests/data/dsA/sub-01/dwi/sub-01_dir-LR_sbref.nii.gz

Whitespace-only changes.

0 commit comments

Comments
 (0)