Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions niworkflows/utils/tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Generating a BIDS skeleton
Creating a BIDS skeleton can be useful when testing methods that operate on diverse BIDS datasets.
This readme contains some information on using `niworkflows.utils.tests.bids.generate_bids_skeleton()` to create a BIDS skeleton.


## Example configuration

> sessions.yaml

```yaml
dataset_description:
Name: sessions
BIDSVersion: 1.6.0
'01':
- session: pre
anat:
- suffix: T1w
metadata:
EchoTime: 1
func:
- task: rest
echo: 1
suffix: bold
metadata:
RepetitionTime: 0.8
EchoTime: 0.5
TotalReadoutTime: 0.5
PhaseEncodingDirection: j
- task: rest
echo: 2
suffix: bold
metadata:
RepetitionTime: 0.8
EchoTime: 0.7
TotalReadoutTime: 0.5
PhaseEncodingDirection: j
- session: post
anat:
suffix: T2w
metadata:
EchoTime: 2
func:
task: rest
acq: lowres
suffix: bold
metadata:
RepetitionTime: 0.8
PhaseEncodingDirection: j-
'02': "*"
'03': "*"
```


### Keys

#### Top level keys
| Key | Description | Required | Values |
| --- | ----------- | -------- | ------ |
| `dataset_description` | Top level JSON to describe the dataset | Optional | `metadata`* |
| `participant` | Participant ID (`sub` prefix not required) | Required | One or more `session`s or `*`**. |

>\* Metadata must include the following fields: `Name`, `BIDSVersion` \
\*\* The `*` will recursively copy all values from the `participant` above.

If `dataset_description` is not specified, a default will be created.

#### Other keys
| Key | Description | Required | Values |
| --- | ----------- | -------- | ------ |
| `session` | A logical grouping of data | Required | One or more `datatype`s + optional `session` field (if multi-session) |
| `datatype` | A functional group of similar data types | Required | One or more `filepair`s |
| `filepair` | Data and associated metadata | Required | BIDS `entities`* + optional `metadata` |
| `metadata` | Sidecar JSON values corresponding to the data file | Optional | Any BIDS field/value pairs (information is dumped to a JSON file) |


>\* The only required `entity` is `suffix`. Ordering is respected, so ensure field order respects BIDS specification.
96 changes: 96 additions & 0 deletions niworkflows/utils/tests/bids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from copy import deepcopy
import json
from pathlib import Path
import yaml


def generate_bids_skeleton(target_path, bids_config):
"""
Converts a BIDS directory in dictionary form to a file structure.

The BIDS configuration can either be a YAML or JSON file, or :obj:dict: object.

Parameters
----------
target_path : str
Path to generate BIDS directory at (must not exist)
bids_config : dict or str
Configuration on how to create the BIDS directory.
"""

if isinstance(bids_config, dict):
# ensure dictionary remains unaltered
bids_dict = deepcopy(bids_config)
elif isinstance(bids_config, str):
bids_config = Path(bids_config).read_text()
try:
bids_dict = json.loads(bids_config)
except json.JSONDecodeError:
bids_dict = yaml.load(bids_config, Loader=yaml.Loader)

_bids_dict = deepcopy(bids_dict)
root = Path(target_path).absolute()
root.mkdir(parents=True)

desc = bids_dict.pop("dataset_description", None)
if desc is None:
# default description
desc = {"Name": "Default", "BIDSVersion": "1.6.0"}
to_json(root / "dataset_description.json", desc)

cached_subject_data = None
for subject, sessions in bids_dict.items():
bids_subject = subject if subject.startswith("sub-") else f"sub-{subject}"
subj_path = root / bids_subject
subj_path.mkdir(exist_ok=True)

if sessions == "*": # special case to copy previous subject data
sessions = cached_subject_data.copy()

if isinstance(sessions, dict): # single session
sessions.update({"session": None})
sessions = [sessions]

cached_subject_data = deepcopy(sessions)
for session in sessions:

ses_name = session.pop("session", None)
if ses_name is not None:
bids_session = ses_name if ses_name.startswith("ses-") else f"ses-{ses_name}"
bids_prefix = f"{bids_subject}_{bids_session}"
curr_path = subj_path / bids_session
curr_path.mkdir(exist_ok=True)
else:
bids_prefix = bids_subject
curr_path = subj_path

# create modalities
for modality, files in session.items():
modality_path = curr_path / modality
modality_path.mkdir(exist_ok=True)

