From 23b5b985ddbae12847fbad99ecbe26c4953f3274 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 14:53:53 -0500 Subject: [PATCH 01/18] Draft function to collect related files for base file. --- cubids/utils.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cubids/utils.py b/cubids/utils.py index 128b0fad..19944852 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1034,3 +1034,30 @@ def assign_variants(summary, rename_cols): summary.at[row, col] = "" return summary + + +def collect_file_collections(layout, base_file): + """Build a list of files in a file collection for a given base file. + + Parameters + ---------- + layout : BIDSLayout + The BIDSLayout object. + base_file : str + The base file to collect file collections for. + + Returns + ------- + list + A list of files in the file collection for the given base file. + """ + from bids.layout import Query + + file_collection_entities = ["echo", "part", "mt", "inv", "flip"] + + base_file = layout.get_file(base_file) + fc_query = {ent: [Query.ANY, Query.NONE] for ent in file_collection_entities} + query = base_file.get_entities() + query = {**query, **fc_query} + files = layout.get(**query) + return files From 1296ea6a555a8351b47da327661fcbcb8d798084 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 15:11:08 -0500 Subject: [PATCH 02/18] Keep working. --- cubids/utils.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/cubids/utils.py b/cubids/utils.py index 19944852..d0316bbe 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1053,11 +1053,33 @@ def collect_file_collections(layout, base_file): """ from bids.layout import Query - file_collection_entities = ["echo", "part", "mt", "inv", "flip"] + file_collection_entities = { + "echo": "EchoTime", + "part": None, + "mt": "MTState", + "inv": "InversionTime", + "flip": "FlipAngle", + } base_file = layout.get_file(base_file) - fc_query = {ent: [Query.ANY, Query.NONE] for ent in file_collection_entities} + fc_query = {ent: [Query.ANY, Query.NONE] for ent in file_collection_entities.keys()} query = base_file.get_entities() query = {**query, **fc_query} files = layout.get(**query) - return files + collected_entities = layout.get_entities(files) + + out_metadata = {} + files_metadata = [f.get_metadata() for f in files] + for ent, field in file_collection_entities.items(): + if ent in collected_entities: + if field is None: + collected_ent = ent + "s" + ent_values = [f.get_entities()[ent] for f in files] + out_metadata[collected_ent] = ent_values + + else: + collected_field = field + "s" + field_values = [meta[field] for meta in files_metadata] + out_metadata[collected_field] = field_values + + return files, out_metadata From b5d62dd2f4f1584b721a0bb137336aa075e6fb10 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 15:24:25 -0500 Subject: [PATCH 03/18] Update utils.py --- cubids/utils.py | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/cubids/utils.py b/cubids/utils.py index d0316bbe..3f8a85cd 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1056,8 +1056,8 @@ def collect_file_collections(layout, base_file): file_collection_entities = { "echo": "EchoTime", "part": None, - "mt": "MTState", - "inv": "InversionTime", + "mtransfer": "MTState", + "inversion": "InversionTime", "flip": "FlipAngle", } @@ -1066,20 +1066,56 @@ def collect_file_collections(layout, base_file): query = base_file.get_entities() query = {**query, **fc_query} files = layout.get(**query) - collected_entities = layout.get_entities(files) + + if len(files) <= 1: + return files, {} + + # Get list of entities present in any of the files + collected_entities = [list(f.get_entities().keys()) for f in files] + # Flatten the list + collected_entities = [item for sublist in collected_entities for item in sublist] + # Remove duplicates + collected_entities = sorted(set(collected_entities)) out_metadata = {} + # Add metadata field with BIDS URIs to all files in file collection + out_metadata["FileCollection"] = [get_bidsuri(f.path, layout.root) for f in files] + files_metadata = [f.get_metadata() for f in files] for ent, field in file_collection_entities.items(): if ent in collected_entities: if field is None: + # If the entity is not mirrored in the metadata, like part, + # just use the entity value from the files. collected_ent = ent + "s" ent_values = [f.get_entities()[ent] for f in files] out_metadata[collected_ent] = ent_values else: - collected_field = field + "s" + # If the entity is mirrored in the metadata, like echo, + # collect the values from the metadata. + collected_field = field.title() + "s" field_values = [meta[field] for meta in files_metadata] out_metadata[collected_field] = field_values return files, out_metadata + + +def get_bidsuri(filename, dataset_root): + """Get the BIDS URI for a given filename. + + Parameters + ---------- + filename : str + The filename to get the BIDS URI for. + dataset_root : str + The root directory of the dataset. + + Returns + ------- + str + The BIDS URI for the given filename. + """ + import os + + return f"bids::{os.path.relpath(filename, dataset_root)}" From 1dace210454d09d35854c988c99a66846df1df49 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 15:25:45 -0500 Subject: [PATCH 04/18] Update utils.py --- cubids/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cubids/utils.py b/cubids/utils.py index 3f8a85cd..7117eeba 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1048,8 +1048,10 @@ def collect_file_collections(layout, base_file): Returns ------- - list + files : list of BIDSFile A list of files in the file collection for the given base file. + out_metadata : dict + A dictionary of metadata for the file collection, to be added to each file's metadata. """ from bids.layout import Query From 00830422fb635bbf75a2d068ae1f6a2414ce1a76 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 15:36:21 -0500 Subject: [PATCH 05/18] Add command-line interface. --- cubids/cli.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ cubids/cubids.py | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/cubids/cli.py b/cubids/cli.py index 378da068..9af6a082 100644 --- a/cubids/cli.py +++ b/cubids/cli.py @@ -1040,6 +1040,63 @@ def _parse_add_nifti_info(): return parser +def _parse_add_file_collections(): + """Parse command-line arguments for the `cubids add-file-collections` command. + + This function sets up an argument parser for the `cubids add-file-collections` command, + which adds file collection metadata to the sidecars of each dataset in a BIDS + directory. + + Parameters + ---------- + bids_dir : str + Absolute path to the root of a BIDS dataset. It should contain sub-X directories + and dataset_description.json. + use_datalad : bool, optional + Ensure that there are no untracked changes before finding groups (default is False). + force_unlock : bool, optional + Unlock dataset before adding file collection metadata (default is False). + + Returns + ------- + argparse.ArgumentParser + The argument parser with the defined arguments. + """ + parser = argparse.ArgumentParser( + description=( + "cubids add-file-collections: Add file collection metadata to the sidecars " + "of each NIfTI file in the BIDS dataset" + ), + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + allow_abbrev=False, + ) + PathExists = partial(_path_exists, parser=parser) + + parser.add_argument( + "bids_dir", + type=PathExists, + action="store", + help=( + "absolute path to the root of a BIDS dataset. " + "It should contain sub-X directories and " + "dataset_description.json." + ), + ) + parser.add_argument( + "--use-datalad", + action="store_true", + default=False, + help="ensure that there are no untracked changes before finding groups", + ) + parser.add_argument( + "--force-unlock", + action="store_true", + default=False, + help="unlock dataset before adding file collection metadata", + ) + return parser + + def _enter_add_nifti_info(argv=None): """Entry point for the deprecated `cubids-add-nifti-info` command. @@ -1345,6 +1402,7 @@ def _enter_print_metadata_fields(argv=None): ("apply", _parse_apply, workflows.apply), ("purge", _parse_purge, workflows.purge), ("add-nifti-info", _parse_add_nifti_info, workflows.add_nifti_info), + ("add-file-collections", _parse_add_file_collections, workflows.add_file_collections), ("copy-exemplars", _parse_copy_exemplars, workflows.copy_exemplars), ("undo", _parse_undo, workflows.undo), ("datalad-save", _parse_datalad_save, workflows.datalad_save), diff --git a/cubids/cubids.py b/cubids/cubids.py index bcec42c1..1b0cc14b 100644 --- a/cubids/cubids.py +++ b/cubids/cubids.py @@ -391,6 +391,49 @@ def add_nifti_info(self): self.reset_bids_layout() + def add_file_collections(self): + """Add file collections to the dataset. + + This method processes all files in the BIDS directory specified by `self.path`. + It identifies file collections based on the presence of specific entities in the filenames. + + """ + # check if force_unlock is set + if self.force_unlock: + # CHANGE TO SUBPROCESS.CALL IF NOT BLOCKING + subprocess.run(["datalad", "unlock"], cwd=self.path) + + checked_files = [] + + # loop through all niftis in the bids dir + for bids_file in self.layout.get(extension=[".nii", ".nii.gz"]): + path = bids_file.path + + if path in checked_files: + continue + + # Add file collection metadata to the sidecar + files, collection_metadata = utils.collect_file_collections(self.layout, path) + filepaths = [f.path for f in files] + checked_files.extend(filepaths) + + # Add metadata to the sidecar + sidecar = utils.img_to_new_ext(str(path), ".json") + if Path(sidecar).exists(): + with open(sidecar, "r") as f: + data = json.load(f) + else: + data = {} + + data.update(collection_metadata) + with open(sidecar, "w") as f: + json.dump(data, f, sort_keys=True, indent=4) + + if self.use_datalad: + self.datalad_save(message="Added file collection metadata to sidecars") + + self.reset_bids_layout() + def apply_tsv_changes(self, summary_tsv, files_tsv, new_prefix, raise_on_error=True): """Apply changes documented in the edited summary tsv and generate the new tsv files. From 8ab3eef5b41cad3d6ac9858f74fc8a97cef55b7a Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 15:40:10 -0500 Subject: [PATCH 06/18] Update workflows.py --- cubids/workflows.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cubids/workflows.py b/cubids/workflows.py index c8b7bdb3..1ccc0bba 100644 --- a/cubids/workflows.py +++ b/cubids/workflows.py @@ -444,6 +444,29 @@ def add_nifti_info(bids_dir, use_datalad, force_unlock): bod.add_nifti_info() +def add_file_collections(bids_dir, use_datalad, force_unlock): + """Add file collection metadata to the sidecars of each NIfTI file in the BIDS dataset. + + Parameters + ---------- + bids_dir : :obj:`pathlib.Path` + Path to the BIDS directory. + use_datalad : :obj:`bool` + Use datalad to track changes. + force_unlock : :obj:`bool` + Force unlock the dataset. + """ + bod = CuBIDS( + data_root=str(bids_dir), + use_datalad=use_datalad, + force_unlock=force_unlock, + ) + if use_datalad and not bod.is_datalad_clean(): + raise Exception(f"Untracked changes in {bids_dir}. Cannot continue.") + + bod.add_file_collections() + + def purge(bids_dir, use_datalad, scans): """Purge scan associations. From 433690e10ce96ce32ae18fb30bc5a501bb4cea96 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 15:59:31 -0500 Subject: [PATCH 07/18] Add test. --- .../skeletons/skeleton_file_collection_01.yml | 96 +++++++++++++++++++ cubids/tests/test_file_collections.py | 34 +++++++ 2 files changed, 130 insertions(+) create mode 100644 cubids/tests/data/skeletons/skeleton_file_collection_01.yml create mode 100644 cubids/tests/test_file_collections.py diff --git a/cubids/tests/data/skeletons/skeleton_file_collection_01.yml b/cubids/tests/data/skeletons/skeleton_file_collection_01.yml new file mode 100644 index 00000000..2a19df38 --- /dev/null +++ b/cubids/tests/data/skeletons/skeleton_file_collection_01.yml @@ -0,0 +1,96 @@ +"01": + - func: + - task: rest + echo: 1 + part: mag + suffix: bold + metadata: + EchoTime: 0.15 + - task: rest + echo: 1 + part: phase + suffix: bold + metadata: + EchoTime: 0.15 + Units: arbitrary + - task: rest + echo: 2 + part: mag + suffix: bold + metadata: + EchoTime: 0.3 + - task: rest + echo: 2 + part: phase + suffix: bold + metadata: + EchoTime: 0.3 + Units: arbitrary + - task: rest + echo: 3 + part: mag + suffix: bold + metadata: + EchoTime: 0.45 + - task: rest + echo: 3 + part: phase + suffix: bold + metadata: + EchoTime: 0.45 + Units: arbitrary + +"02": + - func: + - task: rest + echo: 1 + part: mag + suffix: bold + metadata: + EchoTime: 0.15 + - task: rest + echo: 1 + part: phase + suffix: bold + metadata: + EchoTime: 0.15 + Units: arbitrary + - task: rest + echo: 2 + part: mag + suffix: bold + metadata: + EchoTime: 0.3 + - task: rest + echo: 2 + part: phase + suffix: bold + metadata: + EchoTime: 0.3 + Units: arbitrary + - task: rest + echo: 3 + part: mag + suffix: bold + metadata: + EchoTime: 0.45 + - task: rest + echo: 3 + part: phase + suffix: bold + metadata: + EchoTime: 0.45 + Units: arbitrary + - task: rest + echo: 4 + part: mag + suffix: bold + metadata: + EchoTime: 0.6 + - task: rest + echo: 4 + part: phase + suffix: bold + metadata: + EchoTime: 0.6 + Units: arbitrary diff --git a/cubids/tests/test_file_collections.py b/cubids/tests/test_file_collections.py new file mode 100644 index 00000000..fc30de8f --- /dev/null +++ b/cubids/tests/test_file_collections.py @@ -0,0 +1,34 @@ +"""Test file collection management in CuBIDS.""" + +import json + +from niworkflows.utils.testing import generate_bids_skeleton + +from cubids.tests.utils import TEST_DATA +from cubids.workflows import add_file_collections + + +def test_add_file_collections(tmp_path): + """Test adding file collections to a BIDS dataset.""" + bids_dir = tmp_path / "add_file_collections" + dset_yaml = str(TEST_DATA / "skeletons" / "skeleton_file_collection_01.yml") + generate_bids_skeleton(str(bids_dir), dset_yaml) + add_file_collections(tmp_path) + + f1 = bids_dir / "sub-01" / "func" / "sub-01_task-rest_echo-3_part-phase_bold.json" + assert f1.exists() + expected = { + "EchoTime": 0.45, + "EchoTimes": [0.15, 0.15, 0.3, 0.3, 0.45, 0.45], + "Parts": ["mag", "phase", "mag", "phase", "mag", "phase"], + "FileCollection": [ + "bids::sub-01/func/sub-01_task-rest_echo-1_part-mag_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_echo-1_part-phase_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_echo-2_part-mag_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_echo-2_part-phase_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_echo-3_part-mag_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_echo-3_part-phase_bold.nii.gz", + ], + "Units": "arbitrary", + } + assert json.loads(f1.read_text()) == expected From ab6b7beeab10e9a73485de947cbc5e84680d1a52 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 16:00:33 -0500 Subject: [PATCH 08/18] Add second check in test. --- cubids/tests/test_file_collections.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cubids/tests/test_file_collections.py b/cubids/tests/test_file_collections.py index fc30de8f..aadd4682 100644 --- a/cubids/tests/test_file_collections.py +++ b/cubids/tests/test_file_collections.py @@ -32,3 +32,22 @@ def test_add_file_collections(tmp_path): "Units": "arbitrary", } assert json.loads(f1.read_text()) == expected + + f2 = bids_dir / "sub-02" / "func" / "sub-02_task-rest_echo-3_part-mag_bold.json" + assert f2.exists() + expected = { + "EchoTime": 0.45, + "EchoTimes": [0.15, 0.15, 0.3, 0.3, 0.45, 0.45, 0.6, 0.6], + "Parts": ["mag", "phase", "mag", "phase", "mag", "phase", "mag", "phase"], + "FileCollection": [ + "bids::sub-02/func/sub-02_task-rest_echo-1_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_echo-1_part-phase_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_echo-2_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_echo-2_part-phase_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_echo-3_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_echo-3_part-phase_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_echo-4_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_echo-4_part-phase_bold.nii.gz", + ], + } + assert json.loads(f2.read_text()) == expected From 17cf034b63f4bcbdc6c025914092a82e85c5e889 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 16:08:30 -0500 Subject: [PATCH 09/18] Fix. --- cubids/tests/test_file_collections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cubids/tests/test_file_collections.py b/cubids/tests/test_file_collections.py index aadd4682..a3647a17 100644 --- a/cubids/tests/test_file_collections.py +++ b/cubids/tests/test_file_collections.py @@ -13,7 +13,7 @@ def test_add_file_collections(tmp_path): bids_dir = tmp_path / "add_file_collections" dset_yaml = str(TEST_DATA / "skeletons" / "skeleton_file_collection_01.yml") generate_bids_skeleton(str(bids_dir), dset_yaml) - add_file_collections(tmp_path) + add_file_collections(tmp_path, use_datalad=False, force_unlock=True) f1 = bids_dir / "sub-01" / "func" / "sub-01_task-rest_echo-3_part-phase_bold.json" assert f1.exists() From 354ab4cb4264b62ed6db5b9f44a8e1c55b341792 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 16:15:30 -0500 Subject: [PATCH 10/18] Fix things. --- .../skeletons/skeleton_file_collection_01.yml | 24 +++++++++++ cubids/tests/test_file_collections.py | 43 ++++++++++++------- cubids/utils.py | 4 +- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/cubids/tests/data/skeletons/skeleton_file_collection_01.yml b/cubids/tests/data/skeletons/skeleton_file_collection_01.yml index 2a19df38..77adda58 100644 --- a/cubids/tests/data/skeletons/skeleton_file_collection_01.yml +++ b/cubids/tests/data/skeletons/skeleton_file_collection_01.yml @@ -1,12 +1,14 @@ "01": - func: - task: rest + acq: meepi echo: 1 part: mag suffix: bold metadata: EchoTime: 0.15 - task: rest + acq: meepi echo: 1 part: phase suffix: bold @@ -14,12 +16,14 @@ EchoTime: 0.15 Units: arbitrary - task: rest + acq: meepi echo: 2 part: mag suffix: bold metadata: EchoTime: 0.3 - task: rest + acq: meepi echo: 2 part: phase suffix: bold @@ -27,28 +31,37 @@ EchoTime: 0.3 Units: arbitrary - task: rest + acq: meepi echo: 3 part: mag suffix: bold metadata: EchoTime: 0.45 - task: rest + acq: meepi echo: 3 part: phase suffix: bold metadata: EchoTime: 0.45 Units: arbitrary + - task: rest + acq: seepi + suffix: bold + metadata: + EchoTime: 0.35 "02": - func: - task: rest + acq: meepi echo: 1 part: mag suffix: bold metadata: EchoTime: 0.15 - task: rest + acq: meepi echo: 1 part: phase suffix: bold @@ -56,12 +69,14 @@ EchoTime: 0.15 Units: arbitrary - task: rest + acq: meepi echo: 2 part: mag suffix: bold metadata: EchoTime: 0.3 - task: rest + acq: meepi echo: 2 part: phase suffix: bold @@ -69,12 +84,14 @@ EchoTime: 0.3 Units: arbitrary - task: rest + acq: meepi echo: 3 part: mag suffix: bold metadata: EchoTime: 0.45 - task: rest + acq: meepi echo: 3 part: phase suffix: bold @@ -82,15 +99,22 @@ EchoTime: 0.45 Units: arbitrary - task: rest + acq: meepi echo: 4 part: mag suffix: bold metadata: EchoTime: 0.6 - task: rest + acq: meepi echo: 4 part: phase suffix: bold metadata: EchoTime: 0.6 Units: arbitrary + - task: rest + acq: seepi + suffix: bold + metadata: + EchoTime: 0.35 diff --git a/cubids/tests/test_file_collections.py b/cubids/tests/test_file_collections.py index a3647a17..e3736b4d 100644 --- a/cubids/tests/test_file_collections.py +++ b/cubids/tests/test_file_collections.py @@ -15,39 +15,50 @@ def test_add_file_collections(tmp_path): generate_bids_skeleton(str(bids_dir), dset_yaml) add_file_collections(tmp_path, use_datalad=False, force_unlock=True) - f1 = bids_dir / "sub-01" / "func" / "sub-01_task-rest_echo-3_part-phase_bold.json" + # A JSON sidecar that's part of a file collection should be modified. + f1 = bids_dir / "sub-01" / "func" / "sub-01_task-rest_acq-meepi_echo-3_part-phase_bold.json" assert f1.exists() expected = { "EchoTime": 0.45, "EchoTimes": [0.15, 0.15, 0.3, 0.3, 0.45, 0.45], "Parts": ["mag", "phase", "mag", "phase", "mag", "phase"], "FileCollection": [ - "bids::sub-01/func/sub-01_task-rest_echo-1_part-mag_bold.nii.gz", - "bids::sub-01/func/sub-01_task-rest_echo-1_part-phase_bold.nii.gz", - "bids::sub-01/func/sub-01_task-rest_echo-2_part-mag_bold.nii.gz", - "bids::sub-01/func/sub-01_task-rest_echo-2_part-phase_bold.nii.gz", - "bids::sub-01/func/sub-01_task-rest_echo-3_part-mag_bold.nii.gz", - "bids::sub-01/func/sub-01_task-rest_echo-3_part-phase_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_acq-meepi_echo-1_part-mag_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_acq-meepi_echo-1_part-phase_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_acq-meepi_echo-2_part-mag_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_acq-meepi_echo-2_part-phase_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_acq-meepi_echo-3_part-mag_bold.nii.gz", + "bids::sub-01/func/sub-01_task-rest_acq-meepi_echo-3_part-phase_bold.nii.gz", ], "Units": "arbitrary", } assert json.loads(f1.read_text()) == expected - f2 = bids_dir / "sub-02" / "func" / "sub-02_task-rest_echo-3_part-mag_bold.json" + # A JSON sidecar that's part of a file collection should be modified. + # Same as above, but with a different file collection (4-echo). + f2 = bids_dir / "sub-02" / "func" / "sub-02_task-rest_acq-meepi_echo-3_part-mag_bold.json" assert f2.exists() expected = { "EchoTime": 0.45, "EchoTimes": [0.15, 0.15, 0.3, 0.3, 0.45, 0.45, 0.6, 0.6], "Parts": ["mag", "phase", "mag", "phase", "mag", "phase", "mag", "phase"], "FileCollection": [ - "bids::sub-02/func/sub-02_task-rest_echo-1_part-mag_bold.nii.gz", - "bids::sub-02/func/sub-02_task-rest_echo-1_part-phase_bold.nii.gz", - "bids::sub-02/func/sub-02_task-rest_echo-2_part-mag_bold.nii.gz", - "bids::sub-02/func/sub-02_task-rest_echo-2_part-phase_bold.nii.gz", - "bids::sub-02/func/sub-02_task-rest_echo-3_part-mag_bold.nii.gz", - "bids::sub-02/func/sub-02_task-rest_echo-3_part-phase_bold.nii.gz", - "bids::sub-02/func/sub-02_task-rest_echo-4_part-mag_bold.nii.gz", - "bids::sub-02/func/sub-02_task-rest_echo-4_part-phase_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-1_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-1_part-phase_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-2_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-2_part-phase_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-3_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-3_part-phase_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-4_part-mag_bold.nii.gz", + "bids::sub-02/func/sub-02_task-rest_acq-meepi_echo-4_part-phase_bold.nii.gz", ], } assert json.loads(f2.read_text()) == expected + + # A NIfTI that's not part of a file collection shouldn't be modified. + f3 = bids_dir / "sub-01" / "func" / "sub-01_task-rest_acq-seepi_bold.json" + assert f3.exists() + expected = { + "EchoTime": 0.35, + } + assert json.loads(f3.read_text()) == expected diff --git a/cubids/utils.py b/cubids/utils.py index 7117eeba..e930ed73 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1058,8 +1058,8 @@ def collect_file_collections(layout, base_file): file_collection_entities = { "echo": "EchoTime", "part": None, - "mtransfer": "MTState", - "inversion": "InversionTime", + "mt": "MTState", + "inv": "InversionTime", "flip": "FlipAngle", } From 9a4577f612c4cdf56f8396ed395b3c960019a1f3 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 16:21:49 -0500 Subject: [PATCH 11/18] Update utils.py --- cubids/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cubids/utils.py b/cubids/utils.py index e930ed73..3bf48354 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1084,6 +1084,7 @@ def collect_file_collections(layout, base_file): out_metadata["FileCollection"] = [get_bidsuri(f.path, layout.root) for f in files] files_metadata = [f.get_metadata() for f in files] + assert all(bool(meta) for meta in files_metadata), files_metadata for ent, field in file_collection_entities.items(): if ent in collected_entities: if field is None: From 06da18b6baf08ab1d2cc0d925f881070bf32508a Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 16:27:17 -0500 Subject: [PATCH 12/18] Update utils.py --- cubids/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cubids/utils.py b/cubids/utils.py index 3bf48354..1f576908 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1084,7 +1084,7 @@ def collect_file_collections(layout, base_file): out_metadata["FileCollection"] = [get_bidsuri(f.path, layout.root) for f in files] files_metadata = [f.get_metadata() for f in files] - assert all(bool(meta) for meta in files_metadata), files_metadata + assert all(bool(meta) for meta in files_metadata), files for ent, field in file_collection_entities.items(): if ent in collected_entities: if field is None: From 3a3b98b9c3d30cba5c9c40d5060fd361e01fb1dc Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 27 Feb 2025 16:42:30 -0500 Subject: [PATCH 13/18] Enable metadata indexing. --- cubids/cubids.py | 28 +++++++++++++++------------ cubids/tests/test_file_collections.py | 2 +- cubids/utils.py | 4 ++-- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/cubids/cubids.py b/cubids/cubids.py index 1b0cc14b..169c05f4 100644 --- a/cubids/cubids.py +++ b/cubids/cubids.py @@ -186,7 +186,7 @@ def reset_bids_layout(self, validate=False): re.compile(r"/\."), ] - indexer = bids.BIDSLayoutIndexer(validate=validate, ignore=ignores, index_metadata=False) + indexer = bids.BIDSLayoutIndexer(validate=validate, ignore=ignores, index_metadata=True) self._layout = bids.BIDSLayout(self.path, validate=validate, indexer=indexer) @@ -397,6 +397,9 @@ def add_file_collections(self): This method processes all files in the BIDS directory specified by `self.path`. It identifies file collections based on the presence of specific entities in the filenames. + Notes + ----- + This method requires that the BIDSLayout has been indexed with metadata. """ # check if force_unlock is set if self.force_unlock: @@ -417,17 +420,18 @@ def add_file_collections(self): filepaths = [f.path for f in files] checked_files.extend(filepaths) - # Add metadata to the sidecar - sidecar = utils.img_to_new_ext(str(path), ".json") - if Path(sidecar).exists(): - with open(sidecar, "r") as f: - data = json.load(f) - else: - data = {} - - data.update(collection_metadata) - with open(sidecar, "w") as f: - json.dump(data, f, sort_keys=True, indent=4) + for collection_path in filepaths: + # Add metadata to the sidecar + sidecar = utils.img_to_new_ext(str(collection_path), ".json") + if Path(sidecar).exists(): + with open(sidecar, "r") as f: + data = json.load(f) + else: + data = {} + + data.update(collection_metadata) + with open(sidecar, "w") as f: + json.dump(data, f, sort_keys=True, indent=4) if self.use_datalad: self.datalad_save(message="Added file collection metadata to sidecars") diff --git a/cubids/tests/test_file_collections.py b/cubids/tests/test_file_collections.py index e3736b4d..4143b193 100644 --- a/cubids/tests/test_file_collections.py +++ b/cubids/tests/test_file_collections.py @@ -13,7 +13,7 @@ def test_add_file_collections(tmp_path): bids_dir = tmp_path / "add_file_collections" dset_yaml = str(TEST_DATA / "skeletons" / "skeleton_file_collection_01.yml") generate_bids_skeleton(str(bids_dir), dset_yaml) - add_file_collections(tmp_path, use_datalad=False, force_unlock=True) + add_file_collections(str(bids_dir), use_datalad=False, force_unlock=True) # A JSON sidecar that's part of a file collection should be modified. f1 = bids_dir / "sub-01" / "func" / "sub-01_task-rest_acq-meepi_echo-3_part-phase_bold.json" diff --git a/cubids/utils.py b/cubids/utils.py index 1f576908..dba93e65 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1090,14 +1090,14 @@ def collect_file_collections(layout, base_file): if field is None: # If the entity is not mirrored in the metadata, like part, # just use the entity value from the files. - collected_ent = ent + "s" + collected_ent = ent.title() + "s" ent_values = [f.get_entities()[ent] for f in files] out_metadata[collected_ent] = ent_values else: # If the entity is mirrored in the metadata, like echo, # collect the values from the metadata. - collected_field = field.title() + "s" + collected_field = field + "s" field_values = [meta[field] for meta in files_metadata] out_metadata[collected_field] = field_values From 6e3a1616ba1d284837f1c703336e77df82dd284a Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 28 Feb 2025 08:53:46 -0500 Subject: [PATCH 14/18] Stop relying on index_metadata. --- cubids/cubids.py | 2 +- cubids/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cubids/cubids.py b/cubids/cubids.py index 169c05f4..3ece9063 100644 --- a/cubids/cubids.py +++ b/cubids/cubids.py @@ -186,7 +186,7 @@ def reset_bids_layout(self, validate=False): re.compile(r"/\."), ] - indexer = bids.BIDSLayoutIndexer(validate=validate, ignore=ignores, index_metadata=True) + indexer = bids.BIDSLayoutIndexer(validate=validate, ignore=ignores, index_metadata=False) self._layout = bids.BIDSLayout(self.path, validate=validate, indexer=indexer) diff --git a/cubids/utils.py b/cubids/utils.py index dba93e65..2ee5616f 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1083,7 +1083,7 @@ def collect_file_collections(layout, base_file): # Add metadata field with BIDS URIs to all files in file collection out_metadata["FileCollection"] = [get_bidsuri(f.path, layout.root) for f in files] - files_metadata = [f.get_metadata() for f in files] + files_metadata = [get_sidecar_metadata(img_to_new_ext(f.path, ".json")) for f in files] assert all(bool(meta) for meta in files_metadata), files for ent, field in file_collection_entities.items(): if ent in collected_entities: From b2151ee7f8a297dd0ca9a34bf5bac652cfb0b1e0 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 28 Feb 2025 09:25:22 -0500 Subject: [PATCH 15/18] Add file collection fields to config. --- cubids/data/config.yml | 114 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/cubids/data/config.yml b/cubids/data/config.yml index 9ebffc8e..0297fa1e 100644 --- a/cubids/data/config.yml +++ b/cubids/data/config.yml @@ -37,6 +37,25 @@ derived_params: tolerance: 0.001 precision: 3 suggest_variant_rename: yes + # Array of echo times across file collection + EchoTimes: + tolerance: 0.001 + precision: 3 + suggest_variant_rename: yes + # Array of flip angles across file collection + FlipAngles: + suggest_variant_rename: yes + # Array of inversion times across file collection + InversionTimes: + tolerance: 0.001 + precision: 5 + suggest_variant_rename: yes + # Array of magnetization transfer states across file collection + MTStates: + suggest_variant_rename: yes + # Array of parts across file collection + Parts: + suggest_variant_rename: yes dwi: Dim1Size: suggest_variant_rename: yes @@ -64,6 +83,25 @@ derived_params: suggest_variant_rename: yes ImageOrientation: suggest_variant_rename: yes + # Array of echo times across file collection + EchoTimes: + tolerance: 0.001 + precision: 3 + suggest_variant_rename: yes + # Array of flip angles across file collection + FlipAngles: + suggest_variant_rename: yes + # Array of inversion times across file collection + InversionTimes: + tolerance: 0.001 + precision: 5 + suggest_variant_rename: yes + # Array of magnetization transfer states across file collection + MTStates: + suggest_variant_rename: yes + # Array of parts across file collection + Parts: + suggest_variant_rename: yes fmap: Dim1Size: suggest_variant_rename: yes @@ -90,6 +128,25 @@ derived_params: suggest_variant_rename: yes ImageOrientation: suggest_variant_rename: yes + # Array of echo times across file collection + EchoTimes: + tolerance: 0.001 + precision: 3 + suggest_variant_rename: yes + # Array of flip angles across file collection + FlipAngles: + suggest_variant_rename: yes + # Array of inversion times across file collection + InversionTimes: + tolerance: 0.001 + precision: 5 + suggest_variant_rename: yes + # Array of magnetization transfer states across file collection + MTStates: + suggest_variant_rename: yes + # Array of parts across file collection + Parts: + suggest_variant_rename: yes func: Dim1Size: suggest_variant_rename: yes @@ -117,6 +174,25 @@ derived_params: suggest_variant_rename: yes ImageOrientation: suggest_variant_rename: yes + # Array of echo times across file collection + EchoTimes: + tolerance: 0.001 + precision: 3 + suggest_variant_rename: yes + # Array of flip angles across file collection + FlipAngles: + suggest_variant_rename: yes + # Array of inversion times across file collection + InversionTimes: + tolerance: 0.001 + precision: 5 + suggest_variant_rename: yes + # Array of magnetization transfer states across file collection + MTStates: + suggest_variant_rename: yes + # Array of parts across file collection + Parts: + suggest_variant_rename: yes perf: Dim1Size: suggest_variant_rename: yes @@ -144,6 +220,25 @@ derived_params: suggest_variant_rename: yes ImageOrientation: suggest_variant_rename: yes + # Array of echo times across file collection + EchoTimes: + tolerance: 0.001 + precision: 3 + suggest_variant_rename: yes + # Array of flip angles across file collection + FlipAngles: + suggest_variant_rename: yes + # Array of inversion times across file collection + InversionTimes: + tolerance: 0.001 + precision: 5 + suggest_variant_rename: yes + # Array of magnetization transfer states across file collection + MTStates: + suggest_variant_rename: yes + # Array of parts across file collection + Parts: + suggest_variant_rename: yes other: Dim1Size: suggest_variant_rename: yes @@ -171,6 +266,25 @@ derived_params: suggest_variant_rename: yes ImageOrientation: suggest_variant_rename: yes + # Array of echo times across file collection + EchoTimes: + tolerance: 0.001 + precision: 3 + suggest_variant_rename: yes + # Array of flip angles across file collection + FlipAngles: + suggest_variant_rename: yes + # Array of inversion times across file collection + InversionTimes: + tolerance: 0.001 + precision: 5 + suggest_variant_rename: yes + # Array of magnetization transfer states across file collection + MTStates: + suggest_variant_rename: yes + # Array of parts across file collection + Parts: + suggest_variant_rename: yes # These fields reflect relationships between images. # Not modality specific relational_params: From e7af5aec2fefdade024dbcdd9124fbdf2111751b Mon Sep 17 00:00:00 2001 From: Parker Singleton Date: Fri, 28 Feb 2025 11:09:41 -0500 Subject: [PATCH 16/18] add taylors comment to notes --- cubids/utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cubids/utils.py b/cubids/utils.py index 2ee5616f..d014b2dc 100644 --- a/cubids/utils.py +++ b/cubids/utils.py @@ -1052,6 +1052,15 @@ def collect_file_collections(layout, base_file): A list of files in the file collection for the given base file. out_metadata : dict A dictionary of metadata for the file collection, to be added to each file's metadata. + + Notes + ----- + This relies on a hardcoded list of entities that indicate file collections and their + corresponding metadata fields. It also does not work for file collections that are encoded + with the acq entity or different suffixes, like TB1AFI (which differentiates files with + acq-tr1/acq-tr2), MP2RAGE (which has both _MP2RAGE and _UNIT1 images from the same scan), + or phase-difference field maps (which have suffixes like magnitude1, magnitude2, phasediff, + phase1, and phase2). """ from bids.layout import Query From a60f5b2e5b1801027949a238257a2704320a6064 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 4 Apr 2025 09:40:56 -0400 Subject: [PATCH 17/18] Fix comment. We don't need to index metadata in the BIDSLayout, but the drawback is that inheritance is ignored. --- cubids/cubids.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cubids/cubids.py b/cubids/cubids.py index 3ece9063..9361be75 100644 --- a/cubids/cubids.py +++ b/cubids/cubids.py @@ -399,7 +399,8 @@ def add_file_collections(self): Notes ----- - This method requires that the BIDSLayout has been indexed with metadata. + This method uses metadata from direct sidecar JSON files, + so it will not work with inherited metadata. """ # check if force_unlock is set if self.force_unlock: From 38ac3eaf01285abd16778ccb9c32c4caf02ff9bc Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 4 Apr 2025 09:50:36 -0400 Subject: [PATCH 18/18] Add new workflow to API page. --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index 4e946308..daead85b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -34,6 +34,7 @@ API workflows.undo workflows.copy_exemplars workflows.add_nifti_info + workflows.add_file_collections workflows.purge workflows.remove_metadata_fields workflows.print_metadata_fields