Skip to content

Commit 756fa0a

Browse files
committed
Address @yarikoptic's review.
- Some refactoring of name-updaters. - Add tests for name-updaters.
1 parent b640c16 commit 756fa0a

File tree

2 files changed

+183
-86
lines changed

2 files changed

+183
-86
lines changed

heudiconv/convert.py

Lines changed: 105 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -233,65 +233,91 @@ def prep_conversion(sid, dicoms, outdir, heuristic, converter, anon_sid,
233233
getattr(heuristic, 'DEFAULT_FIELDS', {}))
234234

235235

236-
def update_complex_name(metadata, this_prefix_basename, suffix):
236+
def update_complex_name(metadata, filename, suffix):
237237
"""
238-
Insert `_part-<magnitude|phase>` entity into filename if data are from a sequence
239-
with magnitude/phase part.
238+
Insert `_rec-<magnitude|phase>` entity into filename if data are from a
239+
sequence with magnitude/phase part.
240+
241+
Parameters
242+
----------
243+
metadata : dict
244+
Scan metadata dictionary from BIDS sidecar file.
245+
filename : str
246+
Incoming filename
247+
suffix : str
248+
An index used for cases where a single scan produces multiple files,
249+
but the differences between those files are unknown.
250+
251+
Returns
252+
-------
253+
filename : str
254+
Updated filename with rec entity added in appropriate position.
240255
"""
241-
# Functional scans separate magnitude/phase differently
242-
unsupported_types = ['_bold', '_phase']
243-
if any(ut in this_prefix_basename for ut in unsupported_types):
244-
return this_prefix_basename
256+
# Some scans separate magnitude/phase differently
257+
unsupported_types = ['_bold', '_phase',
258+
'_magnitude', '_magnitude1', '_magnitude2',
259+
'_phasediff', '_phase1', '_phase2']
260+
if any(ut in filename for ut in unsupported_types):
261+
return filename
245262

246263
# Check to see if it is magnitude or phase part:
247264
if 'M' in metadata.get('ImageType'):
248-
mag_or_phase = 'mag'
265+
mag_or_phase = 'magnitude'
249266
elif 'P' in metadata.get('ImageType'):
250267
mag_or_phase = 'phase'
251268
else:
252269
mag_or_phase = suffix
253270

254271
# Determine scan suffix
255-
filetype = '_' + this_prefix_basename.split('_')[-1]
272+
filetype = '_' + filename.split('_')[-1]
256273

257-
# Insert part label
258-
if not ('_part-%s' % mag_or_phase) in this_prefix_basename:
259-
# If "_part-" is specified, prepend the 'mag_or_phase' value.
260-
if '_part-' in this_prefix_basename:
274+
# Insert rec label
275+
if not ('_rec-%s' % mag_or_phase) in filename:
276+
# If "_rec-" is specified, prepend the 'mag_or_phase' value.
277+
if '_rec-' in filename:
261278
raise BIDSError(
262-
"Part label for images will be automatically set, remove "
263-
"from heuristic"
279+
"Reconstruction label for images will be automatically set, "
280+
"remove from heuristic"
264281
)
265282

