Skip to content

Commit

Permalink
Merge pull request #233 from tsalo/master
Browse files Browse the repository at this point in the history
[ENH] Support multi-echo datasets in reports
  • Loading branch information
tyarkoni committed Aug 23, 2018
2 parents 586268e + fa7fbee commit ae99873
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 46 deletions.
70 changes: 51 additions & 19 deletions bids/reports/parsing.py
@@ -1,4 +1,5 @@
"""Generate publication-quality data acquisition methods section from BIDS dataset.
"""Generate publication-quality data acquisition methods section from BIDS
dataset.
Parsing functions for generating the MRI data acquisition portion of a
methods section from a BIDS dataset.
Expand All @@ -19,7 +20,6 @@
LOGGER = logging.getLogger('pybids.reports.parsing')



def general_acquisition_info(metadata):
"""
General sentence on data acquisition. Should be first sentence in MRI data
Expand All @@ -35,11 +35,13 @@ def general_acquisition_info(metadata):
out_str : :obj:`str`
Output string with scanner information.
"""
out_str = ('MR data were acquired using a {tesla}-Tesla {manu} {model} MRI '
'scanner.')
out_str = out_str.format(tesla=metadata.get('MagneticFieldStrength', 'UNKNOWN'),
out_str = ('MR data were acquired using a {tesla}-Tesla {manu} {model} '
'MRI scanner.')
out_str = out_str.format(tesla=metadata.get('MagneticFieldStrength',
'UNKNOWN'),
manu=metadata.get('Manufacturer', 'MANUFACTURER'),
model=metadata.get('ManufacturersModelName', 'MODEL'))
model=metadata.get('ManufacturersModelName',
'MODEL'))
return out_str


Expand Down Expand Up @@ -67,12 +69,13 @@ def func_info(task, n_runs, metadata, img, config):
A description of the scan's acquisition information.
"""
if metadata.get('MultibandAccelerationFactor', 1) > 1:
mb_str = '; MB factor={0}'.format(metadata['MultibandAccelerationFactor'])
mb_str = '; MB factor={}'.format(metadata['MultibandAccelerationFactor'])
else:
mb_str = ''

if metadata.get('ParallelReductionFactorInPlane', 1) > 1:
pr_str = '; in-plane acceleration factor={}'.format(metadata['ParallelReductionFactorInPlane'])
pr_str = ('; in-plane acceleration factor='
'{}'.format(metadata['ParallelReductionFactorInPlane']))
else:
pr_str = ''

Expand All @@ -82,9 +85,18 @@ def func_info(task, n_runs, metadata, img, config):
so_str = ''

if 'EchoTime' in metadata.keys():
te = num_to_str(metadata['EchoTime']*1000)
if isinstance(metadata['EchoTime'], list):
te = [num_to_str(t*1000) for t in metadata['EchoTime']]
te_temp = ', '.join(te[:-1])
te_temp += ', and {}'.format(te[-1])
te = te_temp
me_str = 'multi-echo '
else:
te = num_to_str(metadata['EchoTime']*1000)
me_str = 'single-echo '
else:
te = 'UNKNOWN'
me_str = 'UNKNOWN-echo'

task_name = metadata.get('TaskName', task+' task')
seqs, variants = get_seqstr(config, metadata)
Expand All @@ -102,8 +114,8 @@ def func_info(task, n_runs, metadata, img, config):
run_str = '{0} runs'.format(num2words(n_runs).title())

desc = '''
{run_str} of {task} {variants} {seqs} fMRI data were collected
({n_slices} slices{so_str}; repetition time, TR={tr}ms;
{run_str} of {task} {variants} {seqs} {me_str} fMRI data were
collected ({n_slices} slices{so_str}; repetition time, TR={tr}ms;
echo time, TE={te}ms; flip angle, FA={fa}<deg>;
field of view, FOV={fov}mm; matrix size={ms};
voxel size={vs}mm{mb_str}{pr_str}).
Expand All @@ -113,6 +125,7 @@ def func_info(task, n_runs, metadata, img, config):
task=task_name,
variants=variants,
seqs=seqs,
me_str=me_str,
n_slices=n_slices,
so_str=so_str,
tr=num_to_str(tr*1000),
Expand Down Expand Up @@ -408,7 +421,7 @@ def parse_niftis(layout, niftis, subj, config, **kwargs):
config : :obj:`dict`
Configuration info for methods generation.
"""
kwargs = {k:v for k, v in kwargs.items() if v is not None}
kwargs = {k: v for k, v in kwargs.items() if v is not None}

description_list = []
skip_task = {} # Only report each task once
Expand All @@ -426,21 +439,40 @@ def parse_niftis(layout, niftis, subj, config, **kwargs):

