Skip to content

Commit 97b76f3

Browse files
committed
Merge remote-tracking branch 'origin/master' into adds_populate_intended_for
* origin/master: Update test to match new behavior. Drop suffix parameter from update_complex_name(). Remove unused imports. Enhance lists of entities in filename updaters. Use "base_fn" instead of "fn" in tests. Rename suffix to suffix_counter in update_complex_name. Work on fixing issues with multi-file converters. BF: Fix the order of the 'echo' entity in the filename try a simple fix for wrongly ordered files in tar file Use False instead of NaN bc filter wasn't catching NaNs. Convert variables to lists and check for lists in functions. Conflicts: heudiconv/tests/test_convert.py -- just in imports
2 parents 4b23544 + 92eecfe commit 97b76f3

File tree

3 files changed

+162
-53
lines changed

3 files changed

+162
-53
lines changed

heudiconv/convert.py

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
import os
33
import os.path as op
44
import logging
5-
from math import nan
65
import shutil
7-
import sys
86
import random
97
import re
108

@@ -241,7 +239,7 @@ def prep_conversion(sid, dicoms, outdir, heuristic, converter, anon_sid,
241239
getattr(heuristic, 'DEFAULT_FIELDS', {}))
242240

243241

244-
def update_complex_name(metadata, filename, suffix):
242+
def update_complex_name(metadata, filename):
245243
"""
246244
Insert `_part-<mag|phase>` entity into filename if data are from a
247245
sequence with magnitude/phase part.
@@ -252,14 +250,11 @@ def update_complex_name(metadata, filename, suffix):
252250
Scan metadata dictionary from BIDS sidecar file.
253251
filename : str
254252
Incoming filename
255-
suffix : str
256-
An index used for cases where a single scan produces multiple files,
257-
but the differences between those files are unknown.
258253
259254
Returns
260255
-------
261256
filename : str
262-
Updated filename with rec entity added in appropriate position.
257+
Updated filename with part entity added in appropriate position.
263258
"""
264259
# Some scans separate magnitude/phase differently
265260
# A small note: _phase is deprecated, but this may add part-mag to
@@ -277,12 +272,12 @@ def update_complex_name(metadata, filename, suffix):
277272
elif 'P' in metadata.get('ImageType'):
278273
mag_or_phase = 'phase'
279274
else:
280-
mag_or_phase = suffix
275+
raise RuntimeError("Data type could not be inferred from the metadata.")
281276

282277
# Determine scan suffix
283278
filetype = '_' + filename.split('_')[-1]
284279

285-
# Insert rec label
280+
# Insert part label
286281
if not ('_part-%s' % mag_or_phase) in filename:
287282
# If "_part-" is specified, prepend the 'mag_or_phase' value.
288283
if '_part-' in filename:
@@ -292,7 +287,21 @@ def update_complex_name(metadata, filename, suffix):
292287
)
293288

