diff --git a/.gitignore b/.gitignore index 6641a666..8c34210a 100755 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ .idea/ venvs/ _build/ +build/ +.vscode/ + diff --git a/docs/heuristics.rst b/docs/heuristics.rst index 874cc9b4..ec9f0e76 100644 --- a/docs/heuristics.rst +++ b/docs/heuristics.rst @@ -84,4 +84,66 @@ or:: def grouping(files, dcmfilter, seqinfo): seqinfos = collections.OrderedDict() ... - return seqinfos # ordered dict containing seqinfo objects: list of DICOMs \ No newline at end of file + return seqinfos # ordered dict containing seqinfo objects: list of DICOMs + + +------------------------------- +``POPULATE_INTENDED_FOR_OPTS`` +------------------------------- + +Dictionary to specify options to populate the ``'IntendedFor'`` field of the ``fmap`` +jsons. + +When a BIDS session has ``fmaps``, they can automatically be assigned to be used for +susceptibility distortion correction of other non-``fmap`` images in the session +(populating the ``'IntendedFor'`` field in the ``fmap`` json file). + +For this automated assignment, ``fmaps`` are taken as groups (``_phase`` and ``_phasediff`` +images and the corresponding ``_magnitude`` images; consecutive Spin-Echo images collected +with opposite phase encoding polarity (``pepolar`` case); etc.). + +This is achieved by checking, for every non-``fmap`` image in the session, which ``fmap`` +groups are suitable candidates to correct for distortions in that image. Then, if there is +more than one candidate (e.g., if there was a ``fmap`` collected at the beginning of the +session and another one at the end), the user can specify which one to use. + +The parameters that can be specified and the allowed options are defined in ``bids.py``: + - ``'matching_parameter'``: The imaging parameter that needs to match between the ``fmap`` + and an image for the ``fmap`` to be considered as a suitable to correct that image. + Allowed options are: + + * ``'Shims'``: ``heudiconv`` will check the ``ShimSetting`` in the ``.json`` files and + will only assign ``fmaps`` to images if the ``ShimSettings`` are identical for both. + * ``'ImagingVolume'``: both ``fmaps`` and images will need to have the same the imaging + volume (the header affine transformation: position, orientation and voxel size, as well + as number of voxels along each dimensions). + * ``'ModalityAcquisitionLabel'``: it checks for what modality (``anat``, ``func``, ``dwi``) each + ``fmap`` is intended by checking the ``_acq-`` label in the ``fmap`` filename and finding + corresponding modalities (e.g. ``_acq-fmri``, ``_acq-bold`` and ``_acq-func`` will be matched + with the ``func`` modality) + * ``'CustomAcquisitionLabel'``: it checks for what modality images each ``fmap`` is intended + by checking the ``_acq-`` custom label (e.g. ``_acq-XYZ42``) in the ``fmap`` filename, and + matching it with: + - the corresponding modality image ``_acq-`` label for modalities other than ``func`` + (e.g. ``_acq-XYZ42`` for ``dwi`` images) + - the corresponding image ``_task-`` label for the ``func`` modality (e.g. ``_task-XYZ42``) + * ``'Force'``: forces ``heudiconv`` to consider any ``fmaps`` in the session to be + suitable for any image, no matter what the imaging parameters are. + + + - ``'criterion'``: Criterion to decide which of the candidate ``fmaps`` will be assigned to + a given file, if there are more than one. Allowed values are: + + * ``'First'``: The first matching ``fmap``. + * ``'Closest'``: The closest in time to the beginning of the image acquisition. + +.. note:: + Example:: + + POPULATE_INTENDED_FOR_OPTS = { + 'matching_parameters': ['ImagingVolume', 'Shims'], + 'criterion': 'Closest' + } + +If ``POPULATE_INTENDED_FOR_OPTS`` is not present in the heuristic file, ``IntendedFor`` +will not be populated automatically. diff --git a/heudiconv/bids.py b/heudiconv/bids.py index 2043629c..40deb1e5 100644 --- a/heudiconv/bids.py +++ b/heudiconv/bids.py @@ -4,12 +4,14 @@ import os import os.path as op import logging +import numpy as np import re from collections import OrderedDict from datetime import datetime import csv from random import sample from glob import glob +import errno from .external.pydicom import dcm @@ -19,9 +21,12 @@ save_json, create_file_if_missing, json_dumps, + update_json, set_readonly, is_readonly, get_datetime, + remove_suffix, + remove_prefix, ) from . import __version__ @@ -51,6 +56,25 @@ class BIDSError(Exception): BIDS_VERSION = "1.4.1" +# List defining allowed parameter matching for fmap assignment: +SHIM_KEY = 'ShimSetting' +AllowedFmapParameterMatching = [ + 'Shims', + 'ImagingVolume', + 'ModalityAcquisitionLabel', + 'CustomAcquisitionLabel', + 'Force', +] +# Key info returned by get_key_info_for_fmap_assignment when +# matching_parameter = "Force" +KeyInfoForForce = "Forced" +# List defining allowed criteria to assign a given fmap to a non-fmap run +# among the different fmaps with matching parameters: +AllowedCriteriaForFmapAssignment = [ + 'First', + 'Closest', +] + def maybe_na(val): """Return 'n/a' if non-None value represented as str is not empty @@ -193,7 +217,7 @@ def populate_aggregated_jsons(path): # the next for loop iteration: continue - events_file = fpath[:-len(suf)] + '_events.tsv' + events_file = remove_suffix(fpath, suf) + '_events.tsv' # do not touch any existing thing, it may be precious if not op.lexists(events_file): lgr.debug("Generating %s", events_file) @@ -366,7 +390,7 @@ def save_scans_key(item, bids_files): subj_, ses_ = find_subj_ses(f_name) if not subj_: lgr.warning( - "Failed to detect fullfilled BIDS layout. " + "Failed to detect fulfilled BIDS layout. " "No scans.tsv file(s) will be produced for %s", ", ".join(bids_files) ) @@ -496,3 +520,507 @@ def convert_sid_bids(subject_id): lgr.warning('{0} contained nonalphanumeric character(s), subject ' 'ID was cleaned to be {1}'.format(subject_id, sid)) return sid, subject_id + + +def get_shim_setting(json_file): + """ + Gets the "ShimSetting" field from a json_file. + If no "ShimSetting" present, return error + + Parameters: + ---------- + json_file : str + + Returns: + ------- + str with "ShimSetting" value + """ + data = load_json(json_file) + try: + shims = data[SHIM_KEY] + except KeyError as e: + lgr.error('File %s does not have "%s". ' + 'Please use a different "matching_parameters" in your heuristic file', + json_file, SHIM_KEY) + raise KeyError + return shims + + +def find_fmap_groups(fmap_dir): + """ + Finds the different fmap groups in a fmap directory. + By groups here we mean fmaps that are intended to go together + (with reversed PE polarity, magnitude/phase, etc.) + + Parameters: + ---------- + fmap_dir : str or os.path + path to the session folder (or to the subject folder, if there are no + sessions). + + Returns: + ------- + fmap_groups : dict + key: prefix common to the group (e.g. no "dir" entity, "_phase"/"_magnitude", ...) + value: list of all fmap paths in the group + """ + if op.basename(fmap_dir) != 'fmap': + lgr.error('%s is not a fieldmap folder', fmap_dir) + + # Get a list of all fmap json files in the session: + fmap_jsons = sorted(glob(op.join(fmap_dir, '*.json'))) + + # RegEx to remove fmap-specific substrings from fmap file names + # "_phase[1,2]", "_magnitude[1,2]", "_phasediff", "_dir-