Skip to content

Commit 4b23544

Browse files
authored
Merge pull request #13 from neurorepro/adds_populate_intended_for
Add matching by custom label Thank you, @neurorepro
2 parents 717f500 + 547dafd commit 4b23544

File tree

4 files changed

+228
-15
lines changed

4 files changed

+228
-15
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@
66
venvs/
77
_build/
88
build/
9+
.vscode/
10+

docs/heuristics.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,16 @@ The parameters that can be specified and the allowed options are defined in ``bi
117117
* ``'ImagingVolume'``: both ``fmaps`` and images will need to have the same the imaging
118118
volume (the header affine transformation: position, orientation and voxel size, as well
119119
as number of voxels along each dimensions).
120-
* ``'AcquisitionLabel'``: it checks for what modality (``anat``, ``func``, ``dwi``) each
121-
``fmap`` is intended by checking the ``_acq-`` label in the filename
120+
* ``'ModalityAcquisitionLabel'``: it checks for what modality (``anat``, ``func``, ``dwi``) each
121+
``fmap`` is intended by checking the ``_acq-`` label in the ``fmap`` filename and finding
122+
corresponding modalities (e.g. ``_acq-fmri``, ``_acq-bold`` and ``_acq-func`` will be matched
123+
with the ``func`` modality)
124+
* ``'CustomAcquisitionLabel'``: it checks for what modality images each ``fmap`` is intended
125+
by checking the ``_acq-`` custom label (e.g. ``_acq-XYZ42``) in the ``fmap`` filename, and
126+
matching it with:
127+
- the corresponding modality image ``_acq-`` label for modalities other than ``func``
128+
(e.g. ``_acq-XYZ42`` for ``dwi`` images)
129+
- the corresponding image ``_task-`` label for the ``func`` modality (e.g. ``_task-XYZ42``)
122130
* ``'Force'``: forces ``heudiconv`` to consider any ``fmaps`` in the session to be
123131
suitable for any image, no matter what the imaging parameters are.
124132

heudiconv/bids.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ class BIDSError(Exception):
6161
AllowedFmapParameterMatching = [
6262
'Shims',
6363
'ImagingVolume',
64-
'AcquisitionLabel',
64+
'ModalityAcquisitionLabel',
65+
'CustomAcquisitionLabel',
6566
'Force',
6667
]
6768
# Key info returned by get_key_info_for_fmap_assignment when
@@ -625,7 +626,7 @@ def get_key_info_for_fmap_assignment(json_file, matching_parameter):
625626
nifti_file = nifti_file[0]
626627
nifti_header = nb_load(nifti_file).header
627628
key_info = [nifti_header.get_best_affine(), nifti_header.get_data_shape()[:3]]
628-
elif matching_parameter == 'AcquisitionLabel':
629+
elif matching_parameter == 'ModalityAcquisitionLabel':
629630
# Check the acq label for the fmap and the modality for others:
630631
modality = op.basename(op.dirname(json_file))
631632
if modality == 'fmap':
@@ -639,6 +640,16 @@ def get_key_info_for_fmap_assignment(json_file, matching_parameter):
639640
key_info = ['anat']
640641
else:
641642
key_info = [modality]
643+
elif matching_parameter == 'CustomAcquisitionLabel':
644+
modality = op.basename(op.dirname(json_file))
645+
if modality == 'func':
646+
# extract the <task> entity:
647+
custom_label = BIDSFile.parse(op.basename(json_file))['task']
648+
else:
649+
# extract the <acq> entity:
650+
custom_label = BIDSFile.parse(op.basename(json_file))['acq']
651+
# Get the custom acquisition label, acq_label is None if no custom field found
652+
key_info = [custom_label]
642653
elif matching_parameter == 'Force':
643654
# We want to force the matching, so just return some string
644655
# regardless of the image
@@ -649,7 +660,6 @@ def get_key_info_for_fmap_assignment(json_file, matching_parameter):
649660

