diff --git a/heudiconv/bids.py b/heudiconv/bids.py index 6a347977..a05a92ac 100644 --- a/heudiconv/bids.py +++ b/heudiconv/bids.py @@ -1088,6 +1088,12 @@ class BIDSFile: "mt", "part", "recording", + "chunk", + "nuc", + "tracksys", + "voi", + "stain", + "trc", ] def __init__( diff --git a/heudiconv/convert.py b/heudiconv/convert.py index 03de785a..1bc9da13 100644 --- a/heudiconv/convert.py +++ b/heudiconv/convert.py @@ -20,6 +20,7 @@ from .bids import ( BIDS_VERSION, BIDSError, + BIDSFile, add_participant_record, populate_bids_templates, populate_intended_for, @@ -521,6 +522,41 @@ def update_uncombined_name( return filename +def update_multiorient_name( + metadata: dict[str, Any], + filename: str, + iops: set, +) -> str: + """ + Insert `_chunk-` entity into filename if data are from a sequence + that outputs multiple FoV (localizer, multi-FoV bold) + + Parameters + ---------- + metadata : dict + Scan metadata dictionary from BIDS sidecar file. + filename : str + Incoming filename + + Returns + ------- + filename : str + Updated filename with chunk entity added, if appropriate. + """ + bids_file = BIDSFile.parse(filename) + if bids_file["chunk"]: + lgr.warning( + "Not embedding multi-orientation information as `%r` already uses chunk- parameter.", + filename, + ) + return filename + iops_list = sorted(list(iops)) + bids_file["chunk"] = str( + iops_list.index(str(metadata["ImageOrientationPatientDICOM"])) + 1 + ) + return str(bids_file) + + def convert( items: list[tuple[str, tuple[str, ...], list[str]]], converter: str, @@ -1029,6 +1065,7 @@ def rename_files() -> None: echo_times: set[float] = set() channel_names: set[str] = set() image_types: set[str] = set() + iops: set[str] = set() for metadata in bids_metas: if not metadata: continue @@ -1044,6 +1081,10 @@ def rename_files() -> None: image_types.update(metadata["ImageType"]) except KeyError: pass + try: + iops.add(str(metadata["ImageOrientationPatientDICOM"])) + except KeyError: + pass is_multiecho = ( len(set(filter(bool, echo_times))) > 1 @@ -1054,6 +1095,7 @@ def rename_files() -> None: is_complex = ( "M" in image_types and "P" in image_types ) # Determine if data are complex (magnitude + phase) + is_multiorient = len(iops) > 1 echo_times_lst = sorted(echo_times) # also converts to list channel_names_lst = sorted(channel_names) # also converts to list @@ -1084,6 +1126,11 @@ def rename_files() -> None: bids_meta, this_prefix_basename, channel_names_lst ) + if is_multiorient: + this_prefix_basename = update_multiorient_name( + bids_meta, this_prefix_basename, iops + ) + # Fallback option: # If we have failed to modify this_prefix_basename, because it didn't fall # into any of the options above, just add the suffix at the end: diff --git a/heudiconv/heuristics/bids_localizer.py b/heudiconv/heuristics/bids_localizer.py new file mode 100644 index 00000000..63972a06 --- /dev/null +++ b/heudiconv/heuristics/bids_localizer.py @@ -0,0 +1,44 @@ +"""Heuristic demonstrating conversion of the Multi-Echo sequences. + +It only cares about converting sequences which have _ME_ in their +series_description and outputs to BIDS. +""" + +from __future__ import annotations + +from typing import Optional + +from heudiconv.utils import SeqInfo + + +def create_key( + template: Optional[str], + outtype: tuple[str, ...] = ("nii.gz",), + annotation_classes: None = None, +) -> tuple[str, tuple[str, ...], None]: + if template is None or not template: + raise ValueError("Template must be a valid format string") + return (template, outtype, annotation_classes) + + +def infotodict( + seqinfo: list[SeqInfo], +) -> dict[tuple[str, tuple[str, ...], None], list[str]]: + """Heuristic evaluator for determining which runs belong where + + allowed template fields - follow python string module: + + item: index within category + subject: participant id + seqitem: run number during scanning + subindex: sub index within group + """ + localizer = create_key("sub-{subject}/anat/sub-{subject}_localizer") + + info: dict[tuple[str, tuple[str, ...], None], list[str]] = { + localizer: [], + } + for s in seqinfo: + if "localizer" in s.series_description: + info[localizer].append(s.series_id) + return info diff --git a/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.2018113015350928736278242 b/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.2018113015350928736278242 new file mode 100755 index 00000000..5e6e5c17 Binary files /dev/null and b/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.2018113015350928736278242 differ diff --git a/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.201811301535098526678240 b/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.201811301535098526678240 new file mode 100755 index 00000000..768eae27 Binary files /dev/null and b/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.201811301535098526678240 differ diff --git a/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.2018113015351140807678244 b/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.2018113015351140807678244 new file mode 100755 index 00000000..f8d3f5a5 Binary files /dev/null and b/heudiconv/tests/data/01-localizer_64ch/MR.1.3.12.2.1107.5.2.43.167006.2018113015351140807678244 differ diff --git a/heudiconv/tests/test_bids.py b/heudiconv/tests/test_bids.py index 5c8bf9b1..28666091 100644 --- a/heudiconv/tests/test_bids.py +++ b/heudiconv/tests/test_bids.py @@ -1369,6 +1369,30 @@ def test_BIDSFile() -> None: assert my_bids_file["echo"] == "2" +def test_convert_multiorient( + tmp_path: Path, + heuristic: str = "bids_localizer.py", + subID: str = "loc", +) -> None: + """Unit test for the case of multi-orient localizer data. + The different orientations should be labeled in `acq` entity. + """ + datadir = op.join(TESTS_DATA_PATH, "01-localizer_64ch") + outdir = tmp_path / "out" + outdir.mkdir() + args = gen_heudiconv_args(datadir, str(outdir), subID, heuristic) + runner(args) + + # Check that the expected files have been extracted. + # This also checks that the "echo" entity comes before "part": + for orient in [1, 2, 3]: + for ext in ["nii.gz", "json"]: + fname = op.join( + outdir, "sub-%s", "anat", "sub-%s_chunk-%d_localizer.%s" + ) % (subID, subID, orient, ext) + assert op.exists(fname) + + @pytest.mark.skipif(not have_datalad, reason="no datalad") def test_ME_mag_phase_conversion( monkeypatch: pytest.MonkeyPatch, diff --git a/heudiconv/tests/test_convert.py b/heudiconv/tests/test_convert.py index 7e888678..76249677 100644 --- a/heudiconv/tests/test_convert.py +++ b/heudiconv/tests/test_convert.py @@ -17,6 +17,7 @@ bvals_are_zero, update_complex_name, update_multiecho_name, + update_multiorient_name, update_uncombined_name, ) from heudiconv.utils import load_heuristic @@ -143,6 +144,20 @@ def test_update_uncombined_name() -> None: update_uncombined_name(metadata, base_fn, set(channel_names)) # type: ignore[arg-type] +def test_update_multiorient_name() -> None: + """Unit testing for heudiconv.convert.update_multiorient_name(), which updates + filenames with the chunk field if appropriate. + """ + # Standard name update + base_fn = "sub-X_ses-Y_task-Z_run-01_bold" + metadata = {"ImageOrientationPatientDICOM": [0, 1, 0, 0, 0, -1]} + out_fn_true = "sub-X_ses-Y_task-Z_run-01_chunk-1_bold" + out_fn_test = update_multiorient_name( + metadata, base_fn, set(["[0, 1, 0, 0, 0, -1]"]) + ) + assert out_fn_test == out_fn_true + + def test_b0dwi_for_fmap(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Make sure we raise a warning when .bvec and .bval files are present but the modality is not dwi.