if isinstance(files, dict): # single file / metadata combo
files = [files]

for bids_file in files:
metadata = bids_file.pop("metadata", None)
suffix = bids_file.pop("suffix")
entities = combine_entities(**bids_file)
nii_file = modality_path / f"{bids_prefix}{entities}_{suffix}.nii.gz"
nii_file.touch()

if metadata is not None:
nii_metadata = nii_file.parent / nii_file.name.replace("nii.gz", "json")
to_json(nii_metadata, metadata)

return _bids_dict


def to_json(filename, data):
filename = Path(filename)
filename.write_text(json.dumps(data))
return filename


def combine_entities(**entities):
return f"_{'_'.join([f'{lab}-{val}' for lab, val in entities.items()])}" if entities else ""
135 changes: 135 additions & 0 deletions niworkflows/utils/tests/test_bids_skeleton.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import pytest
from bids import BIDSLayout

from .bids import generate_bids_skeleton


bids_dir_sessions = {
"dataset_description": {"Name": "sample", "BIDSVersion": "1.6.0"},
"01": [ # composed of dictionaries, pertaining to sessions
{
"session": "pre",
"anat": [{"suffix": "T1w", "metadata": {"EchoTime": 1}}], # anatomical files
"func": [ # bold files
{
"task": "rest",
"echo": 1,
"suffix": "bold",
"metadata": {
"RepetitionTime": 0.8,
"EchoTime": 0.5,
"TotalReadoutTime": 0.5,
"PhaseEncodingDirection": "j",
},
},
{
"task": "rest",
"echo": 2,
"suffix": "bold",
"metadata": {
"RepetitionTime": 0.8,
"EchoTime": 0.7,
"TotalReadoutTime": 0.5,
"PhaseEncodingDirection": "j",
},
},
],
},
{
"session": "post",
"anat": {"suffix": "T2w", "metadata": {"EchoTime": 2}},
"func": {
"task": "rest",
"acq": "lowres",
"suffix": "bold",
"metadata": {"RepetitionTime": 0.8, "PhaseEncodingDirection": "j-"},
},
},
],
"02": "*",
"03": "*",
}

bids_dir_session_less = {
"01": [ # composed of dictionaries, pertaining to sessions
{
"anat": {"suffix": "T1w", "metadata": {"EchoTime": 1}},
"func": [ # bold files
{
"task": "rest",
"echo": 1,
"suffix": "bold",
"metadata": {
"EchoTime": 0.5,
"TotalReadoutTime": 0.5,
"PhaseEncodingDirection": "j",
},
},
{
"task": "rest",
"echo": 2,
"suffix": "bold",
"metadata": {
"EchoTime": 0.7,
"TotalReadoutTime": 0.5,
"PhaseEncodingDirection": "j",
},
},
],
}
],
"02": "*",
"03": {
"anat": {"suffix": "T1w", "metadata": {"EchoTime": 1}},
"func": [ # bold files
{
"task": "diff",
"echo": 1,
"suffix": "bold",
"metadata": {
"EchoTime": 0.5,
"TotalReadoutTime": 0.5,
"PhaseEncodingDirection": "j",
},
},
{
"task": "diff",
"echo": 2,
"suffix": "bold",
"metadata": {
"EchoTime": 0.7,
"TotalReadoutTime": 0.5,
"PhaseEncodingDirection": "j",
},
},
],
},
"04": "*",
}


@pytest.mark.parametrize(
"test_id,json_layout,n_files,n_subjects,n_sessions",
[
('sessions', bids_dir_sessions, 31, 3, 2),
('nosession', bids_dir_session_less, 25, 4, 0),
],
)
def test_generate_bids_skeleton(tmp_path, test_id, json_layout, n_files, n_subjects, n_sessions):
root = tmp_path / test_id
generate_bids_skeleton(root, json_layout)
datadesc = root / "dataset_description.json"
assert datadesc.exists()
assert "BIDSVersion" in datadesc.read_text()

assert len([x for x in root.glob("**/*") if x.is_file()]) == n_files

# ensure layout is valid
layout = BIDSLayout(root)
assert len(layout.get_subjects()) == n_subjects
assert len(layout.get_sessions()) == n_sessions

anat = layout.get(suffix="T1w", extension="nii.gz")[0]
bold = layout.get(suffix="bold", extension="nii.gz")[0]
assert anat.get_metadata()
assert bold.get_metadata()