Skip to content

Commit d5f01b9

Browse files
committed
ENH: Adds populate_intended_for
It adds a function to populate the "IntendedFor" field to the fmap jsons. It also adds the corresponding test. Minor modification to `create_tree` for the case of a `name` ending in `.json`.
1 parent 6dbd28a commit d5f01b9

File tree

3 files changed

+289
-1
lines changed

3 files changed

+289
-1
lines changed

heudiconv/bids.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
save_json,
2020
create_file_if_missing,
2121
json_dumps_pretty,
22+
add_field_to_json,
2223
set_readonly,
2324
is_readonly,
2425
get_datetime,
@@ -457,3 +458,125 @@ def convert_sid_bids(subject_id):
457458
lgr.warning('{0} contained nonalphanumeric character(s), subject '
458459
'ID was cleaned to be {1}'.format(subject_id, sid))
459460
return sid, subject_id
461+
462+
463+
def populate_intended_for(path_to_bids_session):
464+
"""
465+
Adds the 'IntendedFor' field to the fmap .json files in a session folder.
466+
It goes through the session folders and checks what runs have the same
467+
'ShimSetting' as the fmaps. If there is no 'ShimSetting' field in the json
468+
file, we'll use the folder name ('func', 'dwi', 'anat') and see which fmap
469+
with a matching '_acq' entity.
470+
471+
If several fmap runs have the same 'ShimSetting' (or '_acq'), it will use
472+
the first one. Because fmaps come in groups (with reversed PE polarity,
473+
or magnitude/phase), it adds the same runs to the 'IntendedFor' of the
474+
corresponding fmaps by checking the '_acq' and '_run' entities.
475+
476+
Note: the logic behind the way we decide how to populate the "IntendedFor"
477+
is: we want all images in the session (except for the fmap images
478+
themselves) to have AT MOST one fmap. (That is, a pair of SE EPI with
479+
reversed polarities, or a magnitude a phase field map). If there are more
480+
than one fmap (more than a fmap pair) with the same acquisition parameters
481+
as, say, a functional run, we will just assign that run to the FIRST pair,
482+
while leaving the other fmap pairs without any assigned images. If the
483+
user's intentions were different, he/she will have to manually edit the
484+
fmap json files.
485+
486+
Parameters:
487+
----------
488+
path_to_bids_session : str or os.path
489+
path to the session folder (or to the subject folder, if there are no
490+
sessions).
491+
"""
492+
lgr.info('')
493+
lgr.info('Adding "IntendedFor" to the fieldmaps in {}.'.format(path_to_bids_session))
494+
495+
shim_key = 'ShimSetting'
496+
lgr.debug('shim_key: {}'.format(shim_key))
497+
498+
# Resolve path (eliminate '..')
499+
path_to_bids_session = op.abspath(path_to_bids_session)
500+
501+
# get the BIDS folder (if "data_folder" includes the session, remove it):
502+
if op.basename(path_to_bids_session).startswith('ses-'):
503+
bids_folder = op.dirname(path_to_bids_session)
504+
else:
505+
bids_folder = path_to_bids_session
506+
507+
fmap_dir = op.join(path_to_bids_session, 'fmap')
508+
if not op.exists(fmap_dir):
509+
lgr.warning('Fmap folder not found in {}.'.format(path_to_bids_session))
510+
lgr.warning('We cannot add the IntendedFor field')
511+
return
512+
513+
# Get a list of all fmap json files in the session:
514+
# (we will remove elements later on, so don't just iterate)
515+
fmap_jsons = sorted([j for j in glob(op.join(path_to_bids_session, 'fmap/*.json'))])
516+
517+
# Get a set with all non-fmap json files in the session (set is easier):
518+
# We also exclude the SBRef files.
519+
session_jsons = set(
520+
j for j in glob(op.join(path_to_bids_session, '*/*.json')) if not (
521+
j in fmap_jsons
522+
# j[:-5] removes the '.json' from the end
523+
or j[-5].endswith('_sbref')
524+
)
525+
)
526+
527+
# Loop through all the fmap json files and, for each one, find which other
528+
# non-fmap images in the session have the same shim settings. Those that
529+
# match are added to the intended_for list and removed from the list of
530+
# non-fmap json files in the session (since they have already assigned to
531+
# a fmap).
532+
# After finishing with all the non-fmap images in the session, we go back
533+
# to the fmap json file list, and find any other fmap json files of the
534+
# same acquisition type and run number (because fmaps have several files:
535+
# normal- and reversed-polarity, or magnitude and phase, etc.) We add the
536+
# same IntendedFor list to those other corresponding fmap json files, and
537+
# remove them from the list of available fmap json files.
538+
# Once we have gone through all the fmap json files, we are done.
539+
jsons_accounted_for = set()
540+
for fm_json in fmap_jsons:
541+
lgr.debug('Looking for runs for {}'.format(fm_json))
542+
data = load_json(fm_json)
543+
if shim_key in data.keys():
544+
fm_shims = data[shim_key]
545+
else:
546+
fm_shims = fm_json.name.split('_acq-')[1].split('_')[0]
547+
if fm_shims.lower() == 'fmri':
548+
fm_shims = 'func'
549+
550+
intended_for = []
551+
for image_json in session_jsons:
552+
data = load_json(image_json)
553+
if shim_key in data.keys():
554+
image_shims = data[shim_key]
555+
else:
556+
# we just use the acquisition type (the name of the folder:
557+
# 'anat', 'func', ...)
558+
image_shims = op.basename(op.dirname(image_json))
559+
if image_shims == fm_shims:
560+
# BIDS specifies that the intended for are:
561+
# - **image** files
562+
# - path relative to the **subject level**
563+
image_json_relative_path = op.relpath(image_json, start=bids_folder)
564+
# image_json_relative_path[:-5] removes the '.json' extension:
565+
intended_for.append(
566+
image_json_relative_path[:-5] + '.nii.gz'
567+
)
568+
jsons_accounted_for.add(image_json)
569+
if len(intended_for) > 0:
570+
fm_json_name = op.basename(fm_json)
571+
# get from "_acq-"/"_run-" to the next "_":
572+
acq_str = '_acq-' + fm_json_name.split('_acq-')[1].split('_')[0]
573+
run_str = '_run-' + fm_json_name.split('_run-')[1].split('_')[0]
574+
# Loop through all the files that have the same "acq-" and "run-"
575+
intended_for = sorted([str(f) for f in intended_for])
576+
for other_fm_json in glob(op.join(path_to_bids_session, 'fmap/*' + acq_str + '*' + run_str + '*.json')):
577+
# add the IntendedFor field to the json file:
578+
add_field_to_json(other_fm_json, {"IntendedFor": intended_for})
579+
fmap_jsons.remove(other_fm_json)
580+
# Remove the runs accounted for from the session_jsons list, so that
581+
# we don't assign another fmap to this image:
582+
session_jsons -= jsons_accounted_for