294289
# Insert it **before** the following string(s), whichever appears first.
295-
for label in ['_recording', '_proc', '_space', filetype]:
290+
# https://bids-specification.readthedocs.io/en/stable/99-appendices/09-entities.html
291+
entities_after_part = [
292+
"_proc",
293+
"_hemi",
294+
"_space",
295+
"_split",
296+
"_recording",
297+
"_chunk",
298+
"_res",
299+
"_den",
300+
"_label",
301+
"_desc",
302+
filetype,
303+
]
304+
for label in entities_after_part:
296305
if (label == filetype) or (label in filename):
297306
filename = filename.replace(
298307
label, "_part-%s%s" % (mag_or_phase, label)
@@ -322,25 +331,52 @@ def update_multiecho_name(metadata, filename, echo_times):
322331
filename : str
323332
Updated filename with echo entity added, if appropriate.
324333
"""
325-
# Field maps separate echoes differently
334+
# Field maps separate echoes differently, so do not attempt to update any filenames with these
335+
# suffixes
326336
unsupported_types = [
327337
'_magnitude', '_magnitude1', '_magnitude2',
328338
'_phasediff', '_phase1', '_phase2', '_fieldmap'
329339
]
330340
if any(ut in filename for ut in unsupported_types):
331341
return filename
332342

333-
# Get the EchoNumber from json file info. If not present, use EchoTime
343+
if not isinstance(echo_times, list):
344+
raise TypeError(f'Argument "echo_times" must be a list, not a {type(echo_times)}')
345+
346+
# Get the EchoNumber from json file info. If not present, use EchoTime.
334347
if 'EchoNumber' in metadata.keys():
335348
echo_number = metadata['EchoNumber']
336-
else:
349+
elif 'EchoTime' in metadata.keys():
337350
echo_number = echo_times.index(metadata['EchoTime']) + 1
351+
else:
352+
raise KeyError(
353+
'Either "EchoNumber" or "EchoTime" must be in metadata keys. '
354+
f'Keys detected: {metadata.keys()}'
355+
)
338356

339357
# Determine scan suffix
340358
filetype = '_' + filename.split('_')[-1]
341359

342360
# Insert it **before** the following string(s), whichever appears first.
343-
for label in ['_recording', '_proc', '_space', filetype]:
361+
# https://bids-specification.readthedocs.io/en/stable/99-appendices/09-entities.html
362+
entities_after_echo = [
363+
"_flip",
364+
"_inv",
365+
"_mt",
366+
"_part",
367+
"_proc",
368+
"_hemi",
369+
"_space",
370+
"_split",
371+
"_recording",
372+
"_chunk",
373+
"_res",
374+
"_den",
375+
"_label",
376+
"_desc",
377+
filetype,
378+
]
379+
for label in entities_after_echo:
344380
if (label == filetype) or (label in filename):
345381
filename = filename.replace(
346382
label, "_echo-%s%s" % (echo_number, label)
@@ -375,6 +411,9 @@ def update_uncombined_name(metadata, filename, channel_names):
375411
if any(ut in filename for ut in unsupported_types):
376412
return filename
377413

414+
if not isinstance(channel_names, list):
415+
raise TypeError(f'Argument "channel_names" must be a list, not a {type(channel_names)}')
416+
378417
# Determine the channel number
379418
channel_number = ''.join([c for c in metadata['CoilString'] if c.isdigit()])
380419
if not channel_number:
@@ -386,7 +425,21 @@ def update_uncombined_name(metadata, filename, channel_names):
386425

387426
# Insert it **before** the following string(s), whichever appears first.
388427
# Choosing to put channel near the end since it's not in the specification yet.
389-
for label in ['_recording', '_proc', '_space', filetype]:
428+
# See https://bids-specification.readthedocs.io/en/stable/99-appendices/09-entities.html
429+
entities_after_ch = [
430+
"_proc",
431+
"_hemi",
432+
"_space",
433+
"_split",
434+
"_recording",
435+
"_chunk",
436+
"_res",
437+
"_den",
438+
"_label",
439+
"_desc",
440+
filetype,
441+
]
442+
for label in entities_after_ch:
390443
if (label == filetype) or (label in filename):
391444
filename = filename.replace(
392445
label, "_ch-%s%s" % (channel_number, label)
@@ -756,12 +809,17 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
756809
for metadata in bids_metas:
757810
if not metadata:
758811
continue
759-
echo_times.add(metadata.get('EchoTime', nan))
760-
channel_names.add(metadata.get('CoilString', nan))
761-
image_types.update(metadata.get('ImageType', [nan]))
812+
813+
# If the field is not available, fill that entry in the set with a False.
814+
echo_times.add(metadata.get('EchoTime', False))
815+
channel_names.add(metadata.get('CoilString', False))
816+
image_types.update(metadata.get('ImageType', [False]))
817+
762818
is_multiecho = len(set(filter(bool, echo_times))) > 1 # Check for varying echo times
763819
is_uncombined = len(set(filter(bool, channel_names))) > 1 # Check for uncombined data
764820
is_complex = 'M' in image_types and 'P' in image_types # Determine if data are complex (magnitude + phase)
821+
echo_times = sorted(echo_times) # also converts to list
822+
channel_names = sorted(channel_names) # also converts to list
765823

766824
### Loop through the bids_files, set the output name and save files
767825
for fl, suffix, bids_file, bids_meta in zip(res_files, suffixes, bids_files, bids_metas):
@@ -781,7 +839,7 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
781839

782840
if is_complex:
783841
this_prefix_basename = update_complex_name(
784-
bids_meta, this_prefix_basename, suffix
842+
bids_meta, this_prefix_basename
785843
)
786844

787845
if is_uncombined:

heudiconv/parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def get_extracted_dicoms(fl):
9797
for tm in tmembers:
9898
tm.mode = 0o700
9999
# get all files, assemble full path in tmp dir
100-
tf_content = [m.name for m in tmembers if m.isfile()]
100+
tf_content = sorted([m.name for m in tmembers if m.isfile()])
101101
# store full paths to each file, so we don't need to drag along
102102
# tmpdir as some basedir
103103
sessions[session] = [op.join(tmpdir, f) for f in tf_content]

heudiconv/tests/test_convert.py

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,88 +4,139 @@
44
from glob import glob
55

66
import pytest
7-
from .utils import TESTS_DATA_PATH
8-
9-
from heudiconv.convert import (update_complex_name,
10-
update_multiecho_name,
11-
update_uncombined_name,
12-
DW_IMAGE_IN_FMAP_FOLDER_WARNING,
13-
)
147
import heudiconv.convert
158
from heudiconv.bids import BIDSError
169
from heudiconv.utils import load_heuristic
1710
from heudiconv.cli.run import main as runner
11+
from heudiconv.convert import (
12+
DW_IMAGE_IN_FMAP_FOLDER_WARNING,
13+
update_complex_name,
14+
update_multiecho_name,
15+
update_uncombined_name,
16+
)
17+
18+
from .utils import TESTS_DATA_PATH
1819

1920

2021
def test_update_complex_name():
2122
"""Unit testing for heudiconv.convert.update_complex_name(), which updates
2223
filenames with the part field if appropriate.
2324
"""
2425
# Standard name update
25-
fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
26+
base_fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
2627
metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'P', 'MB', 'TE3', 'ND', 'MOSAIC']}
27-
suffix = 3
2828
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_part-phase_sbref'
29-
out_fn_test = update_complex_name(metadata, fn, suffix)
29+
out_fn_test = update_complex_name(metadata, base_fn)
3030
assert out_fn_test == out_fn_true
31+
3132
# Catch an unsupported type and *do not* update
32-
fn = 'sub-X_ses-Y_task-Z_run-01_phase'
33-
out_fn_test = update_complex_name(metadata, fn, suffix)
34-
assert out_fn_test == fn
35-
# Data type is missing from metadata so use suffix
36-
fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
33+
base_fn = 'sub-X_ses-Y_task-Z_run-01_phase'
34+
out_fn_test = update_complex_name(metadata, base_fn)
35+
assert out_fn_test == base_fn
36+
37+
# Data type is missing from metadata so raise a RuntimeError
38+
base_fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
3739
metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'MB', 'TE3', 'ND', 'MOSAIC']}
38-
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_part-3_sbref'
39-
out_fn_test = update_complex_name(metadata, fn, suffix)
40-
assert out_fn_test == out_fn_true
41-
# Catch existing field with value that *does not match* metadata
42-
# and raise Exception
43-
fn = 'sub-X_ses-Y_task-Z_run-01_part-mag_sbref'
40+
with pytest.raises(RuntimeError):
41+
update_complex_name(metadata, base_fn)
42+
43+
# Catch existing field with value (part is already in the filename)
44+
# that *does not match* metadata and raise Exception
45+
base_fn = 'sub-X_ses-Y_task-Z_run-01_part-mag_sbref'
4446
metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'P', 'MB', 'TE3', 'ND', 'MOSAIC']}
45-
suffix = 3
4647
with pytest.raises(BIDSError):
47-
assert update_complex_name(metadata, fn, suffix)
48+
update_complex_name(metadata, base_fn)
49+
50+
# Catch existing field with value (part is already in the filename)
51+
# that *does match* metadata and do not update
52+
base_fn = 'sub-X_ses-Y_task-Z_run-01_part-phase_sbref'
53+
metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'P', 'MB', 'TE3', 'ND', 'MOSAIC']}
54+
out_fn_test = update_complex_name(metadata, base_fn)
55+
assert out_fn_test == base_fn
4856

4957

5058
def test_update_multiecho_name():
5159
"""Unit testing for heudiconv.convert.update_multiecho_name(), which updates
5260
filenames with the echo field if appropriate.
5361
"""
5462
# Standard name update
55-
fn = 'sub-X_ses-Y_task-Z_run-01_bold'
63+
base_fn = 'sub-X_ses-Y_task-Z_run-01_bold'
5664
metadata = {'EchoTime': 0.01,
5765
'EchoNumber': 1}
5866
echo_times = [0.01, 0.02, 0.03]
5967
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_echo-1_bold'
60-
out_fn_test = update_multiecho_name(metadata, fn, echo_times)
68+
out_fn_test = update_multiecho_name(metadata, base_fn, echo_times)
6169
assert out_fn_test == out_fn_true
70+
6271
# EchoNumber field is missing from metadata, so use echo_times
6372
metadata = {'EchoTime': 0.01}
64-
out_fn_test = update_multiecho_name(metadata, fn, echo_times)
73+
out_fn_test = update_multiecho_name(metadata, base_fn, echo_times)
6574
assert out_fn_test == out_fn_true
75+
6676
# Catch an unsupported type and *do not* update
67-
fn = 'sub-X_ses-Y_task-Z_run-01_phasediff'
68-
out_fn_test = update_multiecho_name(metadata, fn, echo_times)
69-
assert out_fn_test == fn
77+
base_fn = 'sub-X_ses-Y_task-Z_run-01_phasediff'
78+
out_fn_test = update_multiecho_name(metadata, base_fn, echo_times)
79+
assert out_fn_test == base_fn
80+
81+
# EchoTime is missing, but use EchoNumber (which is the first thing it checks)
82+
base_fn = 'sub-X_ses-Y_task-Z_run-01_bold'
83+
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_echo-1_bold'
84+
metadata = {'EchoNumber': 1}
85+
echo_times = [False, 0.02, 0.03]
86+
out_fn_test = update_multiecho_name(metadata, base_fn, echo_times)
87+
assert out_fn_test == out_fn_true
88+
89+
# Both EchoTime and EchoNumber are missing, which raises a KeyError
90+
base_fn = 'sub-X_ses-Y_task-Z_run-01_bold'
91+
metadata = {}
92+
echo_times = [False, 0.02, 0.03]
93+
with pytest.raises(KeyError):
94+
update_multiecho_name(metadata, base_fn, echo_times)
95+
96+
# Providing echo times as something other than a list should raise a TypeError
97+
base_fn = 'sub-X_ses-Y_task-Z_run-01_bold'
98+
with pytest.raises(TypeError):
99+
update_multiecho_name(metadata, base_fn, set(echo_times))
70100

71101

72102
def test_update_uncombined_name():
73103
"""Unit testing for heudiconv.convert.update_uncombined_name(), which updates
74104
filenames with the ch field if appropriate.
75105
"""
76106
# Standard name update
77-
fn = 'sub-X_ses-Y_task-Z_run-01_bold'
107+
base_fn = 'sub-X_ses-Y_task-Z_run-01_bold'
78108
metadata = {'CoilString': 'H1'}
79109
channel_names = ['H1', 'H2', 'H3', 'HEA;HEP']
80110
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-01_bold'
81-
out_fn_test = update_uncombined_name(metadata, fn, channel_names)
111+
out_fn_test = update_uncombined_name(metadata, base_fn, channel_names)
82112
assert out_fn_test == out_fn_true
83-
# CoilString field has no number in it
113+
114+
# CoilString field has no number in it, so we index the channel_names list
84115
metadata = {'CoilString': 'HEA;HEP'}
85116
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-04_bold'
86-
out_fn_test = update_uncombined_name(metadata, fn, channel_names)
117+
out_fn_test = update_uncombined_name(metadata, base_fn, channel_names)
87118
assert out_fn_test == out_fn_true
88119

120+
# Extract the number from the CoilString and use that
121+
channel_names = ['H1', 'B1', 'H3', 'HEA;HEP']
122+
metadata = {'CoilString': 'H1'}
123+
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-01_bold'
124+
out_fn_test = update_uncombined_name(metadata, base_fn, channel_names)
125+
assert out_fn_test == out_fn_true
126+
127+
# NOTE: Extracting the number does not protect against multiple coils with the same number
128+
# (but, say, different letters)
129+
# Note that this is still "ch-01"
130+
metadata = {'CoilString': 'B1'}
131+
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-01_bold'
132+
out_fn_test = update_uncombined_name(metadata, base_fn, channel_names)
133+
assert out_fn_test == out_fn_true
134+
135+
# Providing echo times as something other than a list should raise a TypeError
136+
base_fn = 'sub-X_ses-Y_task-Z_run-01_bold'
137+
with pytest.raises(TypeError):
138+
update_uncombined_name(metadata, base_fn, set(channel_names))
139+
89140

90141
def test_b0dwi_for_fmap(tmpdir, caplog):
91142
"""Make sure we raise a warning when .bvec and .bval files

0 commit comments

Comments
 (0)