Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: New objects for better representation of fieldmap estimation #114

Merged
merged 6 commits into from
Nov 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Information on specific functions, classes, and methods.
.. toctree::
:glob:

api/sdcflows.fieldmaps
api/sdcflows.interfaces
api/sdcflows.models
api/sdcflows.viz
Expand Down
9 changes: 9 additions & 0 deletions sdcflows/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
layouts = {p.name: BIDSLayout(str(p), validate=False, derivatives=True)
for p in Path(test_data_env).glob('*') if p.is_dir()}

data_dir = Path(__file__).parent / "tests" / "data" / "dsA"


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

doctest_namespace['testdata_dir'] = data_dir


@pytest.fixture
def workdir():
Expand All @@ -49,3 +53,8 @@ def bids_layouts():
@pytest.fixture
def datadir():
return Path(test_data_env)


@pytest.fixture
def testdata_dir():
return data_dir
351 changes: 351 additions & 0 deletions sdcflows/fieldmaps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
"""Utilities for fieldmap estimation."""
from pathlib import Path
from enum import Enum, auto
import re
import attr
from json import loads
from bids.layout import BIDSFile, parse_file_entities
from bids.utils import listify
from niworkflows.utils.bids import relative_to_root


class MetadataError(ValueError):
"""A better name for a specific value error."""


class EstimatorType(Enum):
"""Represents different types of fieldmap estimation approach."""

UNKNOWN = auto()
PEPOLAR = auto()
PHASEDIFF = auto()
MAPPED = auto()
ANAT = auto()


MODALITIES = {
"bold": EstimatorType.PEPOLAR,
"dwi": EstimatorType.PEPOLAR,
"epi": EstimatorType.PEPOLAR,
"fieldmap": EstimatorType.MAPPED,
"magnitude": None,
"magnitude1": None,
"magnitude2": None,
"phase1": EstimatorType.PHASEDIFF,
"phase2": EstimatorType.PHASEDIFF,
"phasediff": EstimatorType.PHASEDIFF,
"sbref": EstimatorType.PEPOLAR,
"T1w": EstimatorType.ANAT,
"T2w": EstimatorType.ANAT,
}


def _type_setter(obj, attribute, value):
"""Make sure the type of estimation is not changed."""
if obj.method == value:
return value

if obj.method != EstimatorType.UNKNOWN and obj.method != value:
raise TypeError(f"Cannot change determined method {obj.method} to {value}.")

if value not in (
EstimatorType.PEPOLAR,
EstimatorType.PHASEDIFF,
EstimatorType.MAPPED,
EstimatorType.ANAT,
):
raise ValueError(f"Invalid estimation method type {value}.")

return value


@attr.s(slots=True)
class FieldmapFile:
"""
Represent a file that can be used in some fieldmap estimation method.

The class will read metadata from a sidecar JSON with filename matching that
of the file.
This class may receive metadata as a keyword argument at initialization.

Examples
--------
>>> f = FieldmapFile(testdata_dir / "sub-01" / "anat" / "sub-01_T1w.nii.gz")
>>> f.suffix
'T1w'

>>> FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
... find_meta=False
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
MetadataError:

>>> f = FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
... )
>>> f.metadata['TotalReadoutTime']
0.005

>>> f = FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
... metadata={'TotalReadoutTime': 0.006}
... )
>>> f.metadata['TotalReadoutTime']
0.006

>>> FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz",
... find_meta=False
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
MetadataError:

>>> f = FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz"
... )
>>> f.metadata['EchoTime2']
0.00746

>>> FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz",
... find_meta=False
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
MetadataError:

>>> f = FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz"
... )
>>> f.metadata['EchoTime']
0.00746

>>> FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz",
... find_meta=False
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
MetadataError:

>>> f = FieldmapFile(
... testdata_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz"
... )
>>> f.metadata['Units']
'rad/s'

"""

path = attr.ib(converter=Path, repr=str, on_setattr=attr.setters.NO_OP)
"""Path to a fieldmap file."""

entities = attr.ib(init=False, repr=False)
"""BIDS entities extracted from filepath."""

suffix = attr.ib(init=False, repr=False)
"""Extracted suffix from input file."""

