Skip to content

Commit ad5a517

Browse files
committed
Merge branch 'master' of https://github.com/nipy/heudiconv into fix/queue
2 parents fefc26b + 4e142b4 commit ad5a517

File tree

7 files changed

+259
-25
lines changed

7 files changed

+259
-25
lines changed

heudiconv/bids.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525

2626
lgr = logging.getLogger(__name__)
2727

28+
29+
class BIDSError(Exception):
30+
pass
31+
32+
2833
def populate_bids_templates(path, defaults={}):
2934
"""Premake BIDS text files with templates"""
3035

@@ -113,6 +118,25 @@ def populate_aggregated_jsons(path):
113118
# create a stub onsets file for each one of those
114119
suf = '_bold.json'
115120
assert fpath.endswith(suf)
121+
# specify the name of the '_events.tsv' file:
122+
if '_echo-' in fpath:
123+
# multi-echo sequence: bids (1.1.0) specifies just one '_events.tsv'
124+
# file, common for all echoes. The name will not include _echo-.
125+
# TODO: RF to use re.match for better readability/robustness
126+
# So, find out the echo number:
127+
fpath_split = fpath.split('_echo-', 1) # split fpath using '_echo-'
128+
fpath_split_2 = fpath_split[1].split('_', 1) # split the second part of fpath_split using '_'
129+
echoNo = fpath_split_2[0] # get echo number
130+
if echoNo == '1':
131+
if len(fpath_split_2) != 2:
132+
raise ValueError("Found no trailer after _echo-")
133+
# we modify fpath to exclude '_echo-' + echoNo:
134+
fpath = fpath_split[0] + '_' + fpath_split_2[1]
135+
else:
136+
# for echoNo greater than 1, don't create the events file, so go to
137+
# the next for loop iteration:
138+
continue
139+
116140
events_file = fpath[:-len(suf)] + '_events.tsv'
117141
# do not touch any existing thing, it may be precious
118142
if not op.lexists(events_file):

