|
| 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.") |
0 commit comments