bids_root = attr.ib(init=False, default=None, repr=False)
"""Path of the BIDS root."""

metadata = attr.ib(kw_only=True, default=attr.Factory(dict))
"""
Metadata associated to this file. When provided as keyword argument in initialization,
will overwrite metadata read from sidecar JSON.
"""

find_meta = attr.ib(kw_only=True, default=True, type=bool, repr=False)
"""Enable/disable automated search for corresponding metadata."""

@path.validator
def check_path(self, attribute, value):
"""Validate a fieldmap path."""
if isinstance(value, BIDSFile):
value = Path(value.path)
elif isinstance(value, str):
value = Path(value)

if not value.is_file():
raise FileNotFoundError(
f"File path <{value}> does not exist, is a broken link, or it is not a file"
)

if not str(value).endswith((".nii", ".nii.gz")):
raise ValueError(f"File path <{value}> does not look like a NIfTI file.")

suffix = re.search(r"(?<=_)\w+(?=\.nii)", value.name).group()
if suffix not in tuple(MODALITIES.keys()):
raise ValueError(
f"File path <{value}> with suffix <{suffix}> is not a valid "
"fieldmap sourcefile."
)

def __attrs_post_init__(self):
"""Validate metadata and additional checks."""
self.entities = parse_file_entities(str(self.path))
self.suffix = self.entities.pop("suffix")
extension = self.entities.pop("extension").lstrip(".")

# Automatically fill metadata in when possible
# TODO: implement BIDS hierarchy of metadata
if self.find_meta:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is probably fine, but what if the find_meta attribute was replaced with bids_layout, allowing the user to pass in an already initialized layout? then you could leverage methods like find_metadata()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to assume that a BIDS layout might not be initialized. There are some other assumptions for the dataset to be BIDS-like (e.g., this really needs the input to have a suffix).

But the idea here is that we just need to insert a Nipype rename node to generate a name with the right suffix and then it should work regardless the file is in a BIDS structure or not (which may happen often, as well).

sidecar = Path(str(self.path).replace(extension, "json"))
if sidecar.is_file():
_meta = self.metadata or {}
self.metadata = loads(sidecar.read_text())
self.metadata.update(_meta)

# Attempt to infer a bids_root folder
relative_path = relative_to_root(self.path)
if str(relative_path) != str(self.path):
self.bids_root = Path(str(self.path)[: -len(str(relative_path))])

# Check for REQUIRED metadata (depends on suffix.)
if self.suffix in ("bold", "dwi", "epi", "sbref"):
if "PhaseEncodingDirection" not in self.metadata:
raise MetadataError(
f"Missing 'PhaseEncodingDirection' for <{self.path}>."
)
if not (
set(("TotalReadoutTime", "EffectiveEchoSpacing")).intersection(
self.metadata.keys()
)
):
raise MetadataError(
f"Missing readout timing information for <{self.path}>."
)

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

elif self.suffix == "phasediff" and (
"EchoTime1" not in self.metadata or "EchoTime2" not in self.metadata
):
raise MetadataError(
f"Missing 'EchoTime1' and/or 'EchoTime2' for <{self.path}>."
)

elif self.suffix in ("phase1", "phase2") and ("EchoTime" not in self.metadata):
raise MetadataError(f"Missing 'EchoTime' for <{self.path}>.")


@attr.s(slots=True)
class FieldmapEstimation:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason for this to be a class, rather than a function returning sources / FM estimation method?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attribute method keeps a state of the estimation (i.e. either unknown or a type of estimation). Also, I think a class can achieve similar things to a function, while having the responsibilities more clear and readable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually when I see a class with the only method being initialization, I think it can be stripped down to a function (estimate_fieldmap_method()) and achieve the same amount of clarity / readability, with the benefit of reducing some bloat. Would method ever be altered after this has been initialized?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function you suggest already exists -