266-
# If not, insert "_part-" + 'mag_or_phase' into the prefix_basename
267-
# **before** "_run", "_echo" or "_sbref", whichever appears first:
283+
# Insert it **before** the following string(s), whichever appears first.
268284
for label in ['_dir', '_run', '_mod', '_echo', '_recording', '_proc', '_space', filetype]:
269-
if label == filetype:
270-
this_prefix_basename = this_prefix_basename.replace(
271-
filetype, "_part-%s%s" % (mag_or_phase, filetype)
272-
)
273-
break
274-
elif (label in this_prefix_basename):
275-
this_prefix_basename = this_prefix_basename.replace(
276-
label, "_part-%s%s" % (mag_or_phase, label)
285+
if (label == filetype) or (label in filename):
286+
filename = filename.replace(
287+
label, "_rec-%s%s" % (mag_or_phase, label)
277288
)
278289
break
279290

280-
return this_prefix_basename
291+
return filename
281292

282293

283-
def update_multiecho_name(metadata, this_prefix_basename, echo_times):
294+
def update_multiecho_name(metadata, filename, echo_times):
284295
"""
285296
Insert `_echo-<num>` entity into filename if data are from a multi-echo
286297
sequence.
298+
299+
Parameters
300+
----------
301+
metadata : dict
302+
Scan metadata dictionary from BIDS sidecar file.
303+
filename : str
304+
Incoming filename
305+
echo_times : list
306+
List of all echo times from scan. Used to determine the echo *number*
307+
(i.e., index) if field is missing from metadata.
308+
309+
Returns
310+
-------
311+
filename : str
312+
Updated filename with echo entity added, if appropriate.
287313
"""
288314
# Field maps separate echoes differently
289315
unsupported_types = [
290316
'_magnitude', '_magnitude1', '_magnitude2',
291317
'_phasediff', '_phase1', '_phase2', '_fieldmap'
292318
]
293-
if any(ut in this_prefix_basename for ut in unsupported_types):
294-
return this_prefix_basename
319+
if any(ut in filename for ut in unsupported_types):
320+
return filename
295321

296322
# Get the EchoNumber from json file info. If not present, use EchoTime
297323
if 'EchoNumber' in metadata.keys():
@@ -300,57 +326,62 @@ def update_multiecho_name(metadata, this_prefix_basename, echo_times):
300326
echo_number = echo_times.index(metadata['EchoTime']) + 1
301327

302328
# Determine scan suffix
303-
filetype = '_' + this_prefix_basename.split('_')[-1]
329+
filetype = '_' + filename.split('_')[-1]
304330

305331
# Insert it **before** the following string(s), whichever appears first.
306332
for label in ['_recording', '_proc', '_space', filetype]:
307-
if label == filetype:
308-
this_prefix_basename = this_prefix_basename.replace(
309-
filetype, "_echo-%s%s" % (echo_number, filetype)
310-
)
311-
break
312-
elif (label in this_prefix_basename):
313-
this_prefix_basename = this_prefix_basename.replace(
333+
if (label == filetype) or (label in filename):
334+
filename = filename.replace(
314335
label, "_echo-%s%s" % (echo_number, label)
315336
)
316337
break
317338

318-
return this_prefix_basename
339+
return filename
319340

320341

321-
def update_uncombined_name(metadata, this_prefix_basename, channel_names):
342+
def update_uncombined_name(metadata, filename, channel_names):
322343
"""
323344
Insert `_ch-<num>` entity into filename if data are from a sequence
324345
with "save uncombined".
346+
347+
Parameters
348+
----------
349+
metadata : dict
350+
Scan metadata dictionary from BIDS sidecar file.
351+
filename : str
352+
Incoming filename
353+
channel_names : list
354+
List of all channel names from scan. Used to determine the channel
355+
*number* (i.e., index) if field is missing from metadata.
356+
357+
Returns
358+
-------
359+
filename : str
360+
Updated filename with ch entity added, if appropriate.
325361
"""
326362
# In case any scan types separate channels differently
327363
unsupported_types = []
328-
if any(ut in this_prefix_basename for ut in unsupported_types):
329-
return this_prefix_basename
364+
if any(ut in filename for ut in unsupported_types):
365+
return filename
330366

331367
# Determine the channel number
332368
channel_number = ''.join([c for c in metadata['CoilString'] if c.isdigit()])
333369
if not channel_number:
334370
channel_number = channel_names.index(metadata['CoilString']) + 1
335-
channel_number = channel_number.zfill(2)
371+
channel_number = str(channel_number).zfill(2)
336372

337373
# Determine scan suffix
338-
filetype = '_' + this_prefix_basename.split('_')[-1]
374+
filetype = '_' + filename.split('_')[-1]
339375

340376
# Insert it **before** the following string(s), whichever appears first.
341377
# Choosing to put channel near the end since it's not in the specification yet.
342378
for label in ['_recording', '_proc', '_space', filetype]:
343-
if label == filetype:
344-
this_prefix_basename = this_prefix_basename.replace(
345-
filetype, "_ch-%s%s" % (channel_number, filetype)
346-
)
347-
break
348-
elif (label in this_prefix_basename):
349-
this_prefix_basename = this_prefix_basename.replace(
379+
if (label == filetype) or (label in filename):
380+
filename = filename.replace(
350381
label, "_ch-%s%s" % (channel_number, label)
351382
)
352383
break
353-
return this_prefix_basename
384+
return filename
354385

355386

356387
def convert(items, converter, scaninfo_suffix, custom_callable, with_prov,
@@ -655,27 +686,16 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
655686
# echo times for all bids_files and see if they are all the same or not.
656687

657688
# Collect some metadata across all images
658-
echo_times, channel_names, image_types = [], [], []
659-
for b in bids_files:
660-
if not b:
689+
echo_times, channel_names, image_types = set(), set(), set()
690+
for metadata in bids_metas:
691+
if not metadata:
661692
continue
662-
metadata = load_json(b)
663-
echo_times.append(metadata.get('EchoTime', None))
664-
channel_names.append(metadata.get('CoilString', None))
665-
image_types.append(metadata.get('ImageType', None))
666-
echo_times = [v for v in echo_times if v]
667-
echo_times = sorted(list(set(echo_times)))
668-
channel_names = [v for v in channel_names if v]
669-
channel_names = sorted(list(set(channel_names)))
670-
image_types = [v for v in image_types if v]
671-
672-
is_multiecho = len(echo_times) > 1 # Check for varying echo times
673-
is_uncombined = len(channel_names) > 1 # Check for uncombined data
674-
675-
# Determine if data are complex (magnitude + phase)
676-
magnitude_found = any(['M' in it for it in image_types])
677-
phase_found = any(['P' in it for it in image_types])
678-
is_complex = magnitude_found and phase_found
693+
echo_times.add(metadata.get('EchoTime', nan))
694+
channel_names.add(metadata.get('CoilString', nan))
695+
image_types.update(metadata.get('ImageType', [nan]))
696+
is_multiecho = len(set(filter(bool, echo_times))) > 1 # Check for varying echo times
697+
is_uncombined = len(set(filter(bool, channel_names))) > 1 # Check for uncombined data
698+
is_complex = 'M' in image_types and 'P' in image_types # Determine if data are complex (magnitude + phase)
679699

680700
### Loop through the bids_files, set the output name and save files
681701
for fl, suffix, bids_file, bids_meta in zip(res_files, suffixes, bids_files, bids_metas):
@@ -686,23 +706,22 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
686706
# and we don't want to modify it for all the bids_files):
687707
this_prefix_basename = prefix_basename
688708

689-
# Update name if multi-echo
690-
if bids_file and is_multiecho:
691-
this_prefix_basename = update_multiecho_name(
692-
bids_meta, this_prefix_basename, echo_times
693-
)
709+
# Update name for certain criteria
710+
if bids_file:
711+
if is_multiecho:
712+
this_prefix_basename = update_multiecho_name(
713+
bids_meta, this_prefix_basename, echo_times
714+
)
694715

695-
# Update name if complex data
696-
if bids_file and is_complex:
697-
this_prefix_basename = update_complex_name(
698-
bids_meta, this_prefix_basename, suffix
699-
)
716+
if is_complex:
717+
this_prefix_basename = update_complex_name(
718+
bids_meta, this_prefix_basename, suffix
719+
)
700720

701-
# Update name if uncombined (channel-level) data
702-
if bids_file and is_uncombined:
703-
this_prefix_basename = update_uncombined_name(
704-
bids_meta, this_prefix_basename, channel_names
705-
)
721+
if is_uncombined:
722+
this_prefix_basename = update_uncombined_name(
723+
bids_meta, this_prefix_basename, channel_names
724+
)
706725

707726
# Fallback option:
708727
# If we have failed to modify this_prefix_basename, because it didn't fall

heudiconv/tests/test_convert.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Test functions in heudiconv.convert module.
2+
"""
3+
import pytest
4+
5+
from heudiconv.convert import (update_complex_name,
6+
update_multiecho_name,
7+
update_uncombined_name)
8+
from heudiconv.bids import BIDSError
9+
10+
11+
def test_update_complex_name():
12+
"""Unit testing for heudiconv.convert.update_complex_name(), which updates
13+
filenames with the rec field if appropriate.
14+
"""
15+
# Standard name update
16+
fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
17+
metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'P', 'MB', 'TE3', 'ND', 'MOSAIC']}
18+
suffix = 3
19+
out_fn_true = 'sub-X_ses-Y_task-Z_rec-phase_run-01_sbref'
20+
out_fn_test = update_complex_name(metadata, fn, suffix)
21+
assert out_fn_test == out_fn_true
22+
# Catch an unsupported type and *do not* update
23+
fn = 'sub-X_ses-Y_task-Z_run-01_phase'
24+
out_fn_test = update_complex_name(metadata, fn, suffix)
25+
assert out_fn_test == fn
26+
# Data type is missing from metadata so use suffix
27+
fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
28+
metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'MB', 'TE3', 'ND', 'MOSAIC']}
29+
out_fn_true = 'sub-X_ses-Y_task-Z_rec-3_run-01_sbref'
30+
out_fn_test = update_complex_name(metadata, fn, suffix)
31+
assert out_fn_test == out_fn_true
32+
# Catch existing field with value that *does not match* metadata
33+
# and raise Exception
34+
fn = 'sub-X_ses-Y_task-Z_rec-magnitude_run-01_sbref'
35+
metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'P', 'MB', 'TE3', 'ND', 'MOSAIC']}
36+
suffix = 3
37+
with pytest.raises(BIDSError):
38+
assert update_complex_name(metadata, fn, suffix)
39+
40+
41+
def test_update_multiecho_name():
42+
"""Unit testing for heudiconv.convert.update_multiecho_name(), which updates
43+
filenames with the echo field if appropriate.
44+
"""
45+
# Standard name update
46+
fn = 'sub-X_ses-Y_task-Z_run-01_bold'
47+
metadata = {'EchoTime': 0.01,
48+
'EchoNumber': 1}
49+
echo_times = [0.01, 0.02, 0.03]
50+
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_echo-1_bold'
51+
out_fn_test = update_multiecho_name(metadata, fn, echo_times)
52+
assert out_fn_test == out_fn_true
53+
# EchoNumber field is missing from metadata, so use echo_times
54+
metadata = {'EchoTime': 0.01}
55+
out_fn_test = update_multiecho_name(metadata, fn, echo_times)
56+
assert out_fn_test == out_fn_true
57+
# Catch an unsupported type and *do not* update
58+
fn = 'sub-X_ses-Y_task-Z_run-01_phasediff'
59+
out_fn_test = update_multiecho_name(metadata, fn, echo_times)
60+
assert out_fn_test == fn
61+
62+
63+
def test_update_uncombined_name():
64+
"""Unit testing for heudiconv.convert.update_uncombined_name(), which updates
65+
filenames with the ch field if appropriate.
66+
"""
67+
# Standard name update
68+
fn = 'sub-X_ses-Y_task-Z_run-01_bold'
69+
metadata = {'CoilString': 'H1'}
70+
channel_names = ['H1', 'H2', 'H3', 'HEA;HEP']
71+
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-01_bold'
72+
out_fn_test = update_uncombined_name(metadata, fn, channel_names)
73+
assert out_fn_test == out_fn_true
74+
# CoilString field has no number in it
75+
metadata = {'CoilString': 'HEA;HEP'}
76+
out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-04_bold'
77+
out_fn_test = update_uncombined_name(metadata, fn, channel_names)
78+
assert out_fn_test == out_fn_true

0 commit comments

Comments
 (0)