Skip to content
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
84 changes: 68 additions & 16 deletions heudiconv/bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -1074,12 +1074,25 @@ class BIDSFile:
order matters
"""

# The full list of entities, in the order mandated by the BIDS entity table
# (``rules/entities.yaml`` of the BIDS schema). Entities we do not produce
# ourselves are listed as well, so that a filename which carries them (e.g.
# coming from a heuristic) survives a parse/serialize round-trip in the
# right order.
_known_entities = [
"sub",
"tpl",
"ses",
"cohort",
"sample",
"task",
"tracksys",
"acq",
"nuc",
"voi",
"ce",
"trc",
"stain",
"rec",
"dir",
"run",
Expand All @@ -1089,7 +1102,22 @@ class BIDSFile:
"inv",
"mt",
"part",
# not a BIDS entity: heudiconv's own, for uncombined multi-channel data.
# Kept at the position update_uncombined_name() places it at.
"ch",
"proc",
"hemi",
"space",
"split",
"recording",
"chunk",
"atlas",
"seg",
"scale",
"res",
"den",
"label",
"desc",
]

def __init__(
Expand All @@ -1113,15 +1141,32 @@ def __eq__(self, other: Any) -> bool:

@classmethod
def parse(cls, filename: str) -> BIDSFile:
"""Parse the filename for BIDS entities, suffix and extension"""
# use re.findall to find all lower-case-letters + '-' + alphanumeric + '_' pairs:
entities_list = re.findall("([a-z]+)-([a-zA-Z0-9]+)[_]*", filename)
# keep only those in the _known_entities list:
entities = {k: v for k, v in entities_list if k in BIDSFile._known_entities}
# get whatever comes after the last key-value pair, and remove any '_' that
"""Parse the filename for BIDS entities, suffix and extension

Raises
------
ValueError
If no ``key-value`` pair could be found at all, i.e. the name is
not a BIDS one.
"""
# Entities are the leading run of lower-case-letters + '-' + alphanumeric
# pairs; everything after it is the suffix (+ extension). Matching the
# run as a whole, rather than every such pair anywhere in the name, keeps
# us from mistaking a part of the suffix for an entity: reproin marks
# duplicate series with a trailing '__dup-01', and a 'T1w-mod' suffix
# contains a 'w-mod' pair.
match = re.match(
r"((?:[a-z]+-[a-zA-Z0-9]+_)*[a-z]+-[a-zA-Z0-9]+)(?=_|\.|$)", filename
)
if not match:
raise ValueError(f"No BIDS entities found in {filename!r}")
# keep all of them: dropping the ones we do not know about would silently
# lose information from the filename (see __str__, which puts the unknown
# ones back at the end).
entities = dict(re.findall("([a-z]+)-([a-zA-Z0-9]+)", match.group(1)))
# get whatever comes after the entities, and remove any '_' that
# might come in front:
ending = filename.split("-".join(entities_list[-1]))[-1]
ending = remove_prefix(ending, "_")
ending = remove_prefix(filename[match.end() :], "_")
# the first dot ('.') separates the suffix from the extension:
if "." in ending:
suffix, extension = ending.split(".", 1)
Expand All @@ -1136,17 +1181,24 @@ def __str__(self) -> str:
# reconstitute the ending for the filename:
suffix = "_" + self.suffix if self.suffix else ""
extension = "." + self.extension if self.extension else ""
return (
"_".join(
[
"-".join([e, self._entities[e]])
for e in self._known_entities
if e in self._entities
]
)
ordered = [e for e in self._known_entities if e in self._entities]
# entities we do not know about cannot be placed within the entity table
# order, so keep them (in the order they were given) right before the
# suffix rather than dropping them on the floor
unknown = [e for e in self._entities if e not in self._known_entities]
out = (
"_".join(["-".join([e, self._entities[e]]) for e in ordered + unknown])
+ suffix
+ extension
)
if unknown:
lgr.warning(
"Unknown BIDS entities (%s) in %s: keeping them, but their "
"placement within the filename might not be BIDS-compliant.",
", ".join(unknown),
out,
)
return out

def __getitem__(self, entity: str) -> Optional[str]:
return self._entities[entity] if entity in self._entities else None
Expand Down
118 changes: 117 additions & 1 deletion 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,79 @@ def update_uncombined_name(
return filename


def update_multiorient_name(
metadata: dict[str, Any],
filename: str,
iops: list[str],
) -> str:
"""
Insert `_chunk-<num>` entity into filename if data are from a sequence
that outputs multiple FoV (localizer, multi-FoV bold)

The index is the position of this file's orientation within ``iops``, which
lists the distinct orientations of the series in the order in which the
files carrying them are converted, i.e. sorted by the name dcm2niix gave
them. Indexing the *orientations* rather than the files means that files
which share an orientation share a chunk, which matters when the series is
additionally split by echo or by magnitude/phase. Taking the order from
the files rather than from the orientation values themselves keeps the
index stable: sorting the orientations would give an order which is both
arbitrary and liable to change between subjects, since a slightly different
obliquity would reshuffle it.