def fieldmap_wrangler(layout, target_image, use_syn=False, force_syn=False):
"""Query the BIDSLayout for fieldmaps, and arrange them for the orchestration workflow."""
from collections import defaultdict
fmap_bids = layout.get_fieldmap(target_image, return_list=True)
fieldmaps = defaultdict(list)
for fmap in fmap_bids:
if fmap['suffix'] == 'epi':
fieldmaps['epi'].append((fmap['epi'], layout.get_metadata(fmap['epi'])))
if fmap['suffix'] == 'fieldmap':
fieldmaps['fieldmap'].append({
'magnitude': [(fmap['magnitude'], layout.get_metadata(fmap['magnitude']))],
'fieldmap': [(fmap['fieldmap'], layout.get_metadata(fmap['fieldmap']))],
})
if fmap['suffix'] == 'phasediff':
fieldmaps['phasediff'].append({
'magnitude': [(fmap[k], layout.get_metadata(fmap[k]))
for k in sorted(fmap.keys()) if k.startswith('magnitude')],
'phases': [(fmap['phasediff'], layout.get_metadata(fmap['phasediff']))],
})
if fmap['suffix'] == 'phase':
fieldmaps['phasediff'].append({
'magnitude': [(fmap[k], layout.get_metadata(fmap[k]))
for k in sorted(fmap.keys()) if k.startswith('magnitude')],
'phases': [(fmap[k], layout.get_metadata(fmap[k]))
for k in sorted(fmap.keys()) if k.startswith('phase')],
})
if fieldmaps and force_syn:
# syn: True -> Run SyN in addition to fieldmap-based SDC
fieldmaps['syn'] = True
elif not fieldmaps and (force_syn or use_syn):
# syn: False -> Run SyN as only SDC
fieldmaps['syn'] = False
return fieldmaps

It is all but pretty or reliable. And yet, it doesn't implement most of the validation checkpoints in this PR or the metadata. Then the staging of the actual estimation happens on a different function -

