Skip to content

Commit fadc3c9

Browse files
authored
Merge pull request #424 from tsalo/ref/modularize-multifile-renamers
[ENH, REF] Add uncombined support and modularize multi-file renamers
2 parents 75f2850 + 1dd72bf commit fadc3c9

File tree

4 files changed

+272
-75
lines changed

4 files changed

+272
-75
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,11 @@ TODO Summary
360360
[#434]: https://github.com/nipy/heudiconv/issues/434
361361
[#436]: https://github.com/nipy/heudiconv/issues/436
362362
[#437]: https://github.com/nipy/heudiconv/issues/437
363+
[#425]: https://github.com/nipy/heudiconv/issues/425
364+
[#420]: https://github.com/nipy/heudiconv/issues/420
365+
[#425]: https://github.com/nipy/heudiconv/issues/425
366+
[#430]: https://github.com/nipy/heudiconv/issues/430
367+
[#432]: https://github.com/nipy/heudiconv/issues/432
368+
[#434]: https://github.com/nipy/heudiconv/issues/434
369+
[#436]: https://github.com/nipy/heudiconv/issues/436
370+
[#437]: https://github.com/nipy/heudiconv/issues/437

docs/installation.rst

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,32 @@ Installation
77

88
Local
99
=====
10-
Released versions of HeuDiConv are available on `PyPI <https://pypi.org/project/heudiconv/>`_
11-
and `conda <https://github.com/conda-forge/heudiconv-feedstock#installing-heudiconv>`_.
10+
Released versions of HeuDiConv are available on `PyPI <https://pypi.org/project/heudiconv/>`_
11+
and `conda <https://github.com/conda-forge/heudiconv-feedstock#installing-heudiconv>`_.
1212
If installing through ``PyPI``, eg::
1313

1414
pip install heudiconv[all]
1515

16-
Manual installation of `dcm2niix <https://github.com/rordenlab/dcm2niix#install>`_
16+
Manual installation of `dcm2niix <https://github.com/rordenlab/dcm2niix#install>`_
1717
is required.
1818

19-
On Debian-based systems we recommend using `NeuroDebian <http://neuro.debian.net>`_
19+
On Debian-based systems we recommend using `NeuroDebian <http://neuro.debian.net>`_
2020
which provides the `heudiconv package <http://neuro.debian.net/pkgs/heudiconv.html>`_.
2121

2222

2323
Docker
2424
======
25-
If `Docker <https://docs.docker.com/install/>`_ is available on your system, you
26-
can visit `our page on Docker Hub <https://hub.docker.com/r/nipy/heudiconv/tags>`_
25+
If `Docker <https://docs.docker.com/install/>`_ is available on your system, you
26+
can visit `our page on Docker Hub <https://hub.docker.com/r/nipy/heudiconv/tags>`_
2727
to view available releases. To pull the latest release, run::
2828

2929
$ docker pull nipy/heudiconv:0.8.0
3030

3131

3232
Singularity
3333
===========
34-
If `Singularity <https://www.sylabs.io/singularity/>`_ is available on your system,
35-
you can use it to pull and convert our Docker images! For example, to pull and
34+
If `Singularity <https://www.sylabs.io/singularity/>`_ is available on your system,
35+
you can use it to pull and convert our Docker images! For example, to pull and
3636
build the latest release, you can run::
3737

3838
$ singularity pull docker://nipy/heudiconv:0.8.0

heudiconv/convert.py

Lines changed: 178 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def conversion_info(subject, outdir, info, filegroup, ses):
8282

8383

8484
def prep_conversion(sid, dicoms, outdir, heuristic, converter, anon_sid,
85-
anon_outdir, with_prov, ses, bids_options, seqinfo,
85+
anon_outdir, with_prov, ses, bids_options, seqinfo,
8686
min_meta, overwrite, dcmconfig, grouping):
8787
if dicoms:
8888
lgr.info("Processing %d dicoms", len(dicoms))
@@ -233,6 +233,157 @@ def prep_conversion(sid, dicoms, outdir, heuristic, converter, anon_sid,
233233
getattr(heuristic, 'DEFAULT_FIELDS', {}))
234234

235235

236+
def update_complex_name(metadata, filename, suffix):
237+
"""
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.
255+
"""
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
262+
263+
# Check to see if it is magnitude or phase part:
264+
if 'M' in metadata.get('ImageType'):
265+
mag_or_phase = 'magnitude'
266+
elif 'P' in metadata.get('ImageType'):
267+
mag_or_phase = 'phase'
268+
else:
269+
mag_or_phase = suffix
270+
271+
# Determine scan suffix
272+
filetype = '_' + filename.split('_')[-1]
273+
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:
278+
raise BIDSError(
279+
"Reconstruction label for images will be automatically set, "
280+
"remove from heuristic"
281+
)
282+
283+
# Insert it **before** the following string(s), whichever appears first.
284+
for label in ['_dir', '_run', '_mod', '_echo', '_recording', '_proc', '_space', filetype]:
285+
if (label == filetype) or (label in filename):
286+
filename = filename.replace(
287+
label, "_rec-%s%s" % (mag_or_phase, label)
288+
)
289+
break
290+
291+
return filename
292+
293+
294+
def update_multiecho_name(metadata, filename, echo_times):
295+
"""
296+
Insert `_echo-<num>` entity into filename if data are from a multi-echo
297+
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.
313+
"""
314+
# Field maps separate echoes differently
315+
unsupported_types = [
316+
'_magnitude', '_magnitude1', '_magnitude2',
317+
'_phasediff', '_phase1', '_phase2', '_fieldmap'
318+
]
319+
if any(ut in filename for ut in unsupported_types):
320+
return filename
321+
322+
# Get the EchoNumber from json file info. If not present, use EchoTime
323+
if 'EchoNumber' in metadata.keys():
324+
echo_number = metadata['EchoNumber']
325+
else:
326+
echo_number = echo_times.index(metadata['EchoTime']) + 1
327+
328+
# Determine scan suffix
329+
filetype = '_' + filename.split('_')[-1]
330+
331+
# Insert it **before** the following string(s), whichever appears first.
332+
for label in ['_recording', '_proc', '_space', filetype]:
333+
if (label == filetype) or (label in filename):
334+
filename = filename.replace(
335+
label, "_echo-%s%s" % (echo_number, label)
336+
)
337+
break
338+
339+
return filename
340+
341+
342+
def update_uncombined_name(metadata, filename, channel_names):
343+
"""
344+
Insert `_ch-<num>` entity into filename if data are from a sequence
345+
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.
361+
"""
362+
# In case any scan types separate channels differently
363+
unsupported_types = []
364+
if any(ut in filename for ut in unsupported_types):
365+
return filename
366+
367+
# Determine the channel number
368+
channel_number = ''.join([c for c in metadata['CoilString'] if c.isdigit()])
369+
if not channel_number:
370+
channel_number = channel_names.index(metadata['CoilString']) + 1
371+
channel_number = str(channel_number).zfill(2)
372+
373+
# Determine scan suffix
374+
filetype = '_' + filename.split('_')[-1]
375+
376+
# Insert it **before** the following string(s), whichever appears first.
377+
# Choosing to put channel near the end since it's not in the specification yet.
378+
for label in ['_recording', '_proc', '_space', filetype]:
379+
if (label == filetype) or (label in filename):
380+
filename = filename.replace(
381+
label, "_ch-%s%s" % (channel_number, label)
382+
)
383+
break
384+
return filename
385+
386+
236387
def convert(items, converter, scaninfo_suffix, custom_callable, with_prov,
237388
bids_options, outdir, min_meta, overwrite, symlink=True, prov_file=None,
238389
dcmconfig=None):
@@ -534,14 +685,17 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
534685
# series. To do that, the most straightforward way is to read the
535686
# echo times for all bids_files and see if they are all the same or not.
536687

537-
# Check for varying echo times
538-
echo_times = sorted(list(set(
539-
b.get('EchoTime', nan)
540-
for b in bids_metas
541-
if b
542-
)))
543-
544-
is_multiecho = len(echo_times) > 1
688+
# Collect some metadata across all images
689+
echo_times, channel_names, image_types = set(), set(), set()
690+
for metadata in bids_metas:
691+
if not metadata:
692+
continue
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)
545699

546700
### Loop through the bids_files, set the output name and save files
547701
for fl, suffix, bids_file, bids_meta in zip(res_files, suffixes, bids_files, bids_metas):
@@ -552,65 +706,22 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
552706
# and we don't want to modify it for all the bids_files):
553707
this_prefix_basename = prefix_basename
554708

555-
# _sbref sequences reconstructing magnitude and phase generate
556-
# two NIfTI files IN THE SAME SERIES, so we cannot just add
557-
# the suffix, if we want to be bids compliant:
558-
if bids_meta and this_prefix_basename.endswith('_sbref') \
559-
and len(suffixes) > len(echo_times):
560-
if len(suffixes) != len(echo_times)*2:
561-
lgr.warning(
562-
"Got %d suffixes for %d echo times, which isn't "
563-
"multiple of two as if it was magnitude + phase pairs",
564-
len(suffixes), len(echo_times)
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+
)
715+
716+
if is_complex:
717+
this_prefix_basename = update_complex_name(
718+
bids_meta, this_prefix_basename, suffix
719+
)
720+
721+
if is_uncombined:
722+
this_prefix_basename = update_uncombined_name(
723+
bids_meta, this_prefix_basename, channel_names
565724
)
566-
# Check to see if it is magnitude or phase reconstruction:
567-
if 'M' in bids_meta.get('ImageType'):
568-
mag_or_phase = 'magnitude'
569-
elif 'P' in bids_meta.get('ImageType'):
570-
mag_or_phase = 'phase'
571-
else:
572-
mag_or_phase = suffix
573-
574-
# Insert reconstruction label
575-
if not ("_rec-%s" % mag_or_phase) in this_prefix_basename:
576-
577-
# If "_rec-" is specified, prepend the 'mag_or_phase' value.
578-
if ('_rec-' in this_prefix_basename):
579-
raise BIDSError(
580-
"Reconstruction label for multi-echo single-band"
581-
" reference images will be automatically set, remove"
582-
" from heuristic"
583-
)
584-
585-
# If not, insert "_rec-" + 'mag_or_phase' into the prefix_basename
586-
# **before** "_run", "_echo" or "_sbref", whichever appears first:
587-
for label in ['_run', '_echo', '_sbref']:
588-
if (label in this_prefix_basename):
589-
this_prefix_basename = this_prefix_basename.replace(
590-
label, "_rec-%s%s" % (mag_or_phase, label)
591-
)
592-
break
593-
594-
# Now check if this run is multi-echo
595-
# (Note: it can be _sbref and multiecho, so don't use "elif"):
596-
# For multi-echo sequences, we have to specify the echo number in
597-
# the file name:
598-
if bids_meta and is_multiecho:
599-
# Get the EchoNumber from json file info. If not present, use EchoTime
600-
if 'EchoNumber' in bids_meta:
601-
echo_number = bids_meta['EchoNumber']
602-
else:
603-
echo_number = echo_times.index(bids_meta['EchoTime']) + 1
604-
605-
supported_multiecho = ['_bold', '_phase', '_epi', '_sbref', '_T1w', '_PDT2']
606-
# Now, decide where to insert it.
607-
# Insert it **before** the following string(s), whichever appears first.
608-
for imgtype in supported_multiecho:
609-
if (imgtype in this_prefix_basename):
610-
this_prefix_basename = this_prefix_basename.replace(
611-
imgtype, "_echo-%d%s" % (echo_number, imgtype)
612-
)
613-
break
614725

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

0 commit comments

Comments
 (0)