Skip to content

Commit 92eecfe

Browse files
authored
Merge pull request #461 from tsalo/fix/updater-lists
FIX: Convert sets to lists for filename updaters
2 parents 3f9a504 + 0b8e42c commit 92eecfe

File tree

2 files changed

+161
-53
lines changed

2 files changed

+161
-53
lines changed

heudiconv/convert.py

Lines changed: 77 additions & 20 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

@@ -239,7 +237,7 @@ def prep_conversion(sid, dicoms, outdir, heuristic, converter, anon_sid,
239237
getattr(heuristic, 'DEFAULT_FIELDS', {}))
240238

241239

242-
def update_complex_name(metadata, filename, suffix):
240+
def update_complex_name(metadata, filename):
243241
"""
244242
Insert `_part-<mag|phase>` entity into filename if data are from a
245243
sequence with magnitude/phase part.
@@ -250,14 +248,11 @@ def update_complex_name(metadata, filename, suffix):
250248
Scan metadata dictionary from BIDS sidecar file.
251249
filename : str
252250
Incoming filename
253-
suffix : str
254-
An index used for cases where a single scan produces multiple files,
255-
but the differences between those files are unknown.
256251
257252
Returns
258253
-------
259254
filename : str
260-
Updated filename with rec entity added in appropriate position.
255+
Updated filename with part entity added in appropriate position.
261256
"""
262257
# Some scans separate magnitude/phase differently
263258
# A small note: _phase is deprecated, but this may add part-mag to
@@ -275,12 +270,12 @@ def update_complex_name(metadata, filename, suffix):
275270
elif 'P' in metadata.get('ImageType'):
276271
mag_or_phase = 'phase'
277272
else:
278-
mag_or_phase = suffix
273+
raise RuntimeError("Data type could not be inferred from the metadata.")
279274

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

283-
# Insert rec label
278+
# Insert part label
284279
if not ('_part-%s' % mag_or_phase) in filename:
285280
# If "_part-" is specified, prepend the 'mag_or_phase' value.
286281
if '_part-' in filename:
@@ -290,7 +285,21 @@ def update_complex_name(metadata, filename, suffix):
290285
)
291286

