Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions heudiconv/bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,12 @@ class BIDSFile:
"mt",
"part",
"recording",
"chunk",
"nuc",
"tracksys",
"voi",
"stain",
"trc",
]

def __init__(
Expand Down
47 changes: 47 additions & 0 deletions heudiconv/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .bids import (
BIDS_VERSION,
BIDSError,
BIDSFile,
add_participant_record,
populate_bids_templates,
populate_intended_for,
Expand Down Expand Up @@ -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-<num>` 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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions heudiconv/heuristics/bids_localizer.py
Original file line number Diff line number Diff line change
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
24 changes: 24 additions & 0 deletions heudiconv/tests/test_bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions heudiconv/tests/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down