diff --git a/heudiconv/convert.py b/heudiconv/convert.py index 8fc4f100..80fcaa61 100644 --- a/heudiconv/convert.py +++ b/heudiconv/convert.py @@ -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 @@ -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] + no_partial_volumes = not any(partial_volumes) or all(partial_volumes) + + 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], @@ -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)) @@ -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) @@ -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 @@ -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) bids_outfiles.append(outname_bids) except TypeError: ##catch lists raise TypeError("Multiple BIDS sidecars detected.") diff --git a/heudiconv/tests/data/xa_4d_interrupted/MRe.1.3.12.2.1107.5.2.43.167006.2025031213310724946301923 b/heudiconv/tests/data/xa_4d_interrupted/MRe.1.3.12.2.1107.5.2.43.167006.2025031213310724946301923 new file mode 100644 index 00000000..2b687cfc Binary files /dev/null and b/heudiconv/tests/data/xa_4d_interrupted/MRe.1.3.12.2.1107.5.2.43.167006.2025031213310724946301923 differ diff --git a/heudiconv/tests/data/xa_4d_interrupted/MRe.1.3.12.2.1107.5.2.43.167006.2025031213314325419905353 b/heudiconv/tests/data/xa_4d_interrupted/MRe.1.3.12.2.1107.5.2.43.167006.2025031213314325419905353 new file mode 100644 index 00000000..5581dd05 Binary files /dev/null and b/heudiconv/tests/data/xa_4d_interrupted/MRe.1.3.12.2.1107.5.2.43.167006.2025031213314325419905353 differ diff --git a/heudiconv/tests/test_heuristics.py b/heudiconv/tests/test_heuristics.py index 7f424108..00f1131d 100644 --- a/heudiconv/tests/test_heuristics.py +++ b/heudiconv/tests/test_heuristics.py @@ -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", [