if nifti_struct.modality == 'func':
if not skip_task.get(nifti_struct.task, False):
n_runs = len(layout.get(subject=subj, extensions='nii.gz',
task=nifti_struct.task, **kwargs))
description_list.append(func_info(nifti_struct.task, n_runs,
metadata, img, config))
echos = layout.get_echoes(subject=subj, extensions='nii.gz',
task=nifti_struct.task, **kwargs)
n_echos = len(echos)
if n_echos > 0:
metadata['EchoTime'] = []
for echo in sorted(echos):
echo_struct = layout.get(subject=subj, echo=echo,
extensions='nii.gz',
task=nifti_struct.task,
**kwargs)[0]
echo_file = echo_struct.filename
echo_meta = layout.get_metadata(echo_file)
metadata['EchoTime'].append(echo_meta['EchoTime'])

n_runs = len(layout.get_runs(subject=subj,
task=nifti_struct.task,
**kwargs))
description_list.append(func_info(nifti_struct.task,
n_runs, metadata, img,
config))
skip_task[nifti_struct.task] = True

elif nifti_struct.modality == 'anat':
type_ = nifti_struct.type
if type_.endswith('w'):
type_ = type_[:-1] + '-weighted'
description_list.append(anat_info(type_, metadata, img, config))
description_list.append(anat_info(type_, metadata, img,
config))
elif nifti_struct.modality == 'dwi':
bval_file = nii_file.replace('.nii.gz', '.bval')
description_list.append(dwi_info(bval_file, metadata, img, config))
description_list.append(dwi_info(bval_file, metadata, img,
config))
elif nifti_struct.modality == 'fmap':
description_list.append(fmap_info(metadata, img, config, layout))
description_list.append(fmap_info(metadata, img, config,
layout))

return description_list
36 changes: 22 additions & 14 deletions bids/reports/report.py
@@ -1,4 +1,5 @@
"""Generate publication-quality data acquisition methods section from BIDS dataset.
"""Generate publication-quality data acquisition methods section from BIDS
dataset.
"""
from __future__ import print_function
import json
Expand Down Expand Up @@ -26,7 +27,8 @@ class BIDSReport(object):
configuration information.
Keys in the dictionary include:
'dir': a dictionary for converting encoding direction strings
(e.g., j-) to descriptions (e.g., anterior to posterior)
(e.g., j-) to descriptions (e.g., anterior to
posterior)
'seq': a dictionary of sequence abbreviations (e.g., EP) and
corresponding names (e.g., echo planar)
'seqvar': a dictionary of sequence variant abbreviations
Expand All @@ -35,15 +37,16 @@ class BIDSReport(object):
def __init__(self, layout, config=None):
self.layout = layout
if config is None:
config = pathjoin(dirname(abspath(__file__)), 'config', 'converters.json')
config = pathjoin(dirname(abspath(__file__)), 'config',
'converters.json')

if isinstance(config, str):
with open(config) as fobj:
config = json.load(fobj)

if not isinstance(config, dict):
raise ValueError('Input config must be None, dict, or path to json '
'file containing dict.')
raise ValueError('Input config must be None, dict, or path to '
'json file containing dict.')

self.config = config

Expand Down Expand Up @@ -76,7 +79,7 @@ def generate(self, **kwargs):
descriptions = []

subjs = self.layout.get_subjects(**kwargs)
kwargs = {k:v for k, v in kwargs.items() if k != 'subject'}
kwargs = {k: v for k, v in kwargs.items() if k != 'subject'}
for sid in subjs:
descriptions.append(self._report_subject(subject=sid, **kwargs))
counter = Counter(descriptions)
Expand Down Expand Up @@ -106,28 +109,33 @@ def _report_subject(self, subject, **kwargs):
information. Each scan type is given its own paragraph.
"""
description_list = []
# Remove session from kwargs if provided, else set sessions as all available
sessions = kwargs.pop('session', self.layout.get_sessions(subject=subject, **kwargs))
# Remove sess from kwargs if provided, else set sess as all available
sessions = kwargs.pop('session',
self.layout.get_sessions(subject=subject,
**kwargs))
if not sessions:
sessions = [None]
elif not isinstance(sessions, list):
sessions = [sessions]

for ses in sessions:
niftis = self.layout.get(subject=subject, extensions='nii.gz', **kwargs)
niftis = self.layout.get(subject=subject, extensions='nii.gz',
**kwargs)

if niftis:
description_list.append('For session {0}:'.format(ses))
description_list += parsing.parse_niftis(self.layout, niftis, subject,
self.config, session=ses)
description_list += parsing.parse_niftis(self.layout, niftis,
subject, self.config,
session=ses)
metadata = self.layout.get_metadata(niftis[0].filename)
else:
raise Exception('No niftis for subject {0}'.format(subject))

# Assume all data were converted the same way and use the last nifti file's
# json for conversion information.
# Assume all data were converted the same way and use the last nifti
# file's json for conversion information.
if 'metadata' not in vars():
raise Exception('No valid jsons found. Cannot generate final paragraph.')
raise Exception('No valid jsons found. Cannot generate final '
'paragraph.')

