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
82 changes: 69 additions & 13 deletions heudiconv/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import shutil
import sys
from types import ModuleType
from typing import TYPE_CHECKING, Any, List, Optional, cast
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, cast

import filelock
from nipype import Node
Expand Down Expand Up @@ -849,6 +849,56 @@ def nipype_convert(
return eg, prov_file


def filter_partial_volumes(
nii_files: list[str],
bids_files: list[str],
bids_metas: list[dict[str, Any]],
) -> Tuple[list[str] | str, list[str] | str, list[Any] | Any]:
"""filter interrupted 4D scans volumes with missing slices on XA: see dcm2niix #742

Parameters
----------
nii_files : list[str]
converted nifti filepaths
bids_files: list[str]
converted BIDS json filepaths
bids_metas : list[dict[str, Any]]
list of metadata dict loaded from BIDS json files

Returns
-------
nii_files
filtered niftis
bids_files
filtered BIDS jsons
bids_metas
filtered BIDS metadata

"""
partial_volumes = [not metadata.get("RawImage", True) for metadata in bids_metas]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, a partial volume is iff RawImage = False? i.e. is every volume with RawImage = False is a partial volume?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the way that it's implemented in dcm2niix for now, but as mentionned above I am not sure if that BIDS tag is used for other cases such as true derived data.

no_partial_volumes = not any(partial_volumes) or all(partial_volumes)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, my had hurts a little trying to do this bool and need for that or. Wouldn't it make it easier to talk in terms of

Suggested change
no_partial_volumes = not any(partial_volumes) or all(partial_volumes)
has_partial_volumes = any(partial_volumes)

? (and then flip if below) or it wouldn't be sufficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if "RawImage":False is also present for other derived data, so I tried to narrow down the case where a series produced niftis with both False and not present (or True if ever).
There might still be derived data that we want to convert.


if no_partial_volumes:
return nii_files, bids_files, bids_metas
else:
new_nii_files, new_bids_files, new_bids_metas = [], [], []
for fl, bids_file, bids_meta, is_pv in zip(
nii_files, bids_files, bids_metas, partial_volumes
):
if is_pv:
# remove partial volume
os.remove(fl)
os.remove(bids_file)
lgr.warning(f"dropped {fl} partial volume from interrupted series")
else:
new_nii_files.append(fl)
new_bids_files.append(bids_file)
new_bids_metas.append(bids_meta)
if len(new_nii_files) == 1:
return new_nii_files[0], new_bids_files[0], new_bids_metas[0]
return new_nii_files, new_bids_files, new_bids_metas


def save_converted_files(
res: Node,
item_dicoms: list[str],
Expand Down Expand Up @@ -885,6 +935,7 @@ def save_converted_files(

bids_outfiles: list[str] = []
res_files = res.outputs.converted_files
bids_files = res.outputs.bids

if not len(res_files):
lgr.debug("DICOMs {} were not converted".format(item_dicoms))
Expand Down Expand Up @@ -925,6 +976,21 @@ def rename_files() -> None:
# we should provide specific handling for fmap,
# dwi etc which might spit out multiple files

# Also copy BIDS files although they might need to
# be merged/postprocessed later
bids_files = (
sorted(bids_files)
if len(bids_files) == len(res_files)
else [None] * len(res_files)
)
# preload since will be used in multiple spots
bids_metas = [load_json(b) for b in bids_files if b]

res_files, bids_files, bids_metas = filter_partial_volumes(
res_files, bids_files, bids_metas
)

if isinstance(res_files, list):
suffixes = (
[str(i + 1) for i in range(len(res_files))]
if (bids_options is not None)
Expand All @@ -941,16 +1007,6 @@ def rename_files() -> None:
)
suffixes = [str(-i - 1) for i in range(len(res_files))]

# Also copy BIDS files although they might need to
# be merged/postprocessed later
bids_files = (
sorted(res.outputs.bids)
if len(res.outputs.bids) == len(res_files)
else [None] * len(res_files)
)
# preload since will be used in multiple spots
bids_metas = [load_json(b) for b in bids_files if b]

### Do we have a multi-echo series? ###
# Some Siemens sequences (e.g. CMRR's MB-EPI) set the label 'TE1',
# 'TE2', etc. in the 'ImageType' field. However, other seqs do not
Expand Down Expand Up @@ -1041,9 +1097,9 @@ def rename_files() -> None:
else:
outname = "{}.{}".format(prefix, outtype)
safe_movefile(res_files, outname, overwrite)
if isdefined(res.outputs.bids):
if bids_files:
try:
safe_movefile(res.outputs.bids, outname_bids, overwrite)
safe_movefile(bids_files, outname_bids, overwrite)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would need to double-check on what is the effect here and why e.g. we did not use bids_files to start with here, or why checking isdefined for res.output.bids and not bids_files to be defined...

bids_outfiles.append(outname_bids)
except TypeError: ##catch lists
raise TypeError("Multiple BIDS sidecars detected.")
Expand Down
Binary file not shown.
Binary file not shown.
19 changes: 19 additions & 0 deletions heudiconv/tests/test_heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,25 @@ def test_scout_conversion(tmp_path: Path) -> None:
assert j[HEUDICONV_VERSION_JSON_KEY] == __version__


def test_partial_xa_conversion(tmp_path: Path) -> None:
args = [
"-b",
"-f",
"convertall",
"-s",
"test",
"--minmeta",
"--files",
op.join(TESTS_DATA_PATH, "xa_4d_interrupted"),
"-o",
str(tmp_path),
]
runner(args)
n_output_files = len(list(tmp_path.glob("*.nii.gz")))
# partial volumes of interrupted scan should have been removed
assert n_output_files == 1


@pytest.mark.parametrize(
"bidsoptions",
[
Expand Down