292287
# Insert it **before** the following string(s), whichever appears first.
293-
for label in ['_recording', '_proc', '_space', filetype]:
288+
# https://bids-specification.readthedocs.io/en/stable/99-appendices/09-entities.html
289+
entities_after_part = [
290+
"_proc",
291+
"_hemi",
292+
"_space",
293+
"_split",
294+
"_recording",
295+
"_chunk",
296+
"_res",
297+
"_den",
298+
"_label",
299+
"_desc",
300+
filetype,
301+
]
302+
for label in entities_after_part:
294303
if (label == filetype) or (label in filename):
295304
filename = filename.replace(
296305
label, "_part-%s%s" % (mag_or_phase, label)
@@ -320,26 +329,52 @@ def update_multiecho_name(metadata, filename, echo_times):
320329
filename : str
321330
Updated filename with echo entity added, if appropriate.
322331
"""
323-
# Field maps separate echoes differently
332+
# Field maps separate echoes differently, so do not attempt to update any filenames with these
333+
# suffixes
324334
unsupported_types = [
325335
'_magnitude', '_magnitude1', '_magnitude2',
326336
'_phasediff', '_phase1', '_phase2', '_fieldmap'
327337
]
328338
if any(ut in filename for ut in unsupported_types):
329339
return filename
330340

331-
# Get the EchoNumber from json file info. If not present, use EchoTime
341+
if not isinstance(echo_times, list):
342+
raise TypeError(f'Argument "echo_times" must be a list, not a {type(echo_times)}')
343+
344+
# Get the EchoNumber from json file info. If not present, use EchoTime.
332345
if 'EchoNumber' in metadata.keys():
333346
echo_number = metadata['EchoNumber']
334-
else:
347+
elif 'EchoTime' in metadata.keys():
335348
echo_number = echo_times.index(metadata['EchoTime']) + 1
349+
else:
350+
raise KeyError(
351+
'Either "EchoNumber" or "EchoTime" must be in metadata keys. '
352+
f'Keys detected: {metadata.keys()}'
353+
)
336354

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

340358
# Insert it **before** the following string(s), whichever appears first.
341-
# https://bids-specification.readthedocs.io/en/stable/99-appendices/04-entity-table.html
342-
for label in ['_flip', '_inv', '_mt', '_part', '_recording', '_proc', '_space', filetype]:
359+
# https://bids-specification.readthedocs.io/en/stable/99-appendices/09-entities.html
360+
entities_after_echo = [
361+
"_flip",
362+
"_inv",
363+
"_mt",
364+
"_part",
365+
"_proc",
366+
"_hemi",
367+
"_space",
368+
"_split",
369+
"_recording",
370+
"_chunk",
371+
"_res",
372+
"_den",
373+
"_label",
374+
"_desc",
375+
filetype,
376+
]
377+
for label in entities_after_echo:
343378
if (label == filetype) or (label in filename):
344379
filename = filename.replace(
345380
label, "_echo-%s%s" % (echo_number, label)
@@ -374,6 +409,9 @@ def update_uncombined_name(metadata, filename, channel_names):
374409
if any(ut in filename for ut in unsupported_types):
375410
return filename
376411

412+
if not isinstance(channel_names, list):
413+
raise TypeError(f'Argument "channel_names" must be a list, not a {type(channel_names)}')
414+
377415
# Determine the channel number
378416
channel_number = ''.join([c for c in metadata['CoilString'] if c.isdigit()])
379417
if not channel_number:
@@ -385,7 +423,21 @@ def update_uncombined_name(metadata, filename, channel_names):
385423

386424
# Insert it **before** the following string(s), whichever appears first.
387425
# Choosing to put channel near the end since it's not in the specification yet.
388-
for label in ['_recording', '_proc', '_space', filetype]:
426+
# See https://bids-specification.readthedocs.io/en/stable/99-appendices/09-entities.html
427+
entities_after_ch = [
428+
"_proc",
429+
"_hemi",
430+
"_space",
431+
"_split",
432+
"_recording",
433+
"_chunk",
434+
"_res",
435+
"_den",
436+
"_label",
437+
"_desc",
438+
filetype,
439+
]
440+
for label in entities_after_ch:
389441
if (label == filetype) or (label in filename):
390442
filename = filename.replace(
391443
label, "_ch-%s%s" % (channel_number, label)
@@ -731,12 +783,17 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
731783
for metadata in bids_metas:
732784
if not metadata:
733785
continue
734-
echo_times.add(metadata.get('EchoTime', nan))
735-
channel_names.add(metadata.get('CoilString', nan))
736-
image_types.update(metadata.get('ImageType', [nan]))
786+
787+
# If the field is not available, fill that entry in the set with a False.
788+
echo_times.add(metadata.get('EchoTime', False))
789+
channel_names.add(metadata.get('CoilString', False))
790+
image_types.update(metadata.get('ImageType', [False]))
791+
737792
is_multiecho = len(set(filter(bool, echo_times))) > 1 # Check for varying echo times
738793
is_uncombined = len(set(filter(bool, channel_names))) > 1 # Check for uncombined data
739794
is_complex = 'M' in image_types and 'P' in image_types # Determine if data are complex (magnitude + phase)
795+
echo_times = sorted(echo_times) # also converts to list
796+
channel_names = sorted(channel_names) # also converts to list
740797

741798
### Loop through the bids_files, set the output name and save files
742799
for fl, suffix, bids_file, bids_meta in zip(res_files, suffixes, bids_files, bids_metas):
@@ -756,7 +813,7 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
756813

757814
if is_complex:
758815
this_prefix_basename = update_complex_name(
759-
bids_meta, this_prefix_basename, suffix
816+
bids_meta, this_prefix_basename
760817
)
761818

762819
if is_uncombined:

heudiconv/tests/test_convert.py

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,86 +4,137 @@
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
from heudiconv.bids import BIDSError
158
from heudiconv.cli.run import main as runner
9+
from heudiconv.convert import (
10+
DW_IMAGE_IN_FMAP_FOLDER_WARNING,
11+
update_complex_name,
12+
update_multiecho_name,
13+
update_uncombined_name,
14+
)
15+
16+
from .utils import TESTS_DATA_PATH
1617

1718

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

4755

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

6999

70100
def test_update_uncombined_name():
71101
"""Unit testing for heudiconv.convert.update_uncombined_name(), which updates
72102
filenames with the ch field if appropriate.
73103
"""
74104
# Standard name update
75-
fn = 'sub-X_ses-Y_task-Z_run-01_bold'
105+
base_fn = 'sub-X_ses-Y_task-Z_run-01_bold'
76106
metadata = {'CoilString': 'H1'}
77107
channel_names = ['H1', 'H2', 'H3', 'HEA;HEP']
78108
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-01_bold'
79-
out_fn_test = update_uncombined_name(metadata, fn, channel_names)
109+
out_fn_test = update_uncombined_name(metadata, base_fn, channel_names)
80110
assert out_fn_test == out_fn_true
81-
# CoilString field has no number in it
111+
112+
# CoilString field has no number in it, so we index the channel_names list
82113
metadata = {'CoilString': 'HEA;HEP'}
83114
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-04_bold'
84-
out_fn_test = update_uncombined_name(metadata, fn, channel_names)
115+
out_fn_test = update_uncombined_name(metadata, base_fn, channel_names)
85116
assert out_fn_test == out_fn_true
86117

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

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

0 commit comments

Comments
 (0)