diff --git a/heudiconv/convert.py b/heudiconv/convert.py index aecc70dc..fe942d29 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, @@ -523,6 +524,30 @@ def update_uncombined_name( return filename +def update_multiorient_name( + metadata: dict[str, Any], + filename: str, +) -> str: + bids_file = BIDSFile.parse(filename) + if bids_file["acq"]: + lgr.warning( + "Not embedding multi-orientation information as `%r` already uses acq- parameter.", + filename, + ) + return filename + iop = metadata.get("ImageOrientationPatientDICOM") + assert isinstance(iop, list) + cross_prod = [ + iop[1] * iop[5] - iop[2] * iop[4], + iop[2] * iop[3] - iop[0] * iop[5], + iop[0] * iop[4] - iop[1] * iop[3], + ] + cross_prod = [abs(x) for x in cross_prod] + slice_orient = ["sagittal", "coronal", "axial"][cross_prod.index(1)] + bids_file["acq"] = slice_orient + return str(bids_file) + + def convert( items: list[tuple[str, tuple[str, ...], list[str]]], converter: str, @@ -953,6 +978,7 @@ def save_converted_files( 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 @@ -968,6 +994,10 @@ def save_converted_files( 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 @@ -978,6 +1008,7 @@ def save_converted_files( 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 @@ -1008,6 +1039,11 @@ def save_converted_files( bids_meta, this_prefix_basename, channel_names_lst ) + if is_multiorient: + this_prefix_basename = update_multiorient_name( + bids_meta, this_prefix_basename + ) + # 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/heuristics/reproin.py b/heudiconv/heuristics/reproin.py index 26db4aea..689b5c2b 100644 --- a/heudiconv/heuristics/reproin.py +++ b/heudiconv/heuristics/reproin.py @@ -406,7 +406,11 @@ def infotodict( # XXX: skip derived sequences, we don't store them to avoid polluting # the directory, unless it is the motion corrected ones # (will get _rec-moco suffix) - if skip_derived and curr_seqinfo.is_derived and not curr_seqinfo.is_motion_corrected: + if ( + skip_derived + and curr_seqinfo.is_derived + and not curr_seqinfo.is_motion_corrected + ): skipped.append(curr_seqinfo.series_id) lgr.debug("Ignoring derived data %s", curr_seqinfo.series_id) continue @@ -552,7 +556,10 @@ def infotodict( # XXX if we have a known earlier study, we need to always # increase the run counter for phasediff because magnitudes # were not acquired - if get_study_hash([curr_seqinfo]) == "9d148e2a05f782273f6343507733309d": + if ( + get_study_hash([curr_seqinfo]) + == "9d148e2a05f782273f6343507733309d" + ): current_run += 1 else: raise RuntimeError( 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..f350fae1 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 ["sagittal", "coronal", "axial"]: + for ext in ["nii.gz", "json"]: + assert op.exists( + op.join(outdir, "sub-%s", "anat", "sub-%s_acq-%s_localizer.%s") + % (subID, subID, orient, ext) + ) + + @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 ec63f4e2..f5716ea9 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,18 @@ 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 acq 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_acq-sagittal_run-01_bold" + out_fn_test = update_multiorient_name(metadata, base_fn) + 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.