|
| 1 | +import logging |
| 2 | +import lzma |
| 3 | +from simplejson import loads as json_loads |
| 4 | +from os import environ |
| 5 | + |
| 6 | +lgr = logging.getLogger(__name__) |
| 7 | + |
| 8 | + |
| 9 | +# map the various guesses to the cannonical labels |
| 10 | +modality_label_map = { |
| 11 | + 't1': 'T1w', |
| 12 | + 't1w': 'T1w', |
| 13 | + 't2': 'T2w', |
| 14 | + 't2w': 'T2w', |
| 15 | + 't1rho': 'T1rho', |
| 16 | + 't1map': 'T1map', |
| 17 | + 't2map': 'T2map', |
| 18 | + 't2star': 'T2star', |
| 19 | + 'flair': 'FLAIR', |
| 20 | + 'flash': 'FLASH', |
| 21 | + 'pd': 'PD', |
| 22 | + 'pdmap': 'PDmap', |
| 23 | + 'pdt2': 'PDT2', |
| 24 | + 'inplanet1': 'inplaneT1', |
| 25 | + 'inplanet2': 'inplaneT2', |
| 26 | +} |
| 27 | + |
| 28 | +# map the cannonical modality labels to data_type labels |
| 29 | +datatype_labels_map = { |
| 30 | + 'bold': 'func', |
| 31 | + 'sbref': 'func', |
| 32 | + |
| 33 | + 'T1w': 'anat', |
| 34 | + 'T2w': 'anat', |
| 35 | + 'T1rho': 'anat', |
| 36 | + 'T1map': 'anat', |
| 37 | + 'T2map': 'anat', |
| 38 | + 'T2star': 'anat', |
| 39 | + 'FLAIR': 'anat', |
| 40 | + 'FLASH': 'anat', |
| 41 | + 'PD': 'anat', |
| 42 | + 'PDmap': 'anat', |
| 43 | + 'PDT2': 'anat', |
| 44 | + 'inplaneT1': 'anat', |
| 45 | + 'inplaneT2': 'anat', |
| 46 | + 'angio': 'anat', |
| 47 | + |
| 48 | + 'swi': 'swi', |
| 49 | + 'dwi': 'dwi', |
| 50 | + |
| 51 | + 'phasediff': 'fmap', |
| 52 | + 'phase1': 'fmap', |
| 53 | + 'phase2': 'fmap', |
| 54 | + 'magnitude1': 'fmap', |
| 55 | + 'magnitude2': 'fmap', |
| 56 | + 'fieldmap': 'fmap', |
| 57 | + |
| 58 | + 'epi': 'fmap', # TODO? |
| 59 | +} |
| 60 | + |
| 61 | +# map specification keys to BIDS abbreviation used in paths |
| 62 | +spec2bids_map = { |
| 63 | + 'subject': "sub", |
| 64 | + 'anon-subject': "sub", |
| 65 | + 'bids-session': "ses", |
| 66 | + 'bids-task': "task", |
| 67 | + 'bids-run': "run", |
| 68 | + 'bids-modality': "mod", |
| 69 | + 'bids-acquisition': "acq", |
| 70 | + 'bids-scan': "scan", |
| 71 | + 'bids-contrast-enhancement': "ce", |
| 72 | + 'bids-reconstruction-algorithm': "rec", |
| 73 | + 'bids-echo': "echo", |
| 74 | + 'bids-direction': "dir", |
| 75 | + |
| 76 | + # SWI Extension: |
| 77 | + 'bids-part': "part", |
| 78 | + 'bids-coil': "coil", |
| 79 | + |
| 80 | +} |
| 81 | + |
| 82 | + |
| 83 | +def get_specval(spec, key): |
| 84 | + return spec[key]['value'] |
| 85 | + |
| 86 | + |
| 87 | +def has_specval(spec, key): |
| 88 | + return key in spec and 'value' in spec[key] and spec[key]['value'] |
| 89 | + |
| 90 | + |
| 91 | +# Snippet from https://github.com/datalad/datalad to avoid depending on it for |
| 92 | +# just one function: |
| 93 | +def LZMAFile(*args, **kwargs): |
| 94 | + """A little decorator to overcome a bug in lzma |
| 95 | +
|
| 96 | + A unique to yoh and some others bug with pyliblzma |
| 97 | + calling dir() helps to avoid AttributeError __exit__ |
| 98 | + see https://bugs.launchpad.net/pyliblzma/+bug/1219296 |
| 99 | + """ |
| 100 | + lzmafile = lzma.LZMAFile(*args, **kwargs) |
| 101 | + dir(lzmafile) |
| 102 | + return lzmafile |
| 103 | + |
| 104 | + |
| 105 | +def loads(s, *args, **kwargs): |
| 106 | + """Helper to log actual value which failed to be parsed""" |
| 107 | + try: |
| 108 | + return json_loads(s, *args, **kwargs) |
| 109 | + except: |
| 110 | + lgr.error( |
| 111 | + "Failed to load content from %r with args=%r kwargs=%r" |
| 112 | + % (s, args, kwargs) |
| 113 | + ) |
| 114 | + raise |
| 115 | + |
| 116 | + |
| 117 | +def load_stream(fname, compressed=False): |
| 118 | + |
| 119 | + _open = LZMAFile if compressed else open |
| 120 | + with _open(fname, mode='r') as f: |
| 121 | + for line in f: |
| 122 | + yield loads(line) |
| 123 | + |
| 124 | +# END datalad Snippet |
| 125 | + |
| 126 | + |
| 127 | +def create_key(template, outtype=('nii.gz',), annotation_classes=None): |
| 128 | + if template is None or not template: |
| 129 | + raise ValueError('Template must be a valid format string') |
| 130 | + |
| 131 | + return template, outtype, annotation_classes |
| 132 | + |
| 133 | + |
| 134 | +class SpecLoader(object): |
| 135 | + """ |
| 136 | + Persistent object to hold the study specification and not read the JSON on |
| 137 | + each invocation of `infotodict`. Module level attribute for the spec itself |
| 138 | + doesn't work, since the env variable isn't necessarily available at first |
| 139 | + import. |
| 140 | + """ |
| 141 | + |
| 142 | + def __init__(self): |
| 143 | + self._spec = None |
| 144 | + # get chosen subject id (orig or anon) from env var |
| 145 | + self.subject = environ.get('HIRNI_SPEC2BIDS_SUBJECT') |
| 146 | + |
| 147 | + def get_study_spec(self): |
| 148 | + if self._spec is None: |
| 149 | + filename = environ.get('HIRNI_STUDY_SPEC') |
| 150 | + if filename: |
| 151 | + self._spec = [d for d in load_stream(filename) |
| 152 | + if d['type'] == 'dicomseries'] |
| 153 | + else: |
| 154 | + # TODO: Just raise or try a default location first? |
| 155 | + raise ValueError("No study specification provided. " |
| 156 | + "Set environment variable HIRNI_STUDY_SPEC " |
| 157 | + "to do so.") |
| 158 | + return self._spec |
| 159 | + |
| 160 | + |
| 161 | +_spec = SpecLoader() |
| 162 | + |
| 163 | + |
| 164 | +def validate_spec(spec): |
| 165 | + |
| 166 | + if not spec: |
| 167 | + raise ValueError("Image series specification is empty.") |
| 168 | + |
| 169 | + tags = spec.get('tags', None) |
| 170 | + if tags and 'hirni-dicom-converter-ignore' in tags: |
| 171 | + lgr.debug("Skip series %s (marked 'ignore' in spec)", spec['uid']) |
| 172 | + return False |
| 173 | + |
| 174 | + # mandatory keys for any spec dict (not only dicomseries) |
| 175 | + for k in spec.keys(): |
| 176 | + # automatically managed keys with no subdict: |
| 177 | + # TODO: Where to define this list? |
| 178 | + # TODO: Test whether those are actually present! |
| 179 | + if k in ['type', 'location', 'uid', 'dataset-id', |
| 180 | + 'dataset-refcommit', 'procedures', 'tags']: |
| 181 | + continue |
| 182 | + if 'value' not in spec[k]: |
| 183 | + lgr.warning("DICOM series specification (UID: {uid}) has no value " |
| 184 | + "for key '{key}'.".format(uid=spec['uid'], key=k)) |
| 185 | + return False |
| 186 | + |
| 187 | + if spec['type'] != 'dicomseries': |
| 188 | + lgr.warning("Specification not of type 'dicomseries'.") |
| 189 | + return False |
| 190 | + |
| 191 | + if 'uid' not in spec.keys() or not spec['uid']: |
| 192 | + lgr.warning("Missing image series UID.") |
| 193 | + return False |
| 194 | + |
| 195 | + for var in ('bids-modality',): |
| 196 | + if not has_specval(spec, var): |
| 197 | + lgr.warning("Missing specification value for key '%s'", var) |
| 198 | + return False |
| 199 | + |
| 200 | + return True |
| 201 | + |
| 202 | + |
| 203 | +# TODO: can be removed, whenever nipy/heudiconv #197 is solved |
| 204 | +def infotoids(seqinfos, outdir): |
| 205 | + return {'locator': None, |
| 206 | + 'session': None, |
| 207 | + 'subject': None} |
| 208 | + |
| 209 | + |
| 210 | +def infotodict(seqinfo): # pragma: no cover |
| 211 | + """Heuristic evaluator for determining which runs belong where |
| 212 | +
|
| 213 | + allowed template fields - follow python string module: |
| 214 | +
|
| 215 | + item: index within category |
| 216 | + subject: participant id |
| 217 | + seqitem: run number during scanning |
| 218 | + subindex: sub index within group |
| 219 | + """ |
| 220 | + |
| 221 | + info = dict() |
| 222 | + for idx, s in enumerate(seqinfo): |
| 223 | + |
| 224 | + # find in spec: |
| 225 | + candidates = [series for series in _spec.get_study_spec() |
| 226 | + if str(s.series_uid) == series['uid']] |
| 227 | + if not candidates: |
| 228 | + raise ValueError("Found no match for seqinfo: %s" % str(s)) |
| 229 | + if len(candidates) != 1: |
| 230 | + raise ValueError("Found %s match(es) for series UID %s" % |
| 231 | + (len(candidates), s.uid)) |
| 232 | + series_spec = candidates[0] |
| 233 | + |
| 234 | + if not validate_spec(series_spec): |
| 235 | + lgr.debug("Series invalid (%s). Skip.", str(s.series_uid)) |
| 236 | + continue |
| 237 | + |
| 238 | + dirname = filename = "sub-{}".format(_spec.subject) |
| 239 | + # session |
| 240 | + if has_specval(series_spec, 'bids-session'): |
| 241 | + ses = get_specval(series_spec, 'bids-session') |
| 242 | + dirname += "/ses-{}".format(ses) |
| 243 | + filename += "_ses-{}".format(ses) |
| 244 | + |
| 245 | + # data type |
| 246 | + modality = get_specval(series_spec, 'bids-modality') |
| 247 | + # make cannonical if possible |
| 248 | + modality = modality_label_map.get(modality, modality) |
| 249 | + # apply fixed mapping from modality -> data_type |
| 250 | + data_type = datatype_labels_map[modality] |
| 251 | + |
| 252 | + dirname += "/{}".format(data_type) |
| 253 | + |
| 254 | + # TODO: Once special cases (like when to use '_mod-' prefix for modality |
| 255 | + # are clear, integrate data type selection with spec_key list and |
| 256 | + # thereby reduce code duplication further |
| 257 | + |
| 258 | + if data_type == 'func': |
| 259 | + # func/sub-<participant_label>[_ses-<session_label>] |
| 260 | + # _task-<task_label>[_acq-<label>][_rec-<label>][_run-<index>][_echo-<index>]_<modality_label>.nii[.gz] |
| 261 | + |
| 262 | + for spec_key in ['bids-task', 'bids-acquisition', |
| 263 | + 'bids-reconstruction_algorithm', 'bids-run', |
| 264 | + 'bids-echo']: |
| 265 | + if has_specval(series_spec, spec_key): |
| 266 | + filename += "_{}-{}".format( |
| 267 | + spec2bids_map[spec_key], |
| 268 | + get_specval(series_spec, spec_key)) |
| 269 | + |
| 270 | + filename += "_{}".format(modality) |
| 271 | + |
| 272 | + if data_type == 'anat': |
| 273 | + # anat/sub-<participant_label>[_ses-<session_label>] |
| 274 | + # [_acq-<label>][_ce-<label>][_rec-<label>][_run-<index>][_mod-<label>]_<modality_label>.nii[.gz] |
| 275 | + |
| 276 | + for spec_key in ['bids-acquisition', |
| 277 | + 'bids-contrast_enhancement', |
| 278 | + 'bids-reconstruction_algorithm', |
| 279 | + 'bids-run']: |
| 280 | + if has_specval(series_spec, spec_key): |
| 281 | + filename += "_{}-{}".format( |
| 282 | + spec2bids_map[spec_key], |
| 283 | + get_specval(series_spec, spec_key)) |
| 284 | + |
| 285 | + # TODO: [_mod-<label>] (modality if defaced, right?) |
| 286 | + # => simple bool 'defaced' in spec or is there more to it? |
| 287 | + |
| 288 | + filename += "_{}".format(modality) |
| 289 | + |
| 290 | + if data_type == 'dwi': |
| 291 | + # dwi/sub-<participant_label>[_ses-<session_label>] |
| 292 | + # [_acq-<label>][_run-<index>]_dwi.nii[.gz] |
| 293 | + |
| 294 | + for spec_key in ['bids-acquisition', |
| 295 | + 'bids-run']: |
| 296 | + if has_specval(series_spec, spec_key): |
| 297 | + filename += "_{}-{}".format( |
| 298 | + spec2bids_map[spec_key], |
| 299 | + get_specval(series_spec, spec_key)) |
| 300 | + |
| 301 | + # TODO: Double check: Is this always correct? |
| 302 | + filename += "_dwi" |
| 303 | + |
| 304 | + if data_type == 'swi': |
| 305 | + # BIDS-Extension: |
| 306 | + # https://docs.google.com/document/d/1kyw9mGgacNqeMbp4xZet3RnDhcMmf4_BmRgKaOkO2Sc |
| 307 | + # swi/sub-<participant_label>[_ses-<session_label>] |
| 308 | + # [_acq-<label>][_rec-<label>]_part-<phase|mag>[_coil-<index>][_echo-<index>][_run-<index>]_GRE.nii[.gz] |
| 309 | + |
| 310 | + for spec_key in ['bids-acquisition', |
| 311 | + 'bids-reconstruction_algorithm', |
| 312 | + 'bids-part', |
| 313 | + 'bids-coil', |
| 314 | + 'bids-echo', |
| 315 | + 'bids-run', |
| 316 | + ]: |
| 317 | + if has_specval(series_spec, spec_key): |
| 318 | + filename += "_{}-{}".format( |
| 319 | + spec2bids_map[spec_key], |
| 320 | + get_specval(series_spec, spec_key)) |
| 321 | + |
| 322 | + filename += "_GRE" |
| 323 | + |
| 324 | + if data_type == 'fmap': |
| 325 | + # Case 1: Phase difference image and at least one magnitude image |
| 326 | + # sub-<participant_label>/[ses-<session_label>/] |
| 327 | + # [_acq-<label>][_dir-<dir_label>][_run-<run_index>]_<modality_label>.nii[.gz] |
| 328 | + |
| 329 | + # Note/TODO: fmap modalities: |
| 330 | + # _phasediff |
| 331 | + # _magnitude1 |
| 332 | + # _magnitude2 |
| 333 | + # _phase1 |
| 334 | + # _phase2 |
| 335 | + # _magnitude |
| 336 | + # _fieldmap |
| 337 | + # _epi |
| 338 | + |
| 339 | + for spec_key in ['bids-acquisition', |
| 340 | + 'bids-direction', |
| 341 | + 'bids-run']: |
| 342 | + if has_specval(series_spec, spec_key): |
| 343 | + filename += "_{}-{}".format( |
| 344 | + spec2bids_map[spec_key], |
| 345 | + get_specval(series_spec, spec_key)) |
| 346 | + |
| 347 | + filename += "_{}".format(modality) |
| 348 | + |
| 349 | + key = create_key(dirname + '/' + filename) |
| 350 | + if key not in info: |
| 351 | + info[key] = [] |
| 352 | + |
| 353 | + info[key].append(s[2]) |
| 354 | + |
| 355 | + return info |
0 commit comments