Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rename BIDS files for multi-orientation series. #788

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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
36 changes: 36 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 @@ -523,6 +524,30 @@
return filename


def update_multiorient_name(
metadata: dict[str, Any],
filename: str,
) -> str:
bids_file = BIDSFile.parse(filename)
if bids_file["acq"]:
lgr.warning(

Check warning on line 533 in heudiconv/convert.py

View check run for this annotation

Codecov / codecov/patch

heudiconv/convert.py#L533

Added line #L533 was not covered by tests
"Not embedding multi-orientation information as `%r` already uses acq- parameter.",
filename,
)
return filename

Check warning on line 537 in heudiconv/convert.py

View check run for this annotation

Codecov / codecov/patch

heudiconv/convert.py#L537

Added line #L537 was not covered by tests
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,
Expand Down Expand Up @@ -953,6 +978,7 @@
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 @@ -968,6 +994,10 @@
image_types.update(metadata["ImageType"])
except KeyError:
pass
try:
iops.add(str(metadata["ImageOrientationPatientDICOM"]))
except KeyError:
pass

Check warning on line 1000 in heudiconv/convert.py

View check run for this annotation

Codecov / codecov/patch

heudiconv/convert.py#L999-L1000

Added lines #L999 - L1000 were not covered by tests

is_multiecho = (
len(set(filter(bool, echo_times))) > 1
Expand All @@ -978,6 +1008,7 @@
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 @@ -1008,6 +1039,11 @@
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:
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")

Check warning on line 20 in heudiconv/heuristics/bids_localizer.py

View check run for this annotation

Codecov / codecov/patch

heudiconv/heuristics/bids_localizer.py#L20

Added line #L20 was not covered by tests
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
11 changes: 9 additions & 2 deletions heudiconv/heuristics/reproin.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,11 @@
# 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
Expand Down Expand Up @@ -552,7 +556,10 @@
# 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 (

Check warning on line 559 in heudiconv/heuristics/reproin.py

View check run for this annotation

Codecov / codecov/patch

heudiconv/heuristics/reproin.py#L559

Added line #L559 was not covered by tests
get_study_hash([curr_seqinfo])
== "9d148e2a05f782273f6343507733309d"
):
current_run += 1
else:
raise RuntimeError(
Expand Down
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 ["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,
Expand Down
13 changes: 13 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,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.
Expand Down