Skip to content

Commit a6a7c6e

Browse files
authored
Merge pull request #114 from oesteban/enh/fieldmap-objects
ENH: New objects for better representation of fieldmap estimation
2 parents d292490 + ef8df62 commit a6a7c6e

39 files changed

+482
-0
lines changed

docs/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Information on specific functions, classes, and methods.
66
.. toctree::
77
:glob:
88

9+
api/sdcflows.fieldmaps
910
api/sdcflows.interfaces
1011
api/sdcflows.models
1112
api/sdcflows.viz

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

0 commit comments

Comments
 (0)