Skip to content

Commit d689687

Browse files
authored
Merge pull request #817 from UNFmontreal/fix/xa_partial_volumes
[FIX] remove XA partial volumes, generated by dcm2niix as derived data
2 parents a1f35d4 + 3877f13 commit d689687

File tree

4 files changed

+96
-13
lines changed

4 files changed

+96
-13
lines changed

heudiconv/convert.py

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import shutil
1212
import sys
1313
from types import ModuleType
14-
from typing import TYPE_CHECKING, Any, List, Optional, cast
14+
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, cast
1515

1616
import filelock
1717
from nipype import Node
@@ -849,6 +849,64 @@ def nipype_convert(
849849
return eg, prov_file
850850

851851

852+
def filter_partial_volumes(
853+
nii_files: list[str],
854+
bids_files: list[str],
855+
bids_metas: list[dict[str, Any]],
856+
) -> Tuple[list[str] | str, list[str] | str, list[Any] | Any]:
857+
"""filter interrupted 4D scans volumes with missing slices on XA: see dcm2niix #742
858+
859+
Parameters
860+
----------
861+
nii_files : list[str]
862+
converted nifti filepaths
863+
bids_files: list[str]
864+
converted BIDS json filepaths
865+
bids_metas : list[dict[str, Any]]
866+
list of metadata dict loaded from BIDS json files
867+
868+
Returns
869+
-------
870+
nii_files
871+
filtered niftis
872+
bids_files
873+
filtered BIDS jsons
874+
bids_metas
875+
filtered BIDS metadata
876+
877+
"""
878+
# dcm2niix sets metadata "RawImage": false and "SeriesNumber" += 1000 to mark partial volumes
879+
# https://github.com/rordenlab/dcm2niix/blob/f6d7a0018d9d268ed1d084faafdedfadcbbb830b/console/nii_dicom.cpp#L8434-L8437
880+
# following that logic until https://github.com/rordenlab/dcm2niix/issues/972 is addressed
881+
partial_volumes = [
882+
not metadata.get("RawImage", True)
883+
and metadata.get("SeriesNumber", 0) > 1000
884+
and "syngo MR XA" in metadata.get("SoftwareVersions", "")
885+
for metadata in bids_metas
886+
]
887+
no_partial_volumes = not any(partial_volumes) or all(partial_volumes)
888+
889+
if no_partial_volumes:
890+
return nii_files, bids_files, bids_metas
891+
else:
892+
new_nii_files, new_bids_files, new_bids_metas = [], [], []
893+
for fl, bids_file, bids_meta, is_pv in zip(
894+
nii_files, bids_files, bids_metas, partial_volumes
895+
):
896+
if is_pv:
897+
# remove partial volume
898+
os.remove(fl)
899+
os.remove(bids_file)
900+
lgr.warning(f"dropped {fl} partial volume from interrupted series")
901+
else:
902+
new_nii_files.append(fl)
903+
new_bids_files.append(bids_file)
904+
new_bids_metas.append(bids_meta)
905+
if len(new_nii_files) == 1:
906+
return new_nii_files[0], new_bids_files[0], new_bids_metas[0]
907+
return new_nii_files, new_bids_files, new_bids_metas
908+
909+
852910
def save_converted_files(
853911
res: Node,
854912
item_dicoms: list[str],
@@ -885,6 +943,7 @@ def save_converted_files(
885943

886944
bids_outfiles: list[str] = []
887945
res_files = res.outputs.converted_files
946+
bids_files = res.outputs.bids
888947

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

987+
# Also copy BIDS files although they might need to
988+
# be merged/postprocessed later
989+
bids_files = (
990+
sorted(bids_files)
991+
if len(bids_files) == len(res_files)
992+
else [None] * len(res_files)
993+
)
994+
# preload since will be used in multiple spots
995+
bids_metas = [load_json(b) for b in bids_files if b]
996+
997+
res_files, bids_files, bids_metas = filter_partial_volumes(
998+
res_files, bids_files, bids_metas
999+
)
1000+
1001+
if isinstance(res_files, list):
9281002
suffixes = (
9291003
[str(i + 1) for i in range(len(res_files))]
9301004
if (bids_options is not None)
@@ -941,16 +1015,6 @@ def rename_files() -> None:
9411015
)
9421016
suffixes = [str(-i - 1) for i in range(len(res_files))]
9431017

944-
# Also copy BIDS files although they might need to
945-
# be merged/postprocessed later
946-
bids_files = (
947-
sorted(res.outputs.bids)
948-
if len(res.outputs.bids) == len(res_files)
949-
else [None] * len(res_files)
950-
)
951-
# preload since will be used in multiple spots
952-
bids_metas = [load_json(b) for b in bids_files if b]
953-
9541018
### Do we have a multi-echo series? ###
9551019
# Some Siemens sequences (e.g. CMRR's MB-EPI) set the label 'TE1',
9561020
# 'TE2', etc. in the 'ImageType' field. However, other seqs do not
@@ -1041,9 +1105,9 @@ def rename_files() -> None:
10411105
else:
10421106
outname = "{}.{}".format(prefix, outtype)
10431107
safe_movefile(res_files, outname, overwrite)
1044-
if isdefined(res.outputs.bids):
1108+
if bids_files:
10451109
try:
1046-
safe_movefile(res.outputs.bids, outname_bids, overwrite)
1110+
safe_movefile(bids_files, outname_bids, overwrite)
10471111
bids_outfiles.append(outname_bids)
10481112
except TypeError: ##catch lists
10491113
raise TypeError("Multiple BIDS sidecars detected.")
450 KB
Binary file not shown.
376 KB
Binary file not shown.

heudiconv/tests/test_heuristics.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,25 @@ def test_scout_conversion(tmp_path: Path) -> None:
183183
assert j[HEUDICONV_VERSION_JSON_KEY] == __version__
184184

185185

186+
def test_partial_xa_conversion(tmp_path: Path) -> None:
187+
args = [
188+
"-b",
189+
"-f",
190+
"convertall",
191+
"-s",
192+
"test",
193+
"--minmeta",
194+
"--files",
195+
op.join(TESTS_DATA_PATH, "xa_4d_interrupted"),
196+
"-o",
197+
str(tmp_path),
198+
]
199+
runner(args)
200+
n_output_files = len(list(tmp_path.glob("*.nii.gz")))
201+
# partial volumes of interrupted scan should have been removed
202+
assert n_output_files == 1
203+
204+
186205
@pytest.mark.parametrize(
187206
"bidsoptions",
188207
[

0 commit comments

Comments
 (0)