heudiconv/tests/test_bids.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import json
2+
import os
3+
import os.path as op
4+
from random import random
5+
6+
from heudiconv.utils import (
7+
load_json,
8+
create_tree,
9+
)
10+
from heudiconv.bids import populate_intended_for
11+
12+
import pytest
13+
14+
15+
def create_dummy_bids_session(session_path):
16+
"""
17+
Creates a dummy BIDS session, with slim json files and empty nii.gz
18+
Parameters:
19+
----------
20+
session_path : str or os.path
21+
path to the session (or subject) level folder
22+
"""
23+
session_parent, session_basename = op.split(session_path)
24+
if session_basename.startswith('ses-'):
25+
subj_folder = session_parent
26+
prefix = op.split(session_parent)[1] + '_' + session_basename
27+
else:
28+
subj_folder = session_path
29+
prefix = session_basename
30+
31+
# Generate some random ShimSettings:
32+
shim_length = 6
33+
dwi_shims = ['{0:.4f}'.format(random()) for i in range(shim_length)]
34+
func_shims_A = ['{0:.4f}'.format(random()) for i in range(shim_length)]
35+
func_shims_B = ['{0:.4f}'.format(random()) for i in range(shim_length)]
36+
37+
# Dict with the file structure for the session:
38+
# -anat:
39+
anat_struct = {
40+
'{p}_{m}.nii.gz'.format(p=prefix, m=mod): '' for mod in ['T1w', 'T2w']
41+
}
42+
anat_struct.update({
43+
# empty json:
44+
'{p}_{m}.json'.format(p=prefix, m=mod): {} for mod in ['T1w', 'T2w']
45+
})
46+
# -dwi:
47+
dwi_struct = {
48+
'{p}_acq-A_run-{r}_dwi.nii.gz'.format(p=prefix, r=runNo): '' for runNo in [1, 2]
49+
}
50+
dwi_struct.update({
51+
'{p}_acq-A_run-{r}_dwi.json'.format(p=prefix, r=runNo): {'ShimSetting': dwi_shims} for runNo in [1, 2]
52+
})
53+
# -func:
54+
func_struct = {
55+
'{p}_acq-{a}_bold.nii.gz'.format(p=prefix, a=acq): '' for acq in ['A', 'B', 'unmatched']
56+
}
57+
func_struct.update({
58+
'{p}_acq-A_bold.json'.format(p=prefix): {'ShimSetting': func_shims_A},
59+
'{p}_acq-B_bold.json'.format(p=prefix): {'ShimSetting': func_shims_B},
60+
'{p}_acq-unmatched_bold.json'.format(p=prefix): {
61+
'ShimSetting': ['{0:.4f}'.format(random()) for i in range(shim_length)]
62+
},
63+
})
64+
# -fmap:
65+
fmap_struct = {
66+
'{p}_acq-{a}_dir-{d}_run-{r}_epi.nii.gz'.format(p=prefix, a=acq, d=d, r=r): ''
67+
for acq in ['dwi', 'fMRI']
68+
for d in ['AP', 'PA']
69+
for r in [1, 2]
70+
}
71+
fmap_struct.update({
72+
'{p}_acq-dwi_dir-{d}_run-{r}_epi.json'.format(p=prefix, d=d, r=r): {'ShimSetting': dwi_shims}
73+
for d in ['AP', 'PA']
74+
for r in [1, 2]
75+
})
76+
fmap_struct.update({
77+
'{p}_acq-fMRI_dir-{d}_run-{r}_epi.json'.format(p=prefix, d=d, r=r): {'ShimSetting': shims}
78+
for r, shims in {'1': func_shims_A, '2': func_shims_B}.items()
79+
for d in ['AP', 'PA']
80+
})
81+
# structure for the full session:
82+
session_struct = {
83+
'anat': anat_struct,
84+
'dwi': dwi_struct,
85+
'func': func_struct,
86+
'fmap': fmap_struct
87+
}
88+
89+
create_tree(session_path, session_struct)
90+
return session_struct
91+
92+
93+
# Test two scenarios:
94+
# -study without sessions
95+
# -study with sessions
96+
# The "expected_prefix" (the beginning of the path to the "IntendedFor")
97+
# should be relative to the subject level
98+
@pytest.mark.parametrize(
99+
"folder, expected_prefix", [
100+
('no_sessions/sub-1', ''),
101+
('sessions/sub-1/ses-pre', 'ses-pre')
102+
]
103+
)
104+
def test_populate_intended_for(tmpdir, folder, expected_prefix):
105+
"""
106+
Test populate_intended_for.
107+
Parameters:
108+
----------
109+
tmpdir
110+
folder : str or os.path
111+
path to BIDS study to be simulated, relative to tmpdir
112+
expected_prefix : str
113+
expected start of the "IntendedFor" elements
114+
"""
115+
116+
session_folder = op.join(tmpdir, folder)
117+
session_struct = create_dummy_bids_session(session_folder)
118+
populate_intended_for(session_folder)
119+
120+
run_prefix = 'sub-1' + ('_' + expected_prefix if expected_prefix else '')
121+
# dict, with fmap names as keys and the expected "IntendedFor" as values.
122+
expected_result = {
123+
'{p}_acq-dwi_dir-{d}_run-{r}_epi.json'.format(p=run_prefix, d=d, r=runNo):
124+
intended_for
125+
# (runNo=1 goes with the long list, runNo=2 goes with None):
126+
for runNo, intended_for in zip(
127+
[1, 2],
128+
[[op.join(expected_prefix, 'dwi', '{p}_acq-A_run-{r}_dwi.nii.gz'.format(p=run_prefix, r=r)) for r in [1,2]],
129+
None]
130+
)
131+
for d in ['AP', 'PA']
132+
}
133+
expected_result.update(
134+
{
135+
'{p}_acq-fMRI_dir-{d}_run-{r}_epi.json'.format(p=run_prefix, d=d, r=runNo):
136+
[
137+
op.join(expected_prefix,
138+
'func',
139+
'{p}_acq-{a}_bold.nii.gz'.format(p=run_prefix, a=acq))
140+
]
141+
# runNo=1 goes with acq='A'; runNo=2 goes with acq='B'
142+
for runNo, acq in zip([1, 2], ['A', 'B'])
143+
for d in['AP', 'PA']
144+
}
145+
)
146+
147+
# Now, loop through the jsons in the fmap folder and make sure it matches
148+
# the expected result:
149+
fmap_folder = op.join(session_folder, 'fmap')
150+
for j in session_struct['fmap'].keys():
151+
if j.endswith('.json'):
152+
assert j in expected_result.keys()
153+
data = load_json(op.join(fmap_folder, j))
154+
if expected_result[j]:
155+
assert data['IntendedFor'] == expected_result[j]
156+
# Also, make sure the run with random shims is not here:
157+
# (It is assured by the assert above, but let's make it
158+
# explicit)
159+
assert '{p}_acq-unmatched_bold.nii.gz'.format(p=run_prefix) not in data['IntendedFor']
160+
else:
161+
assert 'IntendedFor' not in data.keys()

heudiconv/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,11 @@ def create_tree(path, tree, archives_leading_dir=True):
503503
executable = False
504504
name = file_
505505
full_name = op.join(path, name)
506-
if isinstance(load, (tuple, list, dict)):
506+
if name.endswith('.json') and isinstance(load, dict):
507+
# (For a json file, we expect the content to be a dictionary, so
508+
# don't continue creating a tree, but just write dict to file)
509+
save_json(full_name, load, indent=4)
510+
elif isinstance(load, (tuple, list, dict)):
507511
# if name.endswith('.tar.gz') or name.endswith('.tar') or name.endswith('.zip'):
508512
# create_tree_archive(path, name, load, archives_leading_dir=archives_leading_dir)
509513
# else:

0 commit comments

Comments
 (0)