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