def init_sdc_estimate_wf(fmaps, epi_meta, omp_nthreads=1, debug=False):
"""
Build a :abbr:`SDC (susceptibility distortion correction)` workflow.
This workflow implements the heuristics to choose an estimation
methodology for :abbr:`SDC (susceptibility distortion correction)`.
When no field map information is present within the BIDS inputs,
the EXPERIMENTAL "fieldmap-less SyN" can be performed, using
the ``--use-syn`` argument. When ``--force-syn`` is specified,
then the "fieldmap-less SyN" is always executed and reported
despite of other fieldmaps available with higher priority.
In the latter case (some sort of fieldmap(s) is available and
``--force-syn`` is requested), then the :abbr:`SDC (susceptibility
distortion correction)` method applied is that with the
highest priority.
Parameters
----------
fmaps : list of pybids dicts
A list of dictionaries with the available fieldmaps
(and their metadata using the key ``'metadata'`` for the
case of :abbr:`PEPOLAR (Phase-Encoding POLARity)` fieldmaps).
epi_meta : dict
BIDS metadata dictionary corresponding to the
:abbr:`EPI (echo-planar imaging)` run (i.e., suffix ``bold``,
``sbref``, or ``dwi``) for which the fieldmap is being estimated.
omp_nthreads : int
Maximum number of threads an individual process may use
debug : bool
Enable debugging outputs
Inputs
------
epi_file
A reference image calculated at a previous stage
epi_brain
Same as above, but brain-masked
epi_mask
Brain mask for the run
t1w_brain
T1w image, brain-masked, for the fieldmap-less SyN method
std2anat_xfm
Standard-to-T1w transform generated during spatial
normalization (only for the fieldmap-less SyN method).
Outputs
-------
epi_corrected
The EPI scan reference after unwarping.
epi_mask
The corresponding new mask after unwarping
epi_brain
Brain-extracted, unwarped EPI scan reference
out_warp
The deformation field to unwarp the susceptibility distortions
syn_ref
If ``--force-syn``, an unwarped EPI scan reference with this
method (for reporting purposes)
method : str
Short description of the estimation method that was run.
"""
workflow = Workflow(name='sdc_estimate_wf' if fmaps else 'sdc_bypass_wf')
inputnode = pe.Node(niu.IdentityInterface(
fields=['epi_file', 'epi_brain', 'epi_mask', 't1w_brain', 'std2anat_xfm']),
name='inputnode')
outputnode = pe.Node(niu.IdentityInterface(
fields=['epi_corrected', 'epi_mask', 'epi_brain',
'out_warp', 'syn_ref', 'method']),
name='outputnode')
# No fieldmaps - forward inputs to outputs
if not fmaps:
workflow.__postdesc__ = """\
Susceptibility distortion correction (SDC) was omitted.
"""
outputnode.inputs.method = 'None'
outputnode.inputs.out_warp = 'identity'
workflow.connect([
(inputnode, outputnode, [('epi_file', 'epi_corrected'),
('epi_mask', 'epi_mask'),
('epi_brain', 'epi_brain')]),
])
return workflow
workflow.__postdesc__ = """\
Based on the estimated susceptibility distortion, a corrected
EPI (echo-planar imaging) reference was calculated for a more
accurate co-registration with the anatomical reference.
"""
only_syn = 'syn' in fmaps and len(fmaps) == 1
# PEPOLAR path
if 'epi' in fmaps:
from .pepolar import init_pepolar_unwarp_wf, check_pes
# SyN works without this metadata
if epi_meta.get('PhaseEncodingDirection') is None:
raise ValueError(
'PhaseEncodingDirection is not defined within the metadata retrieved '
'for the intended EPI (DWI, BOLD, or SBRef) run.')
outputnode.inputs.method = 'PEB/PEPOLAR (phase-encoding based / PE-POLARity)'
fmaps_epi = [(v[0], v[1].get('PhaseEncodingDirection'))
for v in fmaps['epi']]
if not all(list(zip(*fmaps_epi))[1]):
raise ValueError(
'At least one of the EPI runs with alternative phase-encoding '
'blips is missing the required "PhaseEncodingDirection" metadata entry.')
# Find matched PE directions
matched_pe = check_pes(fmaps_epi, epi_meta['PhaseEncodingDirection'])
# Get EPI polarities and their metadata
sdc_unwarp_wf = init_pepolar_unwarp_wf(
matched_pe=matched_pe,
omp_nthreads=omp_nthreads)
sdc_unwarp_wf.inputs.inputnode.epi_pe_dir = epi_meta['PhaseEncodingDirection']
sdc_unwarp_wf.inputs.inputnode.fmaps_epi = fmaps_epi
workflow.connect([
(inputnode, sdc_unwarp_wf, [
('epi_file', 'inputnode.in_reference'),
('epi_brain', 'inputnode.in_reference_brain'),
('epi_mask', 'inputnode.in_mask')]),
])
# FIELDMAP path
elif 'fieldmap' in fmaps or 'phasediff' in fmaps:
from .fmap import init_fmap2field_wf
from .unwarp import init_sdc_unwarp_wf
# SyN works without this metadata
if epi_meta.get('PhaseEncodingDirection') is None:
raise ValueError(
'PhaseEncodingDirection is not defined within the metadata retrieved '
'for the intended EPI (DWI, BOLD, or SBRef) run.')
if 'fieldmap' in fmaps:
from .fmap import init_fmap_wf
try:
fmap, = fmaps['fieldmap']
except ValueError:
LOGGER.warning('Several B0 fieldmaps found for the given target, using '
'the first one.')
fmap = fmaps['fieldmap'][0]
outputnode.inputs.method = 'FMB (fieldmap-based) - directly measured B0 map'
fmap_wf = init_fmap_wf(
omp_nthreads=omp_nthreads,
fmap_bspline=False)
# set inputs
fmap_wf.inputs.inputnode.magnitude = [
m for m, _ in fmap['magnitude']]
fmap_wf.inputs.inputnode.fieldmap = [
m for m, _ in fmap['fieldmap']]
elif 'phasediff' in fmaps:
from .phdiff import init_phdiff_wf
try:
fmap, = fmaps['phasediff']
except ValueError:
LOGGER.warning('Several phase-difference maps found for the given target, using '
'the first one.')
fmap = fmaps['phasediff'][0]
outputnode.inputs.method = 'FMB (fieldmap-based) - phase-difference map'
fmap_wf = init_phdiff_wf(omp_nthreads=omp_nthreads)
# set inputs
fmap_wf.inputs.inputnode.magnitude = [
m for m, _ in fmap['magnitude']]
fmap_wf.inputs.inputnode.phasediff = fmap['phases']
fmap2field_wf = init_fmap2field_wf(omp_nthreads=omp_nthreads, debug=debug)
fmap2field_wf.inputs.inputnode.metadata = epi_meta
sdc_unwarp_wf = init_sdc_unwarp_wf(
omp_nthreads=omp_nthreads,
debug=debug,
name='sdc_unwarp_wf')
workflow.connect([
(inputnode, fmap2field_wf, [
('epi_file', 'inputnode.in_reference'),
('epi_brain', 'inputnode.in_reference_brain')]),
(inputnode, sdc_unwarp_wf, [
('epi_file', 'inputnode.in_reference'),
('epi_mask', 'inputnode.in_reference_mask')]),
(fmap_wf, fmap2field_wf, [
('outputnode.fmap', 'inputnode.fmap'),
('outputnode.fmap_ref', 'inputnode.fmap_ref'),
('outputnode.fmap_mask', 'inputnode.fmap_mask')]),
(fmap2field_wf, sdc_unwarp_wf, [
('outputnode.out_warp', 'inputnode.in_warp')]),
])
elif not only_syn:
raise ValueError('Fieldmaps of types %s are not supported' %
', '.join(['"%s"' % f for f in fmaps]))
# FIELDMAP-less path
if 'syn' in fmaps:
from .syn import init_syn_sdc_wf
syn_sdc_wf = init_syn_sdc_wf(
epi_pe=epi_meta.get('PhaseEncodingDirection', None),
omp_nthreads=omp_nthreads)
workflow.connect([
(inputnode, syn_sdc_wf, [
('epi_file', 'inputnode.in_reference'),
('epi_brain', 'inputnode.in_reference_brain'),
('t1w_brain', 'inputnode.t1w_brain'),
('std2anat_xfm', 'inputnode.std2anat_xfm')]),
(syn_sdc_wf, outputnode, [
('outputnode.out_reference', 'syn_ref')]),
])
# XXX Eliminate branch when forcing isn't an option
if only_syn: # No fieldmaps, but --use-syn
outputnode.inputs.method = 'FLB ("fieldmap-less", SyN-based)'
sdc_unwarp_wf = syn_sdc_wf
else: # --force-syn was called when other fieldmap was present
sdc_unwarp_wf.__desc__ = None
workflow.connect([
(sdc_unwarp_wf, outputnode, [
('outputnode.out_warp', 'out_warp'),
('outputnode.out_reference', 'epi_corrected'),
('outputnode.out_reference_brain', 'epi_brain'),
('outputnode.out_mask', 'epi_mask')]),
])
return workflow
that has other validation points and decisions.