description = '\n\t'.join(description_list)
description = description.replace('\tFor session', '\nFor session')
Expand Down
24 changes: 19 additions & 5 deletions bids/reports/tests/test_parsing.py
@@ -1,3 +1,6 @@
"""
tests for bids.reports.parsing
"""
import json
from os.path import abspath, join
import pytest
Expand All @@ -8,23 +11,28 @@
from bids.layout import BIDSLayout
from bids.tests import get_test_data_path


@pytest.fixture
def testlayout():
data_dir = join(get_test_data_path(), 'synthetic')
return BIDSLayout(data_dir)


@pytest.fixture
def testconfig():
config_file = abspath(join(get_test_data_path(), '../../reports/config/converters.json'))
config_file = abspath(join(get_test_data_path(),
'../../reports/config/converters.json'))
with open(config_file, 'r') as fobj:
config = json.load(fobj)
return config


@pytest.fixture
def testmeta():
metadata = {'RepetitionTime': 2.}
return metadata


def test_parsing_anat():
"""
parsing.anat_info returns a str description of each structural scan
Expand All @@ -36,6 +44,7 @@ def test_parsing_anat():
desc = parsing.anat_info(type_, metadata, img, config)
assert isinstance(desc, str)


def test_parsing_dwi():
"""
parsing.dwi_info returns a str description of each diffusion scan
Expand All @@ -47,6 +56,7 @@ def test_parsing_dwi():
desc = parsing.dwi_info(bval_file, metadata, img, config)
assert isinstance(desc, str)


def test_parsing_fmap():
"""
parsing.fmap_info returns a str decsription of each field map
Expand All @@ -59,26 +69,29 @@ def test_parsing_fmap():
desc = parsing.fmap_info(metadata, img, config, layout)
assert isinstance(desc, str)


def test_parsing_func():
"""
parsing.func_info returns a str description of a set of functional scans grouped
by task
parsing.func_info returns a str description of a set of functional scans
grouped by task
"""
metadata = testmeta()
img = nib.load(join(get_test_data_path(), 'images/4d.nii.gz'))
config = testconfig()
desc = parsing.func_info('nback', 3, metadata, img, config)
assert isinstance(desc, str)


def test_parsing_genacq():
"""
parsing.general_acquisition_info returns a str description of the scanner from
minimal metadata
parsing.general_acquisition_info returns a str description of the scanner
from minimal metadata
"""
metadata = testmeta()
desc = parsing.general_acquisition_info(metadata)
assert isinstance(desc, str)


def test_parsing_final():
"""
parsing.final_paragraph returns a str description of the dicom-to-nifti
Expand All @@ -88,6 +101,7 @@ def test_parsing_final():
desc = parsing.final_paragraph(metadata)
assert isinstance(desc, str)


def test_parsing_parse():
"""
parsing.parse_niftis should return a list of strings, with each string
Expand Down
21 changes: 17 additions & 4 deletions bids/reports/tests/test_report.py
@@ -1,3 +1,6 @@
"""
tests for bids.reports.report
"""
import json
from collections import Counter
from os.path import join, abspath
Expand All @@ -14,12 +17,14 @@ def testlayout():
data_dir = join(get_test_data_path(), 'synthetic')
return BIDSLayout(data_dir)


def test_report_init():
"""Report initialization should return a BIDSReport object.
"""
report = BIDSReport(testlayout())
assert isinstance(report, BIDSReport)


def test_report_gen():
"""Report generation should return a counter of unique descriptions in the
dataset.
Expand All @@ -28,6 +33,7 @@ def test_report_gen():
descriptions = report.generate()
assert isinstance(descriptions, Counter)


def test_report_subject():
"""Generating a report for one subject should only return one subject's
description (i.e., one pattern with a count of one).
Expand All @@ -36,6 +42,7 @@ def test_report_subject():
descriptions = report.generate(subject='01')
assert sum(descriptions.values()) == 1


def test_report_session():
"""Generating a report for one session should mean that no other sessions
appear in any of the unique descriptions.
Expand All @@ -44,18 +51,24 @@ def test_report_session():
descriptions = report.generate(session='01')
assert 'session 02' not in ' '.join(descriptions.keys())


def test_report_file_config():
"""Report initialization should take in a config file and use that if provided.
"""Report initialization should take in a config file and use that if
provided.
"""
config_file = abspath(join(get_test_data_path(), '../../reports/config/converters.json'))
config_file = abspath(join(get_test_data_path(),
'../../reports/config/converters.json'))
report = BIDSReport(testlayout(), config=config_file)
descriptions = report.generate()
assert isinstance(descriptions, Counter)


def test_report_dict_config():
"""Report initialization should take in a config dict and use that if provided.
"""Report initialization should take in a config dict and use that if
provided.
"""
config_file = abspath(join(get_test_data_path(), '../../reports/config/converters.json'))
config_file = abspath(join(get_test_data_path(),
'../../reports/config/converters.json'))
with open(config_file, 'r') as fobj:
config = json.load(fobj)
report = BIDSReport(testlayout(), config=config)
Expand Down

0 comments on commit ae99873

Please sign in to comment.