650661
return key_info
651662

652-
653663
def find_compatible_fmaps_for_run(json_file, fmap_groups, matching_parameters):
654664
"""
655665
Finds compatible fmaps for a given run, for populate_intended_for.
@@ -685,10 +695,14 @@ def find_compatible_fmaps_for_run(json_file, fmap_groups, matching_parameters):
685695
# the fmaps in the group:
686696
compatible = False
687697
for param in matching_parameters:
698+
json_info_1st_item = json_info[param][0]
688699
fm_info = get_key_info_for_fmap_assignment(fm_group[0], param)
689700
# for the case in which key_info is a list of strings:
690-
if isinstance(json_info[param][0], str):
701+
if isinstance(json_info_1st_item, str):
691702
compatible = json_info[param] == fm_info
703+
# for the case when no key info was found (e.g. "acq" field does not exist)
704+
elif json_info_1st_item is None:
705+
compatible = False
692706
else:
693707
# allow for tiny differences between the affines etc
694708
compatible = all(np.allclose(x, y) for x, y in zip(json_info[param], fm_info))

heudiconv/tests/test_bids.py

Lines changed: 198 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
import re
55
import os
66
import os.path as op
7+
from pathlib import Path
78
from random import (random,
89
shuffle,
10+
choice,
11+
seed
912
)
1013
from datetime import (datetime,
1114
timedelta,
@@ -15,6 +18,7 @@
1518
from glob import glob
1619

1720
import nibabel
21+
import string
1822
from numpy import testing as np_testing
1923

2024
from heudiconv.utils import (
@@ -46,6 +50,15 @@
4650

4751
import pytest
4852

53+
def gen_rand_label(label_size, label_seed, seed_stdout=True):
54+
seed(label_seed)
55+
rand_char = ''.join(choice(string.ascii_letters) for _ in range(label_size-1))
56+
seed(label_seed)
57+
rand_num = choice(string.digits)
58+
if seed_stdout:
59+
print(f'Seed used to generate custom label: {label_seed}')
60+
return rand_char + rand_num
61+
4962
def test_maybe_na():
5063
for na in '', ' ', None, 'n/a', 'N/A', 'NA':
5164
assert maybe_na(na) == 'n/a'
@@ -67,7 +80,7 @@ def test_treat_age():
6780

6881
SHIM_LENGTH = 6
6982
TODAY = datetime.today()
70-
83+
LABEL_SEED = int.from_bytes(os.urandom(8), byteorder="big")
7184

7285
A_SHIM = [random() for i in range(SHIM_LENGTH)]
7386
def test_get_shim_setting(tmpdir):
@@ -86,9 +99,13 @@ def test_get_shim_setting(tmpdir):
8699
assert get_shim_setting(json_name) == A_SHIM
87100

88101

89-
def test_get_key_info_for_fmap_assignment(tmpdir):
102+
def test_get_key_info_for_fmap_assignment(tmpdir, label_size=4, label_seed=LABEL_SEED):
90103
"""
91-
Test get_key_info_for_fmap_assignment
104+
Test get_key_info_for_fmap_assignment.
105+
106+
label_size and label_seed are used for the "CustomAcquisitionLabel" matching
107+
parameter. label_size is the size of the random label while label_seed is
108+
the seed for the random label creation.
92109
"""
93110

94111
nifti_file = op.join(TESTS_DATA_PATH, 'sample_nifti.nii.gz')
@@ -123,9 +140,9 @@ def test_get_key_info_for_fmap_assignment(tmpdir):
123140
)
124141
assert key_info == [KeyInfoForForce]
125142

126-
# 5) matching_parameter = 'AcquisitionLabel'
143+
# 5) matching_parameter = 'ModalityAcquisitionLabel'
127144
for d in ['fmap', 'func', 'dwi', 'anat']:
128-
os.makedirs(op.join(str(tmpdir), d))
145+
Path(op.join(str(tmpdir), d)).mkdir(parents=True, exist_ok=True)
129146
for (dirname, fname, expected_key_info) in [
130147
('fmap', 'sub-foo_acq-fmri_epi.json', 'func'),
131148
('fmap', 'sub-foo_acq-bold_epi.json', 'func'),
@@ -140,7 +157,24 @@ def test_get_key_info_for_fmap_assignment(tmpdir):
140157
json_name = op.join(str(tmpdir), dirname, fname)
141158
save_json(json_name, {SHIM_KEY: A_SHIM})
142159
assert [expected_key_info] == get_key_info_for_fmap_assignment(
143-
json_name, matching_parameter='AcquisitionLabel'
160+
json_name, matching_parameter='ModalityAcquisitionLabel'
161+
)
162+
163+
# 6) matching_parameter = 'CustomAcquisitionLabel'
164+
A_LABEL = gen_rand_label(label_size, label_seed)
165+
for d in ['fmap', 'func', 'dwi', 'anat']:
166+
Path(op.join(str(tmpdir), d)).mkdir(parents=True, exist_ok=True)
167+
168+
for (dirname, fname, expected_key_info) in [
169+
('fmap', f'sub-foo_acq-{A_LABEL}_epi.json', A_LABEL),
170+
('func', f'sub-foo_task-{A_LABEL}_acq-foo_bold.json', A_LABEL),
171+
('dwi', f'sub-foo_acq-{A_LABEL}_dwi.json', A_LABEL),
172+
('anat', f'sub-foo_acq-{A_LABEL}_T1w.json', A_LABEL),
173+
]:
174+
json_name = op.join(str(tmpdir), dirname, fname)
175+
save_json(json_name, {SHIM_KEY: A_SHIM})
176+
assert [expected_key_info] == get_key_info_for_fmap_assignment(
177+
json_name, matching_parameter='CustomAcquisitionLabel'
144178
)
145179

146180
# Finally: invalid matching_parameters:
@@ -500,6 +534,158 @@ def create_dummy_no_shim_settings_bids_session(session_path):
500534

501535
return session_struct, expected_result, expected_fmap_groups, expected_compatible_fmaps
502536

537+
def create_dummy_no_shim_settings_custom_label_bids_session(session_path, label_size=4, label_seed=LABEL_SEED):
538+
"""
539+
Creates a dummy BIDS session, with slim json files and empty nii.gz
540+
The fmap files are pepolar
541+
The json files don't have ShimSettings
542+
The fmap files have a custom ACQ label matching:
543+
- TASK label for <func> modality
544+
- ACQ label for any other modality (e.g. <dwi>)
545+
546+
Parameters:
547+
----------
548+
session_path : str or os.path
549+
path to the session (or subject) level folder
550+
label_size : int, optional
551+
size of the random label
552+
label_seed : int, optional
553+
seed for the random label creation
554+
555+
Returns:
556+
-------
557+
session_struct : dict
558+
Structure of the directory that was created
559+
expected_result : dict
560+
dictionary with fmap names as keys and the expected "IntendedFor" as
561+
values.
562+
None
563+
it returns a third argument (None) to have the same signature as
564+
create_dummy_pepolar_bids_session
565+
"""
566+
session_parent, session_basename = op.split(session_path.rstrip(op.sep))
567+
if session_basename.startswith('ses-'):
568+
prefix = op.split(session_parent)[1] + '_' + session_basename
569+
else:
570+
prefix = session_basename
571+
572+
# 1) Simulate the file structure for a session:
573+
574+
# Dict with the file structure for the session.
575+
# All json files will be empty.
576+
# -anat:
577+
anat_struct = {
578+
f'{prefix}_{mod}.{ext}': dummy_content
579+
for ext, dummy_content in zip(['nii.gz', 'json'], ['', {}])
580+
for mod in ['T1w', 'T2w']
581+
}
582+
# -dwi:
583+
label_seed += 1
584+
DWI_LABEL = gen_rand_label(label_size, label_seed)
585+
dwi_struct = {
586+
f'{prefix}_acq-{DWI_LABEL}_run-{runNo}_dwi.{ext}': dummy_content
587+
for ext, dummy_content in zip(['nii.gz', 'json'], ['', {}])
588+
for runNo in [1, 2]
589+
}
590+
# -func:
591+
label_seed += 1
592+
FUNC_LABEL = gen_rand_label(label_size, label_seed)
593+
func_struct = {
594+
f'{prefix}_task-{FUNC_LABEL}_acq-{acq}_bold.{ext}': dummy_content
595+
for ext, dummy_content in zip(['nii.gz', 'json'], ['', {}])
596+
for acq in ['A', 'B']
597+
}
598+
# -fmap:
599+
fmap_struct = {
600+
f'{prefix}_acq-{acq}_dir-{d}_run-{r}_epi.{ext}': dummy_content
601+
for ext, dummy_content in zip(['nii.gz', 'json'], ['', {}])
602+
for acq in [DWI_LABEL, FUNC_LABEL]
603+
for d in ['AP', 'PA']
604+
for r in [1, 2]
605+
}
606+
expected_fmap_groups = {
607+
f'{prefix}_acq-{acq}_run-{r}_epi': [
608+
f'{op.join(session_path, "fmap", prefix)}_acq-{acq}_dir-{d}_run-{r}_epi.json'
609+
for d in ['AP', 'PA']
610+
]
611+
for acq in [DWI_LABEL, FUNC_LABEL]
612+
for r in [1, 2]
613+
}
614+
615+
# structure for the full session (init the OrderedDict as a list to preserve order):
616+
session_struct = OrderedDict([
617+
('fmap', fmap_struct),
618+
('anat', anat_struct),
619+
('dwi', dwi_struct),
620+
('func', func_struct),
621+
])
622+
# add "_scans.tsv" file to the session_struct
623+
scans_file_content = generate_scans_tsv(session_struct)
624+
session_struct.update({'{p}_scans.tsv'.format(p=prefix): scans_file_content})
625+
626+
create_tree(session_path, session_struct)
627+
628+
# 2) Now, let's create a dict with the fmap groups compatible for each run
629+
# -anat: empty
630+
expected_compatible_fmaps = {
631+
f'{op.join(session_path, "anat", prefix)}_{mod}.json': {}
632+
for mod in ['T1w', 'T2w']
633+
}
634+
# -dwi: each of the runs (1, 2) is compatible with both of the dwi fmaps (1, 2):
635+
expected_compatible_fmaps.update({
636+
f'{op.join(session_path, "dwi", prefix)}_acq-{DWI_LABEL}_run-{runNo}_dwi.json': {
637+
key: val for key, val in expected_fmap_groups.items() if key in [
638+
f'{prefix}_acq-{DWI_LABEL}_run-{r}_epi' for r in [1, 2]
639+
]
640+
}
641+
for runNo in [1, 2]
642+
})
643+
# -func: each of the acq (A, B) is compatible w/ both fmap fMRI runs (1, 2)
644+
expected_compatible_fmaps.update({
645+
f'{op.join(session_path, "func", prefix)}_task-{FUNC_LABEL}_acq-{acq}_bold.json': {
646+
key: val for key, val in expected_fmap_groups.items() if key in [
647+
f'{prefix}_acq-{FUNC_LABEL}_run-{r}_epi' for r in [1, 2]
648+
]
649+
}
650+
for acq in ['A', 'B']
651+
})
652+
653+
# 3) Now, let's create a dict with what we expect for the "IntendedFor":
654+
# NOTE: The "expected_prefix" (the beginning of the path to the
655+
# "IntendedFor") should be relative to the subject level (see:
656+
# https://bids-specification.readthedocs.io/en/stable/04-modality-specific-files/01-magnetic-resonance-imaging-data.html#fieldmap-data)
657+
658+
sub_match = re.findall('(sub-([a-zA-Z0-9]*))', session_path)
659+
sub_str = sub_match[0][0]
660+
expected_prefix = session_path.split(sub_str)[-1].split(op.sep)[-1]
661+
662+
# dict, with fmap names as keys and the expected "IntendedFor" as values.
663+
expected_result = {
664+
# (runNo=1 goes with the long list, runNo=2 goes with None):
665+
f'{prefix}_acq-{DWI_LABEL}_dir-{d}_run-{runNo}_epi.json': intended_for
666+
for runNo, intended_for in zip(
667+
[1, 2],
668+
[[op.join(expected_prefix, 'dwi', f'{prefix}_acq-{DWI_LABEL}_run-{r}_dwi.nii.gz') for r in [1,2]],
669+
None]
670+
)
671+
for d in ['AP', 'PA']
672+
}
673+
expected_result.update(
674+
{
675+
# The first "fMRI" run gets all files in the "func" folder;
676+
# the second shouldn't get any.
677+
f'{prefix}_acq-{FUNC_LABEL}_dir-{d}_run-{runNo}_epi.json': intended_for
678+
for runNo, intended_for in zip(
679+
[1, 2],
680+
[[op.join(expected_prefix, 'func', f'{prefix}_task-{FUNC_LABEL}_acq-{acq}_bold.nii.gz')
681+
for acq in ['A', 'B']],
682+
None]
683+
)
684+
for d in ['AP', 'PA']
685+
}
686+
)
687+
688+
return session_struct, expected_result, expected_fmap_groups, expected_compatible_fmaps
503689

504690
def create_dummy_magnitude_phase_bids_session(session_path):
505691
"""
@@ -685,7 +871,8 @@ def test_find_fmap_groups(tmpdir, simulation_function):
685871
@pytest.mark.parametrize(
686872
"simulation_function, match_param", [
687873
(create_dummy_pepolar_bids_session, 'Shims'),
688-
(create_dummy_no_shim_settings_bids_session, 'AcquisitionLabel'),
874+
(create_dummy_no_shim_settings_bids_session, 'ModalityAcquisitionLabel'),
875+
(create_dummy_no_shim_settings_custom_label_bids_session, 'CustomAcquisitionLabel'),
689876
(create_dummy_magnitude_phase_bids_session, 'Shims')
690877
]
691878
)
@@ -726,7 +913,8 @@ def test_find_compatible_fmaps_for_run(tmpdir, simulation_function, match_param)
726913
for folder, expected_prefix in zip(['no_sessions/sub-1', 'sessions/sub-1/ses-pre'], ['', 'ses-pre'])
727914
for sim_func, mp in [
728915
(create_dummy_pepolar_bids_session, 'Shims'),
729-
(create_dummy_no_shim_settings_bids_session, 'AcquisitionLabel'),
916+
(create_dummy_no_shim_settings_bids_session, 'ModalityAcquisitionLabel'),
917+
(create_dummy_no_shim_settings_custom_label_bids_session, 'CustomAcquisitionLabel'),
730918
(create_dummy_magnitude_phase_bids_session, 'Shims')
731919
]
732920
]
@@ -816,7 +1004,8 @@ def test_select_fmap_from_compatible_groups(tmpdir, folder, expected_prefix, sim
8161004
for folder, expected_prefix in zip(['no_sessions/sub-1', 'sessions/sub-1/ses-pre'], ['', 'ses-pre'])
8171005
for sim_func, mp in [
8181006
(create_dummy_pepolar_bids_session, 'Shims'),
819-
(create_dummy_no_shim_settings_bids_session, 'AcquisitionLabel'),
1007+
(create_dummy_no_shim_settings_bids_session, 'ModalityAcquisitionLabel'),
1008+
(create_dummy_no_shim_settings_custom_label_bids_session, 'CustomAcquisitionLabel'),
8201009
(create_dummy_magnitude_phase_bids_session, 'Shims')
8211010
]
8221011
]

0 commit comments

Comments
 (0)