I wanted to start these objects because I was uncomfortable with the existing code. That code was also not very convenient for standalone implementations (#8) and while it seems to work acceptably for fMRIPrep (acceptable because, for instance, we currently replicate the processing of the same fieldmap for all runs in a dataset, if all are pointed by just one IntendedFor (i.e., one fieldmap). However, when I'm trying to use this on dMRIPrep, well it just does not work.

Objects are definitely easier to test, easier to document (sphinx does a fairly good job collecting docstrings) and more maintainable in the long run because they only have one, simple objective.

One thing I'm still internally debating is whether FieldmapEstimation should have a get_workflow or such, that directly returns the nipype workflow object built and with the inputs and metadata set (i.e., ready to connect the output to some derivatives sink workflow and correction workflows). I'm leaning towards doing it. Any feedback on this will be very very much appreciated.

"""
Represent fieldmap estimation strategies.

This class provides a consistent interface to all types of fieldmap estimation
strategies.
The actual type of method for estimation is inferred from the ``sources`` input,
and collects all the available metadata.

"""

sources = attr.ib(
default=None,
converter=lambda v: [
FieldmapFile(f) if not isinstance(f, FieldmapFile) else f
for f in listify(v)
],
repr=lambda v: f"<{len(v)} files>",
)
"""File path or list of paths indicating the source data to estimate a fieldmap."""

method = attr.ib(init=False, default=EstimatorType.UNKNOWN, on_setattr=_type_setter)
"""Flag indicating the estimator type inferred from the input sources."""

def __attrs_post_init__(self):
"""Determine the inteded fieldmap estimation type and check for data completeness."""
suffix_list = [f.suffix for f in self.sources]
suffix_set = set(suffix_list)

# Fieldmap option 1: actual field-mapping sequences
fmap_types = suffix_set.intersection(
("fieldmap", "phasediff", "phase1", "phase2")
)
if len(fmap_types) > 1 and fmap_types - set(("phase1", "phase2")):
raise TypeError(f"Incompatible suffices found: <{','.join(fmap_types)}>.")

if fmap_types:
sources = sorted(
str(f.path)
for f in self.sources
if f.suffix in ("fieldmap", "phasediff", "phase1", "phase2")
)

# Automagically add the corresponding phase2 file if missing as argument
missing_phases = ("phase1" not in fmap_types, "phase2" not in fmap_types)
if sum(missing_phases) == 1:
mis_ph = "phase1" if missing_phases[0] else "phase2"
hit_ph = "phase2" if missing_phases[0] else "phase1"
new_source = sources[0].replace(hit_ph, mis_ph)
self.sources.append(FieldmapFile(new_source))
sources.insert(int(missing_phases[1]), new_source)

# Set method, this cannot be undone
self.method = MODALITIES[fmap_types.pop()]

# Determine the name of the corresponding (first) magnitude file(s)
magnitude = f"magnitude{'' if self.method == EstimatorType.MAPPED else '1'}"
if magnitude not in suffix_set:
try:
self.sources.append(
FieldmapFile(
sources[0]
.replace("fieldmap", "magnitude")
.replace("diff", "1")
.replace("phase", "magnitude")
)
)
except Exception:
raise ValueError(
"A fieldmap or phase-difference estimation type was found, "
f"but an anatomical reference ({magnitude} file) is missing."
)

# Check presence and try to find (if necessary) the second magnitude file
if (
self.method == EstimatorType.PHASEDIFF
and "magnitude2" not in suffix_set
):
try:
self.sources.append(
FieldmapFile(
sources[-1]
.replace("diff", "2")
.replace("phase", "magnitude")
)
)
except Exception:
if "phase2" in suffix_set:
raise ValueError(
"A phase-difference estimation (phase1/2) type was found, "
"but an anatomical reference (magnitude2 file) is missing."
)

# Fieldmap option 2: PEPOLAR (and fieldmap-less or ANAT)
# IMPORTANT NOTE: fieldmap-less approaches can be considered PEPOLAR with RO = 0.0s
pepolar_types = suffix_set.intersection(("bold", "dwi", "epi", "sbref"))
_pepolar_estimation = (
len([f for f in suffix_list if f in ("bold", "dwi", "epi", "sbref")]) > 1
)

if _pepolar_estimation:
self.method = MODALITIES[pepolar_types.pop()]
_pe = set(f.metadata["PhaseEncodingDirection"] for f in self.sources)
if len(_pe) == 1:
raise ValueError(
f"Only one phase-encoding direction <{_pe.pop()}> found across sources."
)

anat_types = suffix_set.intersection(("T1w", "T2w"))
if anat_types:
self.method = MODALITIES[anat_types.pop()]

if not pepolar_types:
raise ValueError(
"Only anatomical sources were found, cannot estimate fieldmap."
)

# No method has been identified -> fail.
if self.method == EstimatorType.UNKNOWN:
raise ValueError("Insufficient sources to estimate a fieldmap.")
Empty file.
Empty file.
Empty file.
4 changes: 4 additions & 0 deletions sdcflows/tests/data/dsA/sub-01/dwi/sub-01_dir-AP_sbref.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"PhaseEncodingDirection": "j-",
"TotalReadoutTime": 0.005
}
Empty file.
Empty file.
4 changes: 4 additions & 0 deletions sdcflows/tests/data/dsA/sub-01/dwi/sub-01_dir-LR_sbref.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"PhaseEncodingDirection": "i",
"TotalReadoutTime": 0.005
}
Empty file.
Empty file.
Loading