Skip to content

Commit 784e946

Browse files
author
Michael Dayan
committed
Add matching by custom label
1 parent 717f500 commit 784e946

File tree

4 files changed

+217
-14
lines changed

4 files changed

+217
-14
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: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
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
911
)
1012
from datetime import (datetime,
1113
timedelta,
@@ -15,6 +17,8 @@
1517
from glob import glob
1618

1719
import nibabel
20+
import string
21+
import numpy as np
1822
from numpy import testing as np_testing
1923

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

4751
import pytest
4852

53+
def gen_rand_label(label_size, label_seed):
54+
np.random.seed(label_seed)
55+
rand_char = ''.join(choice(string.ascii_letters) for _ in range(label_size-1))
56+
np.random.seed(label_seed)
57+
rand_num = choice(string.digits)
58+
return rand_char + rand_num
59+
4960
def test_maybe_na():
5061
for na in '', ' ', None, 'n/a', 'N/A', 'NA':
5162
assert maybe_na(na) == 'n/a'
@@ -86,7 +97,7 @@ def test_get_shim_setting(tmpdir):
8697
assert get_shim_setting(json_name) == A_SHIM
8798

8899

89-
def test_get_key_info_for_fmap_assignment(tmpdir):
100+
def test_get_key_info_for_fmap_assignment(tmpdir, label_size=4, label_seed=42):
90101
"""
91102
Test get_key_info_for_fmap_assignment
92103
"""
@@ -123,9 +134,9 @@ def test_get_key_info_for_fmap_assignment(tmpdir):
123134
)
124135
assert key_info == [KeyInfoForForce]
125136

126-
# 5) matching_parameter = 'AcquisitionLabel'
137+
# 5) matching_parameter = 'ModalityAcquisitionLabel'
127138
for d in ['fmap', 'func', 'dwi', 'anat']:
128-
os.makedirs(op.join(str(tmpdir), d))
139+
Path(op.join(str(tmpdir), d)).mkdir(parents=True, exist_ok=True)
129140
for (dirname, fname, expected_key_info) in [
130141
('fmap', 'sub-foo_acq-fmri_epi.json', 'func'),
131142
('fmap', 'sub-foo_acq-bold_epi.json', 'func'),
@@ -140,7 +151,24 @@ def test_get_key_info_for_fmap_assignment(tmpdir):
140151
json_name = op.join(str(tmpdir), dirname, fname)
141152
save_json(json_name, {SHIM_KEY: A_SHIM})
142153
assert [expected_key_info] == get_key_info_for_fmap_assignment(
143-
json_name, matching_parameter='AcquisitionLabel'
154+
json_name, matching_parameter='ModalityAcquisitionLabel'
155+
)
156+
157+
# 6) matching_parameter = 'CustomAcquisitionLabel'
158+
A_LABEL = gen_rand_label(label_size, label_seed)
159+
for d in ['fmap', 'func', 'dwi', 'anat']:
160+
Path(op.join(str(tmpdir), d)).mkdir(parents=True, exist_ok=True)
161+
162+
for (dirname, fname, expected_key_info) in [
163+
('fmap', f'sub-foo_acq-{A_LABEL}_epi.json', A_LABEL),
164+
('func', f'sub-foo_task-{A_LABEL}_acq-foo_bold.json', A_LABEL),
165+
('dwi', f'sub-foo_acq-{A_LABEL}_dwi.json', A_LABEL),
166+
('anat', f'sub-foo_acq-{A_LABEL}_T1w.json', A_LABEL),
167+
]:
168+
json_name = op.join(str(tmpdir), dirname, fname)
169+
save_json(json_name, {SHIM_KEY: A_SHIM})
170+
assert [expected_key_info] == get_key_info_for_fmap_assignment(
171+
json_name, matching_parameter='CustomAcquisitionLabel'
144172
)
145173

146174
# Finally: invalid matching_parameters:
@@ -357,7 +385,7 @@ def create_dummy_pepolar_bids_session(session_path):
357385
return session_struct, expected_result, expected_fmap_groups, expected_compatible_fmaps
358386

359387

360-
def create_dummy_no_shim_settings_bids_session(session_path):
388+
def create_dummy_no_shim_settings_bids_session(session_path, label_seed=42, label_size=4):
361389
"""
362390
Creates a dummy BIDS session, with slim json files and empty nii.gz
363391
The fmap files are pepolar
@@ -500,6 +528,154 @@ def create_dummy_no_shim_settings_bids_session(session_path):
500528

501529
return session_struct, expected_result, expected_fmap_groups, expected_compatible_fmaps
502530

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

504680
def create_dummy_magnitude_phase_bids_session(session_path):
505681
"""
@@ -685,7 +861,8 @@ def test_find_fmap_groups(tmpdir, simulation_function):
685861
@pytest.mark.parametrize(
686862
"simulation_function, match_param", [
687863
(create_dummy_pepolar_bids_session, 'Shims'),
688-
(create_dummy_no_shim_settings_bids_session, 'AcquisitionLabel'),
864+
(create_dummy_no_shim_settings_bids_session, 'ModalityAcquisitionLabel'),
865+
(create_dummy_no_shim_settings_custom_label_bids_session, 'CustomAcquisitionLabel'),
689866
(create_dummy_magnitude_phase_bids_session, 'Shims')
690867
]
691868
)
@@ -726,7 +903,8 @@ def test_find_compatible_fmaps_for_run(tmpdir, simulation_function, match_param)
726903
for folder, expected_prefix in zip(['no_sessions/sub-1', 'sessions/sub-1/ses-pre'], ['', 'ses-pre'])
727904
for sim_func, mp in [
728905
(create_dummy_pepolar_bids_session, 'Shims'),
729-
(create_dummy_no_shim_settings_bids_session, 'AcquisitionLabel'),
906+
(create_dummy_no_shim_settings_bids_session, 'ModalityAcquisitionLabel'),
907+
(create_dummy_no_shim_settings_custom_label_bids_session, 'CustomAcquisitionLabel'),
730908
(create_dummy_magnitude_phase_bids_session, 'Shims')
731909
]
732910
]
@@ -816,7 +994,8 @@ def test_select_fmap_from_compatible_groups(tmpdir, folder, expected_prefix, sim
816994
for folder, expected_prefix in zip(['no_sessions/sub-1', 'sessions/sub-1/ses-pre'], ['', 'ses-pre'])
817995
for sim_func, mp in [
818996
(create_dummy_pepolar_bids_session, 'Shims'),
819-
(create_dummy_no_shim_settings_bids_session, 'AcquisitionLabel'),
997+
(create_dummy_no_shim_settings_bids_session, 'ModalityAcquisitionLabel'),
998+
(create_dummy_no_shim_settings_custom_label_bids_session, 'CustomAcquisitionLabel'),
820999
(create_dummy_magnitude_phase_bids_session, 'Shims')
8211000
]
8221001
]

0 commit comments

Comments
 (0)