heudiconv/convert.py

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
save_scans_key,
2525
tuneup_bids_json_files,
2626
add_participant_record,
27+
BIDSError
2728
)
2829
from .dicoms import (
2930
group_dicoms_into_seqinfos,
@@ -243,7 +244,7 @@ def convert(items, converter, scaninfo_suffix, custom_callable, with_prov,
243244
if not isinstance(outtypes, (list, tuple)):
244245
outtypes = (outtypes,)
245246

246-
prefix_dirname = op.dirname(prefix + '.ext')
247+
prefix_dirname = op.dirname(prefix)
247248
outname_bids = prefix + '.json'
248249
bids_outfiles = []
249250
lgr.info('Converting %s (%d DICOMs) -> %s . '
@@ -462,6 +463,8 @@ def save_converted_files(res, item_dicoms, bids, outtype, prefix, outname_bids,
462463
"""
463464
from nipype.interfaces.base import isdefined
464465

466+
prefix_dirname, prefix_basename = op.split(prefix)
467+
465468
bids_outfiles = []
466469
res_files = res.outputs.converted_files
467470

@@ -492,16 +495,112 @@ def save_converted_files(res, item_dicoms, bids, outtype, prefix, outname_bids,
492495
# Also copy BIDS files although they might need to
493496
# be merged/postprocessed later
494497
bids_files = sorted(res.outputs.bids
495-
if len(res.outputs.bids) == len(res_files)
496-
else [None] * len(res_files))
498+
if len(res.outputs.bids) == len(res_files)
499+
else [None] * len(res_files))
500+
501+
### Do we have a multi-echo series? ###
502+
# Some Siemens sequences (e.g. CMRR's MB-EPI) set the label 'TE1',
503+
# 'TE2', etc. in the 'ImageType' field. However, other seqs do not
504+
# (e.g. MGH ME-MPRAGE). They do set a 'EchoNumber', but not for the
505+
# first echo. To compound the problem, the echoes are NOT in order,
506+
# so the first NIfTI file does not correspond to echo-1, etc. So, we
507+
# need to know, beforehand, whether we are dealing with a multi-echo
508+
# series. To do that, the most straightforward way is to read the
509+
# echo times for all bids_files and see if they are all the same or not.
510+
511+
# Check for echotime information
512+
echo_times = set()
513+
514+
for bids_file in bids_files:
515+
if bids_file:
516+
# check for varying EchoTimes
517+
echot = load_json(bids_file).get('EchoTime', None)
518+
if echot is not None:
519+
echo_times.add(echot)
497520

521+
# To see if the echo times are the same, convert it to a set and see if
522+
# only one remains:
523+
is_multiecho = len(echo_times) >= 1 if echo_times else False
524+
525+
### Loop through the bids_files, set the output name and save files
498526
for fl, suffix, bids_file in zip(res_files, suffixes, bids_files):
499-
outname = "%s%s.%s" % (prefix, suffix, outtype)
500-
safe_copyfile(fl, outname, overwrite)
527+
528+
# TODO: monitor conversion duration
501529
if bids_file:
502-
outname_bids_file = "%s%s.json" % (prefix, suffix)
530+
fileinfo = load_json(bids_file)
531+
532+
# set the prefix basename for this specific file (we'll modify it,
533+
# and we don't want to modify it for all the bids_files):
534+
this_prefix_basename = prefix_basename
535+
536+
# _sbref sequences reconstructing magnitude and phase generate
537+
# two NIfTI files IN THE SAME SERIES, so we cannot just add
538+
# the suffix, if we want to be bids compliant:
539+
if bids_file and this_prefix_basename.endswith('_sbref'):
540+
# Check to see if it is magnitude or phase reconstruction:
541+
if 'M' in fileinfo.get('ImageType'):
542+
mag_or_phase = 'magnitude'
543+
elif 'P' in fileinfo.get('ImageType'):
544+
mag_or_phase = 'phase'
545+
else:
546+
mag_or_phase = suffix
547+
548+
# Insert reconstruction label
549+
if not ("_rec-%s" % mag_or_phase) in this_prefix_basename:
550+
551+
# If "_rec-" is specified, prepend the 'mag_or_phase' value.
552+
if ('_rec-' in this_prefix_basename):
553+
raise BIDSError(
554+
"Reconstruction label for multi-echo single-band"
555+
" reference images will be automatically set, remove"
556+
" from heuristic"
557+
)
558+
559+
# If not, insert "_rec-" + 'mag_or_phase' into the prefix_basename
560+
# **before** "_run", "_echo" or "_sbref", whichever appears first:
561+
for label in ['_run', '_echo', '_sbref']:
562+
if (label in this_prefix_basename):
563+
this_prefix_basename = this_prefix_basename.replace(
564+
label, "_rec-%s%s" % (mag_or_phase, label)
565+
)
566+
break
567+
568+
# Now check if this run is multi-echo
569+
# (Note: it can be _sbref and multiecho, so don't use "elif"):
570+
# For multi-echo sequences, we have to specify the echo number in
571+
# the file name:
572+
if bids_file and is_multiecho:
573+
# Get the EchoNumber from json file info. If not present, it's echo-1
574+
echo_number = fileinfo.get('EchoNumber', 1)
575+
576+
577+
supported_multiecho = ['_bold', '_epi', '_sbref', '_T1w']
578+
# Now, decide where to insert it.
579+
# Insert it **before** the following string(s), whichever appears first.
580+
for imgtype in supported_multiecho:
581+
if (imgtype in this_prefix_basename):
582+
this_prefix_basename = this_prefix_basename.replace(
583+
imgtype, "_echo-%d%s" % (echo_number, imgtype)
584+
)
585+
break
586+
587+
# Fallback option:
588+
# If we have failed to modify this_prefix_basename, because it didn't fall
589+
# into any of the options above, just add the suffix at the end:
590+
if this_prefix_basename == prefix_basename:
591+
this_prefix_basename += suffix
592+
593+
# Finally, form the outname by stitching the directory and outtype:
594+
outname = op.join(prefix_dirname, this_prefix_basename)
595+
outfile = outname + '.' + outtype
596+
597+
# Write the files needed:
598+
safe_copyfile(fl, outfile, overwrite)
599+
if bids_file:
600+
outname_bids_file = "%s.json" % (outname)
503601
safe_copyfile(bids_file, outname_bids_file, overwrite)
504602
bids_outfiles.append(outname_bids_file)
603+
505604
# res_files is not a list
506605
else:
507606
outname = "{}.{}".format(prefix, outtype)

heudiconv/heuristics/bids_ME.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Heuristic demonstrating conversion of the Multi-Echo sequences.
2+
3+
It only cares about converting sequences which have _ME_ in their
4+
series_description and outputs to BIDS.
5+
"""
6+
7+
8+
def create_key(template, outtype=('nii.gz',), annotation_classes=None):
9+
if template is None or not template:
10+
raise ValueError('Template must be a valid format string')
11+
return template, outtype, annotation_classes
12+
13+
def infotodict(seqinfo):
14+
"""Heuristic evaluator for determining which runs belong where
15+
16+
allowed template fields - follow python string module:
17+
18+
item: index within category
19+
subject: participant id
20+
seqitem: run number during scanning
21+
subindex: sub index within group
22+
"""
23+
bold = create_key('sub-{subject}/func/sub-{subject}_task-test_run-{item}_bold')
24+
25+
info = {bold: []}
26+
for s in seqinfo:
27+
if '_ME_' in s.series_description:
28+
info[bold].append(s.series_id)
29+
return info

heudiconv/parser.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,6 @@ def get_study_sessions(dicom_dir_template, files_opt, heuristic, outdir,
167167
dcmfilter=getattr(heuristic, 'filter_dicom', None),
168168
grouping=grouping)
169169

170-
if not getattr(heuristic, 'infotoids', None):
171-
raise NotImplementedError(
172-
"For now, if no subj template is provided, requiring "
173-
"heuristic to have infotoids")
174-
175170
if sids:
176171
if not (len(sids) == 1 and len(seqinfo_dict) == 1):
177172
raise RuntimeError(
@@ -184,6 +179,24 @@ def get_study_sessions(dicom_dir_template, files_opt, heuristic, outdir,
184179
else:
185180
sid = None
186181

182+
if not getattr(heuristic, 'infotoids', None):
183+
# allow bypass with subject override
184+
if not sid:
185+
raise NotImplementedError("Cannot guarantee subject id - add "
186+
"`infotoids` to heuristic file or "
187+
"provide `--subjects` option")
188+
lgr.warn("Heuristic is missing an `infotoids` method, assigning "
189+
"empty method and using provided subject id %s."
190+
"Provide `session` and `locator` fields for best results."
191+
, sid)
192+
def infotoids(seqinfos, outdir):
193+
return {
194+
'locator': None,
195+
'session': None,
196+
'subject': None
197+
}
198+
heuristic.infotoids = infotoids
199+
187200
for studyUID, seqinfo in seqinfo_dict.items():
188201
# so we have a single study, we need to figure out its
189202
# locator, session, subject

heudiconv/tests/test_heuristics.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,11 @@ def test_reproin_largely_smoke(tmpdir, heuristic, invocation):
6464
runner(args + ['--subjects', 'sub1', 'sub2'])
6565

6666
if heuristic != 'reproin':
67-
# none other heuristic has mighty infotoids atm
67+
# if subject is not overriden, raise error
6868
with pytest.raises(NotImplementedError):
6969
runner(args)
7070
return
71+
7172
runner(args)
7273
ds = Dataset(str(tmpdir))
7374
assert ds.is_installed()

heudiconv/tests/test_regression.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Testing conversion with conversion saved on datalad"""
22
import json
33
from glob import glob
4+
import os.path as op
45

56
import pytest
67

@@ -11,8 +12,8 @@
1112
except ImportError:
1213
have_datalad = False
1314

14-
import heudiconv
1515
from heudiconv.cli.run import main as runner
16+
from heudiconv.utils import load_json
1617
# testing utilities
1718
from .utils import fetch_data, gen_heudiconv_args
1819

@@ -24,12 +25,19 @@
2425
def test_conversion(tmpdir, subject, heuristic, anon_cmd):
2526
tmpdir.chdir()
2627
try:
27-
datadir = fetch_data(tmpdir.strpath, subject)
28+
datadir = fetch_data(tmpdir.strpath,
29+
"dbic/QA", # path from datalad database root
30+
getpath=op.join('sourcedata', subject))
2831
except IncompleteResultsError as exc:
2932
pytest.skip("Failed to fetch test data: %s" % str(exc))
3033
outdir = tmpdir.mkdir('out').strpath
3134

32-
args = gen_heudiconv_args(datadir, outdir, subject, heuristic, anon_cmd)
35+
args = gen_heudiconv_args(datadir,
36+
outdir,
37+
subject,
38+
heuristic,
39+
anon_cmd,
40+
template=op.join('sourcedata/{subject}/*/*/*.tgz'))
3341
runner(args) # run conversion
3442

3543
# verify functionals were converted
@@ -38,8 +46,38 @@ def test_conversion(tmpdir, subject, heuristic, anon_cmd):
3846

3947
# compare some json metadata
4048
json_ = '{}/task-rest_acq-24mm64sl1000tr32te600dyn_bold.json'.format
41-
orig, conv = (json.load(open(json_(datadir))),
42-
json.load(open(json_(outdir))))
49+
orig, conv = (load_json(json_(datadir)),
50+
load_json(json_(outdir)))
4351
keys = ['EchoTime', 'MagneticFieldStrength', 'Manufacturer', 'SliceTiming']
4452
for key in keys:
4553
assert orig[key] == conv[key]
54+
55+
@pytest.mark.skipif(not have_datalad, reason="no datalad")
56+
def test_multiecho(tmpdir, subject='MEEPI', heuristic='bids_ME.py'):
57+
tmpdir.chdir()
58+
try:
59+
datadir = fetch_data(tmpdir.strpath, "dicoms/velasco/MEEPI")
60+
except IncompleteResultsError as exc:
61+
pytest.skip("Failed to fetch test data: %s" % str(exc))
62+
63+
outdir = tmpdir.mkdir('out').strpath
64+
args = gen_heudiconv_args(datadir, outdir, subject, heuristic)
65+
runner(args) # run conversion
66+
67+
# check if we have echo functionals
68+
echoes = glob(op.join('out', 'sub-' + subject, 'func', '*echo*nii.gz'))
69+
assert len(echoes) == 3
70+
71+
# check EchoTime of each functional
72+
# ET1 < ET2 < ET3
73+
prev_echo = 0
74+
for echo in sorted(echoes):
75+
_json = echo.replace('.nii.gz', '.json')
76+
assert _json
77+
echotime = load_json(_json).get('EchoTime', None)
78+
assert echotime > prev_echo
79+
prev_echo = echotime
80+
81+
events = glob(op.join('out', 'sub-' + subject, 'func', '*events.tsv'))
82+
for event in events:
83+
assert 'echo-' not in event

0 commit comments

Comments
 (0)