Parameters
----------
metadata : dict
Scan metadata dictionary from BIDS sidecar file.
filename : str
Incoming filename
iops : list of str
The distinct ``ImageOrientationPatientDICOM`` values of the series, as
strings, in the order in which they were encountered.

Returns
-------
filename : str
Updated filename with chunk entity added, if appropriate.
"""
iop = metadata.get("ImageOrientationPatientDICOM")
if iop is None:
lgr.warning(
"Not embedding multi-orientation information into %r: it has no "
"ImageOrientationPatientDICOM while other files of the series do.",
filename,
)
return filename
if str(iop) not in iops: # should not happen: iops comes from this metadata
lgr.warning(
"Not embedding multi-orientation information into %r: its "
"orientation is not among the %d collected for the series.",
filename,
len(iops),
)
return filename
chunk = str(iops.index(str(iop)) + 1)
try:
bids_file = BIDSFile.parse(filename)
if bids_file["chunk"]:
lgr.warning(
"Not embedding multi-orientation information as %r already uses "
"the chunk- entity; falling back to appending an index to the "
"suffix, which is not BIDS-compliant.",
filename,
)
return filename
bids_file["chunk"] = chunk
return str(bids_file)
except ValueError as exc:
# not a name we can take apart (no entities at all, or no sub-);
# leave it to the caller's fallback rather than aborting the conversion
lgr.warning(
"Not embedding multi-orientation information into %r: %s", filename, exc
)
return filename


def convert(
items: list[tuple[str, tuple[str, ...], list[str]]],
converter: str,
Expand Down Expand Up @@ -1029,6 +1103,9 @@ def rename_files() -> None:
echo_times: set[float] = set()
channel_names: set[str] = set()
image_types: set[str] = set()
# the distinct image orientations, kept in the order dcm2niix emitted
# them rather than sorted -- see update_multiorient_name
iops: list[str] = []
for metadata in bids_metas:
if not metadata:
continue
Expand All @@ -1044,6 +1121,13 @@ def rename_files() -> None:
image_types.update(metadata["ImageType"])
except KeyError:
pass
try:
iop = str(metadata["ImageOrientationPatientDICOM"])
except KeyError:
pass
else:
if iop not in iops:
iops.append(iop)

is_multiecho = (
len(set(filter(bool, echo_times))) > 1
Expand All @@ -1054,10 +1138,16 @@ 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

### Loop through the bids_files, set the output name and save files
### Loop through the bids_files and set the output names. We do not
### save anything yet: the renaming below is only of use if it gives
### every file a distinct name, and we cannot tell until we have them
### all (dcm2niix splits a series on more criteria than we handle, so
### e.g. two of the images can well share an orientation).
renamed: list[tuple[str, str, Optional[str]]] = []
for fl, suffix, bids_file, bids_meta in zip(
res_files, suffixes, bids_files, bids_metas
):
Expand All @@ -1084,12 +1174,38 @@ 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:
if this_prefix_basename == prefix_basename:
this_prefix_basename += suffix

renamed.append((fl, this_prefix_basename, bids_file))

# If the renaming did not manage to tell the files apart, we would
# overwrite (or, with overwrite=False, fail on) our own output, so use
# the plain numeric suffix for all of them instead.
basenames = [basename for _, basename, _ in renamed]
if len(set(basenames)) != len(basenames):
lgr.warning(
"Renaming of the %d files converted for %s did not give them "
"unique names; falling back to appending an index to the suffix, "
"which is not BIDS-compliant.",
len(basenames),
prefix_basename,
)
renamed = [
(fl, prefix_basename + suffix, bids_file)
for (fl, _, bids_file), suffix in zip(renamed, suffixes)
]

### Now save the files under the names we settled on
for fl, this_prefix_basename, bids_file in renamed:
# Finally, form the outname by stitching the directory and outtype:
outname = op.join(prefix_dirname, this_prefix_basename)
outfile = outname + "." + outtype
Expand Down
52 changes: 52 additions & 0 deletions heudiconv/heuristics/bids_localizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Heuristic demonstrating conversion of a multi-orientation localizer.

It only cares about converting sequences which have "localizer" in their
series_description and outputs to BIDS.

Note that BIDS has no suffix for localizers/scouts, so the `_localizer`
name used below is *not* BIDS-compliant -- this heuristic exists to
exercise the `chunk-` naming of the multiple orientations dcm2niix
produces for such a series, not to serve as a model for what to do with
localizers (`reproin` converts them to DICOMs only). It also makes no
attempt to tell two localizer series apart, so it is only usable on a
session which has a single one.
"""

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.
Loading
Loading