clabtoolkit 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2260 @@
1
+ import os
2
+ import shutil
3
+ import pandas as pd
4
+ import time
5
+ import queue
6
+ import threading
7
+ import numpy as np
8
+
9
+ from typing import Union, Dict, Set, Tuple, List, Optional
10
+ from pathlib import Path
11
+
12
+ import re
13
+ import json
14
+ from glob import glob
15
+
16
+ from rich.progress import (
17
+ Progress,
18
+ BarColumn,
19
+ TimeRemainingColumn,
20
+ TextColumn,
21
+ MofNCompleteColumn,
22
+ SpinnerColumn,
23
+ )
24
+ from rich.console import Console
25
+ from rich.panel import Panel
26
+
27
+ # Importing the clabtoolkit modules
28
+ from . import misctools as cltmisc
29
+
30
+
31
+ ####################################################################################################
32
+ ####################################################################################################
33
+ ############ ############
34
+ ############ ############
35
+ ############ Section 1: Methods dedicated to work with BIDs naming conventions ############
36
+ ############ ############
37
+ ############ ############
38
+ ####################################################################################################
39
+ ####################################################################################################
40
+ def str2entity(string: str) -> dict:
41
+ """
42
+ Converts a formatted string into a dictionary.
43
+
44
+ Parameters
45
+ ----------
46
+ string : str
47
+ String to convert, with the format `key1-value1_key2-value2...suffix.extension`.
48
+
49
+ Returns
50
+ -------
51
+ dict
52
+ Dictionary containing the entities extracted from the string.
53
+
54
+ Examples
55
+ --------
56
+ >>> str2entity("sub-01_ses-M00_acq-3T_dir-AP_run-01_T1w.nii.gz")
57
+ Returns: {'sub': '01', 'ses': 'M00', 'acq': '3T', 'dir': 'AP', 'run': '01', 'suffix': 'T1w', 'extension': 'nii.gz'}
58
+
59
+ """
60
+ ent_dict = {}
61
+ suffix, extension = "", ""
62
+
63
+ # Split the string into entities based on underscores.
64
+ ent_list = string.split("_")
65
+
66
+ # Detect suffix and extension
67
+ for ent in ent_list[:]:
68
+ if "-" not in ent:
69
+ # If entity does not contain a '-', it's a suffix or extension.
70
+ if "." in ent:
71
+ # Split suffix and extension parts
72
+ suffix, extension = ent.split(".", 1)
73
+ else:
74
+ suffix = ent
75
+ ent_list.remove(ent)
76
+
77
+ # Process the remaining entities
78
+ for ent in ent_list:
79
+ key, value = ent.split("-", 1) # Split each entity on the first "-"
80
+ ent_dict[key] = value
81
+
82
+ # Add suffix and extension to the dictionary if they were found
83
+ if suffix:
84
+ ent_dict["suffix"] = suffix
85
+ if extension:
86
+ ent_dict["extension"] = extension
87
+
88
+ return ent_dict
89
+
90
+
91
+ ####################################################################################################
92
+ def entity2str(entity: dict) -> str:
93
+ """
94
+ Converts an entity dictionary to a string representation.
95
+
96
+ Parameters
97
+ ----------
98
+ entity : dict
99
+ Dictionary containing the entities.
100
+
101
+ Returns
102
+ -------
103
+ str
104
+ String containing the entities in the format `key1-value1_key2-value2...suffix.extension`.
105
+
106
+ Examples
107
+ --------
108
+ >>> entity2str({'sub': '01', 'ses': 'M00', 'acq': '3T', 'dir': 'AP', 'run': '01', 'suffix': 'T1w', 'extension': 'nii.gz'})
109
+ Returns: "sub-01_ses-M00_acq-3T_dir-AP_run-01_T1w.nii.gz"
110
+
111
+ """
112
+ # Make a copy of the entity dictionary to avoid mutating the original.
113
+ entity = entity.copy()
114
+
115
+ # Extract optional 'suffix' and 'extension' fields if present.
116
+ suffix = entity.pop("suffix", "")
117
+ extension = entity.pop("extension", "")
118
+
119
+ # Construct the main part of the string by joining key-value pairs with '_'
120
+ ent_string = "_".join(f"{key}-{str(value)}" for key, value in entity.items())
121
+
122
+ # Append suffix if it exists
123
+ if suffix:
124
+ ent_string += "_" + suffix
125
+ else:
126
+ ent_string = ent_string.rstrip("_") # Remove trailing underscore if no suffix
127
+
128
+ # Append extension if it exists
129
+ if extension:
130
+ ent_string += f".{extension}"
131
+
132
+ return ent_string
133
+
134
+
135
+ ####################################################################################################
136
+ def delete_entity(
137
+ entity: Union[dict, str], ent2rem: Union[List[str], str, dict]
138
+ ) -> Union[dict, str]:
139
+ """
140
+ Removes specified keys from an entity dictionary or string representation.
141
+
142
+ Parameters
143
+ ----------
144
+ entity : dict or str
145
+ Dictionary or string containing the entities.
146
+
147
+ ent2rem : List[str], str or dict
148
+ Entities to be removed from the entity dictionary or string. If ent2rem is a dictionary,
149
+ only the combination key-value will be removed from the filenames.
150
+
151
+ Returns
152
+ -------
153
+ Union[dict, str]
154
+ The updated entity as a dictionary or string (matching the input type).
155
+
156
+ Examples
157
+ --------
158
+ >>> delete_entity("sub-01_ses-M00_acq-3T_dir-AP_run-01_T1w.nii.gz", "acq")
159
+ Returns: "sub-01_ses-M00_dir-AP_run-01_T1w.nii.gz"
160
+
161
+ """
162
+ # Determine if `entity` is a string and convert if necessary.
163
+ is_string = isinstance(entity, str)
164
+ rem_is_dict = False # Boolean variable in case you want to delete an entity with a certain value and the key2remis a dictionary
165
+ if is_string:
166
+ entity_out = str2entity(entity)
167
+ elif isinstance(entity, dict):
168
+ entity_out = entity.copy()
169
+ else:
170
+ raise ValueError("The entity must be a dictionary or a string.")
171
+
172
+ # Ensure `key2rem` is a list for uniform processing.
173
+ if isinstance(ent2rem, str):
174
+ key2rem = [ent2rem]
175
+
176
+ elif isinstance(ent2rem, list):
177
+ key2rem = list(set(ent2rem)) # Convert to a set for unique keys
178
+
179
+ elif isinstance(ent2rem, dict):
180
+ rem_is_dict = True
181
+ key2rem = list(ent2rem.keys())
182
+
183
+ else:
184
+ raise ValueError(
185
+ "The ent2rem parameter must be a string, list of strings, or dictionary."
186
+ )
187
+
188
+ # Remove specified keys from the entity dictionary.
189
+ for key in key2rem:
190
+ if rem_is_dict:
191
+ # If `key2rem` is a dictionary, check if the key exists and has the specified value.
192
+ if key in entity_out and entity_out[key] in ent2rem[key]:
193
+ entity_out.pop(key, None)
194
+ else:
195
+ entity_out.pop(key, None) # `pop` with default `None` avoids KeyErrors.
196
+
197
+ # Convert back to string format if original input was a string.
198
+ if is_string:
199
+ return entity2str(entity_out)
200
+
201
+ return entity_out
202
+
203
+
204
+ ####################################################################################################
205
+ def replace_entity_value(
206
+ entity: Union[dict, str], ent2replace: Union[dict, str], verbose: bool = False
207
+ ) -> Union[dict, str]:
208
+ """
209
+ Replaces values in an entity dictionary or string representation.
210
+
211
+ Parameters
212
+ ----------
213
+ entity : dict or str
214
+ Dictionary or string containing the entities.
215
+
216
+ ent2replace : dict or str
217
+ Dictionary or string containing entities to replace with new values.
218
+
219
+ verbose : bool, optional
220
+ If True, prints warnings for non-existent or empty values.
221
+
222
+ Returns
223
+ -------
224
+ Union[dict, str]
225
+ Updated entity as a dictionary or string (matching the input type).
226
+
227
+ Examples
228
+ --------
229
+ >>> replace_entity_value("sub-01_ses-M00_acq-3T_dir-AP_run-01_T1w.nii.gz", {"acq": "7T"})
230
+ Returns: "sub-01_ses-M00_acq-7T_dir-AP_run-01_T1w.nii.gz"
231
+
232
+ """
233
+ # Determine if `entity` is a string and convert if necessary.
234
+ is_string = isinstance(entity, str)
235
+ if is_string:
236
+ entity_out = str2entity(entity)
237
+ elif isinstance(entity, dict):
238
+ entity_out = entity.copy()
239
+ else:
240
+ raise ValueError("The entity must be a dictionary or a string.")
241
+
242
+ # Adding the possibility to enter a string value. It will convert it to a dictionary
243
+ if isinstance(ent2replace, str):
244
+ ent2replace = str2entity(ent2replace)
245
+
246
+ # Remove any empty keys or values from `ent2replace`.
247
+ ent2replace = {k: v for k, v in ent2replace.items() if v}
248
+
249
+ # Replace values in `entity_out` based on `ent2replace`.
250
+ for key, new_value in ent2replace.items():
251
+ if key in entity_out:
252
+ if new_value:
253
+ entity_out[key] = new_value
254
+ elif verbose:
255
+ print(f"Warning: Replacement value for '{key}' is empty.")
256
+ elif verbose:
257
+ print(f"Warning: Entity '{key}' not found in entity dictionary.")
258
+
259
+ # Convert back to string format if original input was a string.
260
+ if is_string:
261
+ return entity2str(entity_out)
262
+
263
+ return entity_out
264
+
265
+
266
+ ####################################################################################################
267
+ def replace_entity_key(
268
+ entity: Union[dict, str], keys2replace: Dict[str, str], verbose: bool = False
269
+ ) -> Union[dict, str]:
270
+ """
271
+ Replaces specified keys in an entity dictionary or string representation.
272
+
273
+ Parameters
274
+ ----------
275
+ entity : dict or str
276
+ Dictionary containing the entities or a string that follows the BIDS naming specifications.
277
+
278
+ keys2replace : dict
279
+ Dictionary mapping old keys to new keys.
280
+
281
+ verbose : bool, optional
282
+ If True, prints warnings for keys in `keys2replace` that are not found in `entity`.
283
+
284
+ Returns
285
+ -------
286
+ Union[dict, str]
287
+ Updated entity as a dictionary or string (matching the input type).
288
+
289
+ Examples
290
+ --------
291
+ >>> replace_entity_key("sub-01_ses-M00_acq-3T_dir-AP_run-01_T1w.nii.gz", {"acq": "TESTrep1", "dir": "TESTrep2"})
292
+ Returns: "sub-01_ses-M00_TESTrep1-3T_TESTrep2-AP_run-01_T1w.nii.gz"
293
+
294
+ """
295
+ # Convert `entity` to a dictionary if it's a string
296
+ is_string = isinstance(entity, str)
297
+ if is_string:
298
+ entity = str2entity(entity)
299
+ elif not isinstance(entity, dict):
300
+ raise ValueError("The entity must be a dictionary or a string.")
301
+
302
+ # Validate that `keys2replace` is a dictionary
303
+ if not isinstance(keys2replace, dict):
304
+ raise ValueError("The keys2replace parameter must be a dictionary.")
305
+
306
+ # Filter out any empty keys or values from `keys2replace`
307
+ keys2replace = {k: v for k, v in keys2replace.items() if k and v}
308
+
309
+ # Replace key names in the entity
310
+ entity_out = {}
311
+ for key, value in entity.items():
312
+ # Use the new key if it exists in `keys2replace`, otherwise keep the original key
313
+ new_key = keys2replace.get(key, key)
314
+ entity_out[new_key] = value
315
+
316
+ # Verbose output if the key to replace does not exist in the entity
317
+ if verbose and key in keys2replace and key not in entity:
318
+ print(f"Warning: Key '{key}' not found in the original dictionary.")
319
+
320
+ # Convert back to string format if the original input was a string
321
+ if is_string:
322
+ return entity2str(entity_out)
323
+
324
+ return entity_out
325
+
326
+
327
+ ####################################################################################################
328
+ def insert_entity(
329
+ entity: Union[dict, str], entity2add: Dict[str, str], prev_entity: str = None
330
+ ) -> Union[dict, str]:
331
+ """
332
+ Adds entities to an existing entity dictionary or string representation.
333
+
334
+ Parameters
335
+ ----------
336
+ entity : dict or str
337
+ Dictionary containing the entities or a string that follows the BIDS naming specifications.
338
+
339
+ entity2add : dict
340
+ Dictionary containing the entities to add.
341
+ IMPORTANT: If the `entity2add` contains keys that already exist in the `entity`, they will not be added.
342
+
343
+ prev_entity : str, optional
344
+ Key in `entity` after which to insert the new entities.
345
+
346
+ Returns
347
+ -------
348
+ Union[dict, str]
349
+ Updated entity with the new entities added (matching the input type).
350
+
351
+ Examples
352
+ --------
353
+ >>> insert_entity("sub-01_ses-M00_acq-3T_dir-AP_run-01_T1w.nii.gz", {"task": "rest"})
354
+ Returns: "sub-01_ses-M00_acq-3T_dir-AP_run-01_task-rest_T1w.nii.gz"
355
+
356
+ >>> insert_entity("sub-01_ses-M00_acq-3T_dir-AP_run-01_T1w.nii.gz", {"task": "rest"}, prev_entity="ses")
357
+ Returns: "sub-01_ses-M00_task-rest_acq-3T_dir-AP_run-01_T1w.nii.gz"
358
+
359
+ """
360
+
361
+ # Determine if `entity` is a string and convert if necessary
362
+ is_string = isinstance(entity, str)
363
+ if is_string:
364
+ entity = str2entity(entity)
365
+ elif not isinstance(entity, dict):
366
+ raise ValueError("The entity must be a dictionary or a string.")
367
+
368
+ # Clean `entity2add` by removing any empty keys or values
369
+ entity2add = {k: v for k, v in entity2add.items() if k and v}
370
+
371
+ # Validate `prev_entity` if provided
372
+ if prev_entity is not None and prev_entity not in entity:
373
+ raise ValueError(
374
+ f"Reference entity '{prev_entity}' is not in the entity dictionary."
375
+ )
376
+
377
+ # Temporarily remove `suffix` and `extension` if they exist
378
+ suffix = entity.pop("suffix", None)
379
+ extension = entity.pop("extension", None)
380
+
381
+ # Check if there are some entities already on the name and removing those items from
382
+ # the dictionary
383
+ if len(cltmisc.list_intercept(list(entity2add.keys()), list(entity.keys()))) > 0:
384
+ # Removes the items from `entity2add` that already exist in `entity`
385
+ entity2add = {k: v for k, v in entity2add.items() if k not in entity}
386
+
387
+ # If `entity2add` is empty after filtering, return the original entity
388
+ if not entity2add:
389
+ if is_string:
390
+ return entity2str(entity)
391
+ return entity
392
+
393
+ else:
394
+ # Build `ent_out` by adding items from `entity`, and insert `entity2add` after `prev_entity` if specified
395
+ ent_out = {}
396
+ for key, value in entity.items():
397
+ ent_out[key] = value
398
+ if key == prev_entity:
399
+ ent_out.update(
400
+ entity2add
401
+ ) # Insert new entities immediately after `prev_entity`
402
+
403
+ # If no `prev_entity` is specified or if `prev_entity` is "suffix", append `entity2add` at the end
404
+ if prev_entity is None or prev_entity == "suffix":
405
+ ent_out.update(entity2add)
406
+
407
+ # Restore `suffix` and `extension` if they were removed
408
+ if suffix:
409
+ ent_out["suffix"] = suffix
410
+ if extension:
411
+ ent_out["extension"] = extension
412
+
413
+ # Convert back to string format if the original input was a string
414
+ if is_string:
415
+ return entity2str(ent_out)
416
+
417
+ return ent_out
418
+
419
+
420
+ ####################################################################################################
421
+ def recursively_replace_entity_value(
422
+ root_dir: str, dict2old: Union[dict, str], dict2new: Union[dict, str]
423
+ ):
424
+ """
425
+ This method replaces the values of certain entities in all the files and folders of a BIDs dataset.
426
+
427
+ Parameters
428
+ ----------
429
+ root_dir: str
430
+ Root directory of the BIDs dataset
431
+
432
+ dict2old: dict or str
433
+ Dictionary containing the entities to replace and their old values
434
+
435
+ dict2new: dict or str
436
+ Dictionary containing the entities to replace and their new values
437
+
438
+
439
+ """
440
+
441
+ # Detect if the BIDs directory exists
442
+ if not os.path.isdir(root_dir):
443
+ raise ValueError("The BIDs directory does not exist.")
444
+
445
+ # Convert the strings to dictionaries
446
+ if isinstance(dict2old, str):
447
+ dict2old = str2entity(dict2old)
448
+ if isinstance(dict2new, str):
449
+ dict2new = str2entity(dict2new)
450
+
451
+ # Leave in the dictionaries only the keys that are common
452
+ dict2old = {k: dict2old[k] for k in dict2old if k in dict2new}
453
+ dict2new = {k: dict2new[k] for k in dict2new if k in dict2old}
454
+
455
+ # Order the dictionaries alphabetically by key
456
+ dict2old = dict(sorted(dict2old.items()))
457
+ dict2new = dict(sorted(dict2new.items()))
458
+
459
+ # Creating the list of strings
460
+ dict2old_list = [f"{key}-{value}" for key, value in dict2old.items()]
461
+ dict2new_list = [f"{key}-{value}" for key, value in dict2new.items()]
462
+
463
+ replacements = dict(zip(dict2old_list, dict2new_list))
464
+
465
+ #
466
+ all_files = cltmisc.get_all_files(
467
+ root_dir, or_filter=dict2old_list[0], and_filter=dict2old_list
468
+ )
469
+
470
+ if not all_files:
471
+ print(
472
+ "No files found that match the specified entities. Please check the input parameters."
473
+ )
474
+ return
475
+
476
+ else:
477
+ all_dirs = []
478
+ for file in all_files:
479
+ file_path = os.path.dirname(file)
480
+ file_name = os.path.basename(file)
481
+ all_dirs.append(file_path)
482
+
483
+ for i, subst_x in enumerate(dict2old_list):
484
+ subst_y = dict2new_list[i]
485
+ if subst_x in file_name:
486
+ old_path = os.path.join(file_path, file_name)
487
+ new_name = file_name.replace(subst_x, subst_y)
488
+ new_path = os.path.join(file_path, new_name)
489
+ os.rename(old_path, new_path)
490
+ file_name = (
491
+ new_name # Update old_path to the new path after renaming
492
+ )
493
+
494
+ all_dirs = set(all_dirs) # Remove duplicates from the directory list
495
+
496
+ # Renaming the directories
497
+ cltmisc.rename_folders(all_dirs, replacements)
498
+
499
+
500
+ ####################################################################################################
501
+ def recursively_replace_entity_key(root_dir: str, replacements: dict):
502
+ """
503
+ This method replaces the keys of certain entities in all the files and folders of a BIDs dataset.
504
+
505
+ Parameters
506
+ ----------
507
+ root_dir: str
508
+ Root directory of the BIDs dataset
509
+
510
+ replacements: dict
511
+ Dictionary containing the entities to replace and their new keys.
512
+ Example: {'acq': 'desc', 'run': 'runny'}
513
+
514
+ Returns
515
+ -------
516
+ None
517
+ The method will rename the files and folders in the BIDs dataset. All the files or folders containing the old
518
+ entities' names on their names will be renamed and the old entities will be replaced with the new entities.
519
+
520
+ """
521
+ # Detect if the BIDs directory exists
522
+ if not os.path.isdir(root_dir):
523
+ raise ValueError("The BIDs directory does not exist.")
524
+
525
+ old_keys = list(replacements.keys())
526
+ new_keys = list(replacements.values())
527
+
528
+ all_files = cltmisc.get_all_files(
529
+ root_dir, or_filter=old_keys[0], and_filter=old_keys
530
+ )
531
+
532
+ if not all_files:
533
+ print(
534
+ "No files found that match the specified entities. Please check the input parameters."
535
+ )
536
+ return
537
+
538
+ else:
539
+ all_dirs = []
540
+ for file in all_files:
541
+ file_path = os.path.dirname(file)
542
+ file_name = os.path.basename(file)
543
+ all_dirs.append(file_path)
544
+
545
+ for i, subst_x in enumerate(old_keys):
546
+ subst_y = new_keys[i]
547
+ if subst_x in file_name:
548
+ old_path = os.path.join(file_path, file_name)
549
+ new_name = file_name.replace(subst_x, subst_y)
550
+ new_path = os.path.join(file_path, new_name)
551
+ os.rename(old_path, new_path)
552
+ file_name = (
553
+ new_name # Update old_path to the new path after renaming
554
+ )
555
+
556
+ all_dirs = set(all_dirs) # Remove duplicates from the directory list
557
+
558
+ # Renaming the directories
559
+ cltmisc.rename_folders(all_dirs, replacements)
560
+
561
+
562
+ def recursively_delete_entity(root_dir: str, key2rem: Union[List[str], str, dict]):
563
+ """
564
+ This method deletes entities in all the files and folders of a BIDs dataset.
565
+
566
+ Parameters
567
+ ----------
568
+ root_dir: str
569
+ Root directory of the BIDs dataset
570
+
571
+ key2rem: list or str
572
+ Key(s) of the entities that will be removed from the files and folders.
573
+
574
+ Returns
575
+ -------
576
+ None
577
+ The method will rename the files and folders in the BIDs dataset, removing from file names and folder names the entities containing the specified keys.
578
+
579
+
580
+ """
581
+
582
+ # Detect if the BIDs directory exists
583
+ root_dir = os.path.abspath(root_dir)
584
+ if not os.path.isdir(root_dir):
585
+ raise ValueError("The BIDs directory does not exist.")
586
+
587
+ # Convert the strings to lists
588
+ if isinstance(key2rem, str):
589
+ key2rem = [key2rem]
590
+
591
+ if isinstance(key2rem, dict):
592
+ tmp_keys = list(key2rem.keys())
593
+ all_files = cltmisc.get_all_files(
594
+ root_dir, or_filter=tmp_keys[0], and_filter=tmp_keys
595
+ )
596
+ else:
597
+
598
+ all_files = cltmisc.get_all_files(
599
+ root_dir, or_filter=key2rem[0], and_filter=key2rem
600
+ )
601
+
602
+ if not all_files:
603
+ print(
604
+ "No files found that match the specified entities. Please check the input parameters."
605
+ )
606
+ return
607
+
608
+ else:
609
+ all_dirs = []
610
+ for file in all_files:
611
+ file_path = os.path.dirname(file)
612
+ file_name = os.path.basename(file)
613
+ all_dirs.append(file_path)
614
+
615
+ new_entity = delete_entity(file_name, key2rem)
616
+
617
+ old_path = os.path.join(file_path, file_name)
618
+ new_path = os.path.join(file_path, new_entity)
619
+ os.rename(old_path, new_path)
620
+
621
+ all_dirs = set(all_dirs) # Remove duplicates from the directory list
622
+
623
+ # Renaming the directories
624
+ key2rem_dict = {key: "" for key in key2rem} # Create a dict with empty values
625
+ cltmisc.rename_folders(all_dirs, key2rem_dict)
626
+
627
+
628
+ def recursively_insert_entity(
629
+ root_dir: str, entity2add: Dict[str, str], prev_entity: str = None
630
+ ) -> None:
631
+ """
632
+ This method inserts entities in all the files and folders of a BIDs dataset.
633
+
634
+ Parameters
635
+ ----------
636
+ root_dir: str
637
+ Root directory of the BIDs dataset
638
+
639
+ entity2add: dict
640
+ Dictionary containing the entities to add.
641
+ Example: {'task': 'rest', 'run': '01'}
642
+
643
+ prev_entity: str, optional
644
+ Key in `entity` after which to insert the new entities. Otherwise it will be added at the end of the file name, just before the suffix.
645
+
646
+ Returns
647
+ -------
648
+ None
649
+ The method will rename the files and folders in the BIDs dataset. All the files or folders containing the old
650
+ entities' names on their names will be renamed and the old entities will be replaced with the new entities.
651
+
652
+ """
653
+
654
+ # Detect if the BIDs directory exists
655
+ root_dir = os.path.abspath(root_dir)
656
+ if not os.path.isdir(root_dir):
657
+ raise ValueError("The BIDs directory does not exist.")
658
+
659
+ # Convert the strings to dictionaries
660
+ if isinstance(entity2add, str):
661
+ entity2add = str2entity(entity2add)
662
+
663
+ # Order the dictionaries alphabetically by key
664
+ entity2add = dict(sorted(entity2add.items()))
665
+
666
+ # Creating the list of strings
667
+ entity2add_list = [f"{key}-{value}" for key, value in entity2add.items()]
668
+
669
+ if prev_entity is not None:
670
+ all_files = cltmisc.get_all_files(root_dir, or_filter=prev_entity)
671
+ else:
672
+ all_files = cltmisc.get_all_files(root_dir, or_filter="sub-")
673
+
674
+ if not all_files:
675
+ print(
676
+ "No files found that match the specified entities. Please check the input parameters."
677
+ )
678
+ return
679
+
680
+ else:
681
+ all_dirs = []
682
+ for file in all_files:
683
+ file_path = os.path.dirname(file)
684
+ file_name = os.path.basename(file)
685
+ all_dirs.append(file_path)
686
+
687
+ new_entity = insert_entity(file_name, entity2add, prev_entity=prev_entity)
688
+
689
+ old_path = os.path.join(file_path, file_name)
690
+ new_path = os.path.join(file_path, new_entity)
691
+ os.rename(old_path, new_path)
692
+
693
+
694
+ ####################################################################################################
695
+ def get_all_entities(root_dir: str) -> Tuple[Dict[str, Set[str]], List[str]]:
696
+ """
697
+ Returns a set of all unique entities found in the BIDS dataset.
698
+
699
+ Parameters
700
+ ----------
701
+ root_dir : str
702
+ Root directory of the BIDS dataset.
703
+
704
+ Returns
705
+ -------
706
+ all_entities: Set[str]
707
+ A set of unique entity names found in the dataset.
708
+
709
+ all_suffixes: List[str]
710
+ A list of unique suffixes found in the dataset.
711
+
712
+ Raises
713
+ ------
714
+ ValueError
715
+ If the specified root directory does not exist.
716
+ FileNotFoundError
717
+ If the default configuration file is not found.
718
+ ValueError
719
+ If the default configuration JSON does not have the expected structure.
720
+
721
+ Examples
722
+ --------
723
+ >>> get_all_entities('/path/to/bids/dataset')
724
+ {'sub', 'ses', 'task', 'run', ...}
725
+ >>> get_all_entities('/path/to/bids/dataset')
726
+ {'sub', 'ses', 'task', 'run', ...}, ['T1w', 'bold', ...]
727
+
728
+ """
729
+
730
+ # Check if the root directory exists
731
+ if not os.path.isdir(root_dir):
732
+ raise ValueError(f"The specified root directory does not exist: {root_dir}")
733
+
734
+ # Load the config bids.json file
735
+ cwd = os.path.dirname(os.path.abspath(__file__))
736
+
737
+ try:
738
+ config_data = load_bids_json()
739
+
740
+ # Merge raw and derivatives entities
741
+ if (
742
+ "bids_entities" in config_data
743
+ and "raw_entities" in config_data["bids_entities"]
744
+ and "derivatives_entities" in config_data["bids_entities"]
745
+ ):
746
+ entities = {
747
+ **config_data["bids_entities"]["raw_entities"],
748
+ **config_data["bids_entities"]["derivatives_entities"],
749
+ }
750
+ else:
751
+ raise ValueError(
752
+ "Default config JSON does not have the expected structure."
753
+ )
754
+ except FileNotFoundError:
755
+ raise FileNotFoundError(
756
+ f"Default configuration file not found at: {os.path.join(cwd, 'config', 'bids.json')}"
757
+ )
758
+
759
+ # Get all the leaf directories in the BIDS dataset
760
+ bids_folders = cltmisc.get_leaf_directories(root_dir)
761
+
762
+ # Get all the files in the folder
763
+ # Leave only the files that have a correct BIDs suffix
764
+ suffixes = config_data["bids_entities"]["raw_suffix"]
765
+ extensions = config_data["bids_entities"]["extensions"]
766
+
767
+ # Multiply the suffixes and extensions to get all the possible combinations
768
+ suffixes = [f"_{s}{e}" for s in suffixes for e in extensions]
769
+
770
+ bids_files = cltmisc.get_all_files(root_dir, or_filter=suffixes)
771
+
772
+ # Leave only the directories that are BIDs folders
773
+ bids_folders = cltmisc.filter_by_substring(
774
+ bids_folders, config_data["bids_entities"]["bids_folders"]
775
+ )
776
+
777
+ # Leave only the files that are BIDs files
778
+ bids_files = cltmisc.filter_by_substring(bids_files, bids_folders)
779
+
780
+ # Initialize a set to store all unique entities
781
+ all_entities = []
782
+ all_suffixes = []
783
+ for file in bids_files:
784
+
785
+ filename = os.path.basename(file)
786
+ ent_dict = str2entity(filename)
787
+
788
+ # Remove suffix and extension from the entity dictionary
789
+ if "suffix" in ent_dict:
790
+ suffix = ent_dict["suffix"]
791
+ all_suffixes.append(suffix)
792
+ del ent_dict["suffix"]
793
+
794
+ if "extension" in ent_dict:
795
+ del ent_dict["extension"]
796
+
797
+ if "run" in ent_dict:
798
+ del ent_dict["run"]
799
+
800
+ file_ent_keys = list(ent_dict.keys())
801
+
802
+ all_entities.extend(file_ent_keys)
803
+
804
+ # Unique entities
805
+ all_entities = sorted(set(all_entities))
806
+ all_suffixes = sorted(set(all_suffixes))
807
+
808
+ selected_ent_dict = entities4table(selected_entities=all_entities)
809
+
810
+ return selected_ent_dict, all_suffixes
811
+
812
+
813
+ ####################################################################################################
814
+ def entities4table(
815
+ entities_json: str = None, selected_entities: Union[str, Dict, List] = None
816
+ ) -> Dict:
817
+ """
818
+ Returns the BIDS entities that will be included in the morphometric table.
819
+
820
+ This function loads BIDS entities from a JSON configuration file and filters
821
+ them based on optional selected entities.
822
+
823
+ Parameters
824
+ ----------
825
+ entities_json : str, optional
826
+ Path to the JSON file with entity definitions.
827
+ If None, the method uses the default config JSON file.
828
+ selected_entities : Union[str, Dict, List], optional
829
+ Entities to select from the loaded entities. Can be:
830
+ - A string with comma-separated entity names
831
+ - A dictionary with entity names as keys
832
+ - A list of entity names
833
+ If None, all entities are included.
834
+
835
+ Returns
836
+ -------
837
+ Dict
838
+ Dictionary of entity names and their values.
839
+
840
+ Raises
841
+ ------
842
+ ValueError
843
+ If the provided JSON file path is invalid or the JSON format is incorrect.
844
+ FileNotFoundError
845
+ If the specified JSON file does not exist.
846
+
847
+ Examples
848
+ --------
849
+ >>> # Using default config file (returns all entities)
850
+ >>> entities4table()
851
+ {'sub': {'...'}, 'ses': {'...'}, ... 'scale': {'...'}}
852
+
853
+ >>> # Using a custom JSON file
854
+ >>> entities4table('path/to/custom/entities.json')
855
+ {'sub': {'...'}, 'ses': {'...'}, ... 'scale': {'...'}}
856
+
857
+ >>> # Selecting specific entities
858
+ >>> entities4table(selected_entities='sub,ses,run')
859
+ {'sub': {'...'}, 'ses': {'...'}, 'run': {'...'}}
860
+
861
+ >>> # Using a dictionary to select entities
862
+ >>> entities4table(selected_entities={'sub': None, 'ses': None})
863
+ {'sub': {'...'}, 'ses': {'...'}}
864
+
865
+ >>> # Using a list to select entities
866
+ >>> entities4table(selected_entities=['sub', 'ses'])
867
+ {'sub': {'...'}, 'ses': {'...'}}
868
+ """
869
+ import os
870
+ import json
871
+ from typing import Dict, Union, List
872
+
873
+ # Load entities from JSON
874
+ if entities_json is None:
875
+ # Define path to default config JSON
876
+ default_config_path = os.path.join(
877
+ os.path.dirname(__file__), "config", "bids.json"
878
+ )
879
+ try:
880
+ config_data = load_bids_json(default_config_path)
881
+
882
+ # Merge raw and derivatives entities
883
+ if (
884
+ "bids_entities" in config_data
885
+ and "raw_entities" in config_data["bids_entities"]
886
+ and "derivatives_entities" in config_data["bids_entities"]
887
+ ):
888
+ ent_out_dict = {
889
+ **config_data["bids_entities"]["raw_entities"],
890
+ **config_data["bids_entities"]["derivatives_entities"],
891
+ }
892
+ else:
893
+ raise ValueError(
894
+ "Default config JSON does not have the expected structure."
895
+ )
896
+ except FileNotFoundError:
897
+ raise FileNotFoundError(
898
+ f"Default configuration file not found at: {default_config_path}"
899
+ )
900
+
901
+ elif isinstance(entities_json, str):
902
+ # Load from provided JSON file path
903
+ if not os.path.isfile(entities_json):
904
+ raise FileNotFoundError(f"JSON file not found: {entities_json}")
905
+
906
+ try:
907
+
908
+ ent_out_dict = cltmisc.extract_string_values(entities_json)
909
+ except json.JSONDecodeError:
910
+ raise ValueError(f"Error parsing the JSON file: {entities_json}")
911
+ else:
912
+ raise TypeError("entities_json must be None or a string path to a JSON file.")
913
+
914
+ # Filter entities based on selected_entities
915
+ if selected_entities is not None:
916
+ selected_entity_keys = []
917
+
918
+ # Handle string input (convert to list of keys)
919
+ if isinstance(selected_entities, str):
920
+ try:
921
+ # Assume it's a comma-separated string
922
+ if "," in selected_entities:
923
+ selected_entity_keys = [
924
+ e.strip() for e in selected_entities.split(",")
925
+ ]
926
+ elif is_bids_filename(selected_entities):
927
+ selected_entities = str2entity(selected_entities)
928
+ selected_entity_keys = list(selected_entities.keys())
929
+
930
+ except (ImportError, AttributeError):
931
+ raise ValueError(
932
+ "Cannot parse selected_entities string. Provide a comma-separated list or a BIDs-like string (e.g. sub-XXX_ses-SSS_run-01 )."
933
+ )
934
+
935
+ # Handle dictionary input
936
+ elif isinstance(selected_entities, dict):
937
+ selected_entity_keys = list(selected_entities.keys())
938
+
939
+ # Handle list input
940
+ elif isinstance(selected_entities, list):
941
+ selected_entity_keys = selected_entities
942
+
943
+ # Filter the output dictionary to include only selected entities
944
+ ent_out_dict = {
945
+ k: v for k, v in ent_out_dict.items() if k in selected_entity_keys
946
+ }
947
+
948
+ return ent_out_dict
949
+
950
+
951
+ ####################################################################################################
952
+ def entities_to_table(
953
+ filepath: str,
954
+ entities_to_extract: Optional[Union[str, List[str], Dict[str, str]]] = None,
955
+ ) -> pd.DataFrame:
956
+ """
957
+ Creates a DataFrame with BIDS entities extracted from a filename.
958
+
959
+ This function parses BIDS-compliant filenames to extract specified entities
960
+ (such as subject, session, task) and organizes them into a DataFrame with
961
+ appropriate column names. It supports special handling for certain entities
962
+ like atlas and description fields.
963
+
964
+ Parameters
965
+ ----------
966
+ filepath : str
967
+ Full path to the BIDS file from which to extract entities.
968
+ entities_to_extract : str, list, dict, or None, default=None
969
+ Specifies which entities to extract from the filename:
970
+ - If str: A single entity name to extract
971
+ - If list: Multiple entity names to extract
972
+ - If dict: Keys are entity names, values are custom column names
973
+ - If None: Returns a single column with the full filename
974
+
975
+ Returns
976
+ -------
977
+ pd.DataFrame
978
+ DataFrame containing the extracted entities as columns.
979
+ If the file is not BIDS-compliant, returns an empty DataFrame.
980
+
981
+ Examples
982
+ --------
983
+ >>> # Extract subject and session from a BIDS file
984
+ >>> df = entities_to_table(
985
+ ... '/data/sub-01/ses-pre/sub-01_ses-pre_task-rest_bold.nii.gz',
986
+ ... ['sub', 'ses']
987
+ ... )
988
+ >>> print(df)
989
+ Participant Session
990
+ 0 01 pre
991
+
992
+ >>> # Extract entities with custom column names
993
+ >>> df = entities_to_table(
994
+ ... '/data/sub-01/anat/sub-01_T1w.nii.gz',
995
+ ... {'sub': 'SubjectID'}
996
+ ... )
997
+ >>> print(df)
998
+ SubjectID
999
+ 0 01
1000
+
1001
+ >>> # Extract atlas with special handling
1002
+ >>> df = entities_to_table(
1003
+ ... '/data/sub-01/atlas-chimera123_desc-parcellation.nii.gz',
1004
+ ... ['atlas', 'desc']
1005
+ ... )
1006
+ >>> print(df)
1007
+ Atlas ChimeraCode Description
1008
+ 0 chimera 123 parcellation
1009
+
1010
+ """
1011
+ # Type checking
1012
+ if filepath is None or not isinstance(filepath, str):
1013
+ raise TypeError("filepath must be a string")
1014
+
1015
+ # Reading the mapping dictionary
1016
+ cwd = os.path.dirname(os.path.abspath(__file__))
1017
+ default_config_path = os.path.join(cwd, "config", "bids.json")
1018
+ try:
1019
+ with open(default_config_path, "r") as f:
1020
+ config_data = json.load(f)
1021
+
1022
+ # Merge raw and derivatives entities
1023
+ if (
1024
+ "bids_entities" in config_data
1025
+ and "raw_entities" in config_data["bids_entities"]
1026
+ and "derivatives_entities" in config_data["bids_entities"]
1027
+ ):
1028
+ ent_out_dict = {
1029
+ **config_data["bids_entities"]["raw_entities"],
1030
+ **config_data["bids_entities"]["derivatives_entities"],
1031
+ }
1032
+ else:
1033
+ raise ValueError(
1034
+ "Default config JSON does not have the expected structure."
1035
+ )
1036
+ except FileNotFoundError:
1037
+ raise FileNotFoundError(
1038
+ f"Default configuration file not found at: {default_config_path}"
1039
+ )
1040
+ except json.JSONDecodeError:
1041
+ raise ValueError(
1042
+ f"Error parsing the default configuration file: {default_config_path}"
1043
+ )
1044
+
1045
+ # Extract file directory and name
1046
+ file_directory = os.path.dirname(filepath)
1047
+ filename = os.path.basename(filepath)
1048
+
1049
+ # Create an empty DataFrame
1050
+ result_df = pd.DataFrame()
1051
+
1052
+ # Only process if the file follows BIDS naming convention
1053
+ if is_bids_filename(filename):
1054
+
1055
+ # Parse entities from the filename
1056
+ entities_dict = str2entity(filename)
1057
+
1058
+ # Remove the 'extension' key if it exists
1059
+ if "extension" in entities_dict:
1060
+ entities_dict.pop("extension")
1061
+ # Remove the 'suffix' key if it exists
1062
+ if "suffix" in entities_dict:
1063
+ entities_dict.pop("suffix")
1064
+
1065
+ if entities_to_extract is not None:
1066
+ # Convert string input to list
1067
+ if isinstance(entities_to_extract, str):
1068
+ entities_to_extract = [entities_to_extract]
1069
+
1070
+ # Convert list to dictionary for unified processing
1071
+ if isinstance(entities_to_extract, list):
1072
+ # Create dict with entity names as both keys and values
1073
+ entities_to_extract = {entity: "" for entity in entities_to_extract}
1074
+ else:
1075
+ entities_to_extract = {entity: "" for entity in entities_dict.keys()}
1076
+
1077
+ # Initialize DataFrame with one empty row
1078
+ if result_df.empty:
1079
+ result_df = pd.DataFrame([{}])
1080
+
1081
+ # Process entities in reverse order to maintain column order
1082
+ entity_keys = list(entities_to_extract.keys())
1083
+ for entity in reversed(entity_keys):
1084
+ # Get entity value from filename or empty string if not found
1085
+ value = entities_dict.get(entity, "")
1086
+
1087
+ if entity in ent_out_dict.keys():
1088
+ # If entity is in the mapping dictionary, use its value
1089
+ var_name = ent_out_dict[entity]
1090
+ if entity == "atlas":
1091
+ # Special handling for atlas
1092
+ if "chimera" in value:
1093
+ result_df.insert(0, "ChimeraCode", value.replace("chimera", ""))
1094
+ result_df.insert(0, "Atlas", "chimera")
1095
+
1096
+ else:
1097
+ result_df.insert(0, "ChimeraCode", "")
1098
+ result_df.insert(0, "Atlas", value)
1099
+
1100
+ elif entity == "desc":
1101
+ # Special handling for description
1102
+ result_df.insert(0, "Description", value)
1103
+ if "grow" in value:
1104
+ result_df.insert(0, "GrowIntoWM", value.replace("grow", ""))
1105
+
1106
+ else:
1107
+ result_df.insert(0, var_name, value)
1108
+ else:
1109
+
1110
+ result_df.insert(0, entity.capitalize(), value)
1111
+
1112
+ else:
1113
+ # If no entities specified, use full filename as Participant
1114
+ if result_df.empty:
1115
+ result_df = pd.DataFrame([{}])
1116
+
1117
+ if "Participant" not in result_df.columns:
1118
+ # If 'Participant' column doesn't exist, create it
1119
+ # Remove the extension from the filename in case it exists
1120
+ temp = os.path.splitext(filename)[0]
1121
+ result_df.insert(0, "Participant", temp)
1122
+
1123
+ # Declaring all the columns as string type
1124
+ for col in result_df.columns:
1125
+ result_df[col] = result_df[col].astype(str)
1126
+
1127
+ return result_df
1128
+
1129
+
1130
+ ####################################################################################################
1131
+ ####################################################################################################
1132
+ ############ ############
1133
+ ############ ############
1134
+ ############ Section 2: Methods dedicated to work with BIDs file organization ############
1135
+ ############ ############
1136
+ ############ ############
1137
+ ####################################################################################################
1138
+ ####################################################################################################
1139
+ def get_subjects(bids_dir: str) -> list:
1140
+ """
1141
+ Get a list of all subjects in the BIDs directory.
1142
+
1143
+ Parameters
1144
+ ----------
1145
+ bids_dir : str
1146
+ Path to the BIDs directory.
1147
+
1148
+ Returns
1149
+ -------
1150
+ list
1151
+ List of subject IDs.
1152
+
1153
+ Usage example:
1154
+ >>> bids_dir = "/path/to/bids"
1155
+ >>> print(get_subjects(bids_dir))
1156
+ ["sub-01", "sub-02", ...]
1157
+
1158
+ """
1159
+ subjects = []
1160
+
1161
+ for root, dirs, files in os.walk(bids_dir):
1162
+ for dir_name in dirs:
1163
+ if dir_name.startswith("sub-"):
1164
+ subjects.append(dir_name)
1165
+
1166
+ return subjects
1167
+
1168
+
1169
+ ####################################################################################################
1170
+ def copy_bids_folder(
1171
+ bids_dir: str,
1172
+ out_dir: str,
1173
+ subjects_to_copy: Union[list, str] = None,
1174
+ folders_to_copy: Union[list, str] = "all",
1175
+ deriv_dir: str = None,
1176
+ include_derivatives: Union[str, list] = None,
1177
+ ):
1178
+ """
1179
+ This function copies the BIDs folder and its derivatives for given subjects to a new location.
1180
+
1181
+ Parameters
1182
+ ----------
1183
+ bids_dir : str
1184
+ Path to the BIDs directory.
1185
+ out_dir : str
1186
+ Path to the output directory where the copied BIDs folder will be saved.
1187
+ subjects_to_copy : list or str, optional
1188
+ List of subject IDs to copy. If None, all subjects will be copied.
1189
+ folders_to_copy : list or str, optional
1190
+ List of BIDs folders to copy. If "all", all folders will be copied. Default is "all".
1191
+ deriv_dir : str, optional
1192
+ Path to the derivatives directory. If None, it will be set to "derivatives" in the BIDs directory.
1193
+ include_derivatives : str or list, optional
1194
+ List of derivatives to include. If "all", all derivatives will be included. Default is None.
1195
+ If None, no derivatives will be copied.
1196
+ If "chimera", only the chimera derivatives will be copied.
1197
+ If "all", all derivatives will be copied.
1198
+ If a list, only the derivatives in the list will be copied.
1199
+ If a string, only the derivatives with the name in the string will be copied.
1200
+
1201
+ Returns
1202
+ -------
1203
+ None
1204
+ Copies the specified folders and subjects to the output directory.
1205
+
1206
+ Usage example:
1207
+ >>> bids_dir = "/path/to/bids"
1208
+ >>> out_dir = "/path/to/output"
1209
+ >>> copy_bids_folder(bids_dir, out_dir, subjects_to_copy=["sub-01"], folders_to_copy=["anat"])
1210
+ >>> copy_bids_folder(bids_dir, out_dir, subjects_to_copy=["sub-01"], include_derivatives=["chimera", "freesurfer"])
1211
+ >>> copy_bids_folder(bids_dir, out_dir, subjects_to_copy=["sub-01"], deriv_dir="/path/to/derivatives")
1212
+
1213
+ """
1214
+
1215
+ bids_dir = cltmisc.remove_trailing_separators(bids_dir)
1216
+ out_dir = cltmisc.remove_trailing_separators(out_dir)
1217
+
1218
+ if not os.path.isdir(bids_dir):
1219
+ raise FileNotFoundError(f"The BIDs directory {bids_dir} does not exist.")
1220
+
1221
+ if not os.path.isdir(out_dir):
1222
+ raise FileNotFoundError(f"The output directory {out_dir} does not exist.")
1223
+
1224
+ if deriv_dir is not None:
1225
+ deriv_dir = cltmisc.remove_trailing_separators(deriv_dir)
1226
+ if not os.path.isdir(deriv_dir):
1227
+ raise FileNotFoundError(
1228
+ f"The derivatives directory {deriv_dir} does not exist."
1229
+ )
1230
+
1231
+ # Selecting the subjects that will be copied
1232
+ if isinstance(subjects_to_copy, str):
1233
+ subjects_to_copy = [subjects_to_copy]
1234
+
1235
+ if subjects_to_copy is None:
1236
+ subjects_to_copy = get_subjects(bids_dir)
1237
+ else:
1238
+ # Check if the cad "sub-" is in the subjects_to_copy of not add it
1239
+ subjects_to_copy = [
1240
+ sub if sub.startswith("sub-") else f"sub-{sub}" for sub in subjects_to_copy
1241
+ ]
1242
+
1243
+ # Selecting the BIDs folders that will be copied
1244
+ if isinstance(folders_to_copy, str):
1245
+ folders_to_copy = [folders_to_copy]
1246
+
1247
+ if "all" in folders_to_copy:
1248
+ folders_to_copy = ["all"]
1249
+
1250
+ # Number of subjects to copy
1251
+ n_subj = len(subjects_to_copy)
1252
+
1253
+ # Selecting the derivatives folder
1254
+ if include_derivatives is not None:
1255
+ copy_derivatives = True
1256
+
1257
+ if deriv_dir is None:
1258
+ deriv_dir = os.path.join(bids_dir, "derivatives")
1259
+
1260
+ if not os.path.isdir(deriv_dir):
1261
+ # Lunch a warning message if the derivatives folder does not exist
1262
+ print("WARNING: The derivatives folder does not exist.")
1263
+ print("WARNING: The derivatives folder will not be copied.")
1264
+ copy_derivatives = False
1265
+ else:
1266
+ # Check if the derivatives folder is empty
1267
+ if len(os.listdir(deriv_dir)) == 0:
1268
+ print("WARNING: The derivatives folder is empty.")
1269
+ print("WARNING: The derivatives folder will not be copied.")
1270
+ copy_derivatives = False
1271
+
1272
+ # Selecting all the derivatives folders
1273
+ directories = os.listdir(deriv_dir)
1274
+ der_pipe_folders = []
1275
+ for directory in directories:
1276
+ pipe_dir = os.path.join(deriv_dir, directory)
1277
+ if not directory.startswith(".") and os.path.isdir(pipe_dir):
1278
+ der_pipe_folders.append(pipe_dir)
1279
+
1280
+ if isinstance(include_derivatives, str):
1281
+ include_derivatives = [include_derivatives]
1282
+
1283
+ if "all" not in include_derivatives:
1284
+ include_derivatives = [
1285
+ os.path.join(deriv_dir, i) for i in include_derivatives
1286
+ ]
1287
+ der_pipe_folders = cltmisc.list_intercept(
1288
+ der_pipe_folders, include_derivatives
1289
+ )
1290
+
1291
+ if len(der_pipe_folders) == 0:
1292
+ print(
1293
+ "WARNING: No derivatives folders were found with the specified names."
1294
+ )
1295
+ copy_derivatives = False
1296
+ else:
1297
+ copy_derivatives = False
1298
+ der_pipe_folders = []
1299
+
1300
+ progress = Progress(
1301
+ TextColumn("[bold blue]{task.description}", justify="right"),
1302
+ BarColumn(bar_width=None),
1303
+ "[progress.percentage]{task.percentage:>3.0f}%",
1304
+ TimeRemainingColumn(),
1305
+ MofNCompleteColumn(),
1306
+ )
1307
+ with progress:
1308
+ task = progress.add_task(
1309
+ "[cyan]Copying BIDs folder and derivatives...", total=None
1310
+ )
1311
+ progress.update(task, total=n_subj)
1312
+ progress.start_task(task)
1313
+ progress.update(task, completed=0)
1314
+
1315
+ # Loop around all the subjects
1316
+ for i, full_id in enumerate(subjects_to_copy): # Loop along the IDs
1317
+
1318
+ # Extract the subject id
1319
+ subj_entity = str2entity(full_id)
1320
+
1321
+ # Remove the extension from the subj_entity
1322
+ if "extension" in subj_entity.keys():
1323
+ del subj_entity["extension"]
1324
+
1325
+ if "suffix" in subj_entity.keys():
1326
+ suffix = subj_entity["suffix"]
1327
+ del subj_entity["suffix"]
1328
+
1329
+ else:
1330
+ suffix = None
1331
+
1332
+ # Generate a list with the format [key_value]
1333
+ file_filter = [f"{k}-{v}" for k, v in subj_entity.items()]
1334
+ fold_filter = [f"{k}-{v}" for k, v in subj_entity.items()]
1335
+
1336
+ if suffix is not None:
1337
+ file_filter.append(f"_{suffix}")
1338
+
1339
+ if subj_entity is None:
1340
+ print(f"WARNING: The subject ID {full_id} is not valid.")
1341
+ continue
1342
+
1343
+ subj_id = subj_entity["sub"]
1344
+
1345
+ subj_dir = os.path.join(bids_dir, f"sub-{subj_id}")
1346
+ all_subj_subfold = cltmisc.get_leaf_directories(subj_dir)
1347
+
1348
+ # Upgrade the progress bar and include the subject id on the text
1349
+ progress.update(
1350
+ task, description=f"[red]Copying subject: {full_id}", completed=i + 1
1351
+ )
1352
+
1353
+ # Detect the session id if it exists
1354
+ ses_id = None
1355
+ if "ses" in subj_entity.keys():
1356
+ ses_id = subj_entity["ses"]
1357
+ full_ses_id = f"ses-{ses_id}"
1358
+
1359
+ all_subj_subfold = cltmisc.filter_by_substring(
1360
+ input_list=all_subj_subfold, or_filter=full_ses_id
1361
+ )
1362
+
1363
+ ####### Copying the BIDs folder
1364
+ if "all" not in folders_to_copy:
1365
+ tmp_list = [os.path.basename(i) for i in all_subj_subfold]
1366
+ indexes = cltmisc.get_indexes_by_substring(tmp_list, folders_to_copy)
1367
+ all_subj_subfold = [all_subj_subfold[i] for i in indexes]
1368
+
1369
+ raw_files_to_copy = []
1370
+ # Loop along all the folders to copy
1371
+ for fc in all_subj_subfold:
1372
+ all_subj_raw_files = cltmisc.get_all_files(fc)
1373
+
1374
+ raw_files_to_copy = raw_files_to_copy + all_subj_raw_files
1375
+
1376
+ # Filtering the files to copy according to the full
1377
+ raw_files_to_copy = cltmisc.filter_by_substring(
1378
+ input_list=raw_files_to_copy,
1379
+ or_filter=f"sub-{subj_id}",
1380
+ and_filter=file_filter,
1381
+ )
1382
+
1383
+ for file in raw_files_to_copy:
1384
+ if os.path.isfile(file):
1385
+ # Copying the file
1386
+ try:
1387
+ dest_dir = file.replace(bids_dir, out_dir)
1388
+ os.makedirs(os.path.dirname(dest_dir), exist_ok=True)
1389
+ shutil.copy2(file, dest_dir)
1390
+ except:
1391
+ print(f"WARNING: The file {file} could not be copied.")
1392
+ continue
1393
+
1394
+ # Copying the Derivatives folder
1395
+ if copy_derivatives:
1396
+ # Loop along all the derivatives folders
1397
+ for pipe_dir in der_pipe_folders:
1398
+ # Check if the derivatives folder exists
1399
+ if os.path.isdir(pipe_dir):
1400
+ # Copying the derivatives folder
1401
+ subj_deriv = glob(os.path.join(pipe_dir, f"sub-{subj_id}*"))
1402
+
1403
+ if len(subj_deriv) > 0:
1404
+
1405
+ # Detecting the files and folders to copy.
1406
+ # We are doing this separation because there are derivatives that contain
1407
+ # only the folders with the name of the subject and not the files (i.e. freesurfer)
1408
+
1409
+ # Loop along all the folders to copy
1410
+ deriv_files_to_copy = []
1411
+ deriv_folds_to_copy = []
1412
+ for deriv in subj_deriv:
1413
+
1414
+ # Detect the files and folders to copy
1415
+ tmp_fold_to_copy = cltmisc.filter_by_substring(
1416
+ input_list=deriv,
1417
+ or_filter=f"sub-{subj_id}",
1418
+ and_filter=fold_filter,
1419
+ )
1420
+
1421
+ if len(tmp_fold_to_copy) > 0:
1422
+ # Detect the files and folders to copy
1423
+ for tmp in tmp_fold_to_copy:
1424
+ if os.path.isdir(tmp):
1425
+ deriv_folds_to_copy.append(tmp)
1426
+ else:
1427
+ deriv_files_to_copy.append(tmp)
1428
+ else:
1429
+ all_subj_deriv_files = cltmisc.get_all_files(deriv)
1430
+
1431
+ # Filtering the files to copy according to the full
1432
+ all_subj_deriv_files = cltmisc.filter_by_substring(
1433
+ input_list=all_subj_deriv_files,
1434
+ or_filter=f"sub-{subj_id}",
1435
+ and_filter=fold_filter,
1436
+ )
1437
+
1438
+ deriv_files_to_copy = (
1439
+ deriv_files_to_copy + all_subj_deriv_files
1440
+ )
1441
+
1442
+ # Copying the folders
1443
+ if len(deriv_folds_to_copy) > 0:
1444
+ for fold in deriv_folds_to_copy:
1445
+ try:
1446
+ dest_dir = fold.replace(
1447
+ deriv_dir,
1448
+ os.path.join(out_dir, "derivatives"),
1449
+ )
1450
+ os.makedirs(dest_dir, exist_ok=True)
1451
+ shutil.copytree(
1452
+ fold, dest_dir, dirs_exist_ok=True
1453
+ )
1454
+ except:
1455
+ print(
1456
+ f"WARNING: The folder or file {fold} could not be copied."
1457
+ )
1458
+ continue
1459
+
1460
+ # Copying the files
1461
+ if len(deriv_files_to_copy) > 0:
1462
+ for file in deriv_files_to_copy:
1463
+ try:
1464
+ dest_dir = file.replace(
1465
+ deriv_dir,
1466
+ os.path.join(out_dir, "derivatives"),
1467
+ )
1468
+ os.makedirs(
1469
+ os.path.dirname(dest_dir), exist_ok=True
1470
+ )
1471
+ shutil.copy2(file, dest_dir)
1472
+ except:
1473
+ print(
1474
+ f"WARNING: The file {file} could not be copied."
1475
+ )
1476
+ continue
1477
+
1478
+ # Update the progress bar to 100%
1479
+ progress.update(task, completed=n_subj)
1480
+
1481
+
1482
+ ####################################################################################################
1483
+ def get_bids_database_table(
1484
+ root_dir: str, output_table: Optional[str] = None
1485
+ ) -> pd.DataFrame:
1486
+ """
1487
+ Generate a comprehensive summary table of all neuroimaging files in a BIDS dataset.
1488
+
1489
+ This function scans a BIDS dataset directory structure and creates a detailed table
1490
+ containing all BIDS entities (subject, session, acquisition, etc.) and file counts.
1491
+ The output table provides an overview of the dataset composition, making it easy to
1492
+ identify data availability, missing files, and dataset structure.
1493
+
1494
+ Parameters
1495
+ ----------
1496
+ root_dir : str
1497
+ Path to the BIDS dataset root directory. This should be the top-level directory
1498
+ containing subject folders (sub-*) and optionally a dataset_description.json file.
1499
+
1500
+ output_table : str, optional
1501
+ Path where the resulting CSV table should be saved. If None, the table is not
1502
+ saved to disk but still returned as a DataFrame. Default is None.
1503
+
1504
+ Returns
1505
+ -------
1506
+ pd.DataFrame
1507
+ DataFrame with columns for each detected BIDS entity (Subject, Session,
1508
+ Acquisition, etc.), plus 'suffix' (image type like T1w, FLAIR) and 'N'
1509
+ (number of files for each unique combination). Each row represents a unique
1510
+ combination of BIDS entities and their file count.
1511
+
1512
+ Raises
1513
+ ------
1514
+ FileNotFoundError
1515
+ If the specified root_dir does not exist.
1516
+ NotADirectoryError
1517
+ If root_dir exists but is not a directory.
1518
+ ValueError
1519
+ If no subjects are found in the BIDS dataset (no sub-* folders).
1520
+
1521
+ Examples
1522
+ --------
1523
+ Basic usage - analyze dataset and return summary table:
1524
+
1525
+ >>> import pandas as pd
1526
+ >>> bids_table = get_bids_table('/path/to/bids/dataset')
1527
+ >>> print(f"Dataset contains {len(bids_table)} unique file combinations")
1528
+ >>> print(f"Total files: {bids_table['N'].sum()}")
1529
+
1530
+ Save summary table to CSV file:
1531
+
1532
+ >>> bids_table = get_bids_table(
1533
+ ... root_dir='/data/my_study',
1534
+ ... output_table='/data/my_study/bids_summary.csv'
1535
+ ... )
1536
+
1537
+ Analyze specific aspects of the dataset:
1538
+
1539
+ >>> # Count files by image type
1540
+ >>> suffix_counts = bids_table.groupby('suffix')['N'].sum()
1541
+ >>> print("Files by image type:")
1542
+ >>> print(suffix_counts)
1543
+
1544
+ >>> # Check data availability per subject
1545
+ >>> subject_counts = bids_table.groupby('Subject')['N'].sum()
1546
+ >>> print("Files per subject:")
1547
+ >>> print(subject_counts)
1548
+
1549
+ >>> # Find subjects with specific image types
1550
+ >>> t1w_subjects = bids_table[bids_table['suffix'] == 'T1w']['Subject'].unique()
1551
+ >>> print(f"Subjects with T1w images: {len(t1w_subjects)}")
1552
+
1553
+ Example output table structure:
1554
+
1555
+ >>> print(bids_table.head())
1556
+ Subject Session Acquisition suffix N
1557
+ 0 sub-01 ses-01 acq-mprage T1w 1
1558
+ 1 sub-01 ses-01 acq-space T2w 1
1559
+ 2 sub-01 ses-01 None FLAIR 1
1560
+ 3 sub-02 ses-01 acq-mprage T1w 1
1561
+ 4 sub-02 ses-02 acq-mprage T1w 1
1562
+
1563
+ Notes
1564
+ -----
1565
+ - Only processes .nii.gz files (NIfTI compressed format)
1566
+ - Automatically detects all BIDS entities present in the dataset
1567
+ - Groups identical combinations and sums file counts
1568
+ - Results are sorted by Subject, Session, and suffix for readability
1569
+ - Progress is displayed using Rich progress bar during processing
1570
+ - Column names are converted to human-readable format (e.g., 'sub' -> 'Subject')
1571
+
1572
+ See Also
1573
+ --------
1574
+ clabtoolkit.bidstools.get_subjects : Get list of subjects in BIDS dataset
1575
+ clabtoolkit.bidstools.get_all_entities : Extract all BIDS entities from dataset
1576
+ clabtoolkit.bidstools.str2entity : Parse BIDS filename to extract entities
1577
+ """
1578
+
1579
+ # Check if the root directory exists
1580
+ if not os.path.exists(root_dir):
1581
+ raise FileNotFoundError(f"BIDS root directory not found: {root_dir}")
1582
+
1583
+ if not os.path.isdir(root_dir):
1584
+ raise NotADirectoryError(f"Provided path is not a directory: {root_dir}")
1585
+
1586
+ # Initialize console
1587
+ console = Console()
1588
+
1589
+ # Get all the subjects in the BIDs dataset
1590
+ subj_ids = get_subjects(root_dir)
1591
+
1592
+ if not subj_ids:
1593
+ raise ValueError(f"No subjects found in the BIDS dataset at {root_dir}")
1594
+
1595
+ # Get all entities in the BIDs dataset
1596
+ all_dataset_entities, all_dataset_suffixes = get_all_entities(root_dir)
1597
+
1598
+ console.print(f"[green]Detected entities: {list(all_dataset_entities.keys())}")
1599
+
1600
+ n_subj = len(subj_ids)
1601
+
1602
+ # Create column names
1603
+ column_names = list(all_dataset_entities.keys())
1604
+ column_names.extend(["suffix", "N"])
1605
+
1606
+ # List to store all rows
1607
+ all_rows = []
1608
+
1609
+ # Create Progress with explicit configuration
1610
+ progress = Progress(
1611
+ SpinnerColumn(),
1612
+ TextColumn("[progress.description]{task.description}"),
1613
+ BarColumn(bar_width=None),
1614
+ "[progress.percentage]{task.percentage:>3.1f}%",
1615
+ "•",
1616
+ MofNCompleteColumn(),
1617
+ "•",
1618
+ TimeRemainingColumn(),
1619
+ console=console,
1620
+ auto_refresh=True,
1621
+ refresh_per_second=10,
1622
+ )
1623
+
1624
+ # Use the progress bar
1625
+ with progress:
1626
+ # Add the task
1627
+ task_id = progress.add_task(
1628
+ description="Starting BIDS processing...", total=n_subj
1629
+ )
1630
+
1631
+ # Process each subject
1632
+ for i, subj_id in enumerate(subj_ids):
1633
+ # Update task description at the start of processing
1634
+ progress.update(
1635
+ task_id, description=f"Processing subject {subj_id}...", completed=i
1636
+ )
1637
+
1638
+ subj_dir = os.path.join(root_dir, subj_id)
1639
+ all_files = cltmisc.get_all_files(
1640
+ subj_dir, recursive=True, or_filter=[".nii.gz"]
1641
+ )
1642
+
1643
+ files_processed = 0
1644
+ for file_path in all_files:
1645
+ file_name = os.path.basename(file_path)
1646
+
1647
+ # Parse the BIDS filename to get entities
1648
+ ent_dict = str2entity(file_name)
1649
+
1650
+ # Create a row dictionary with all possible entity columns initialized to None
1651
+ row_dict = {col: None for col in column_names}
1652
+
1653
+ # Fill in the entities that are present in this file
1654
+ for entity, value in ent_dict.items():
1655
+ if entity in row_dict:
1656
+ row_dict[entity] = value
1657
+
1658
+ # Extract suffix from filename
1659
+ row_dict["suffix"] = ent_dict["suffix"]
1660
+
1661
+ # Set N to 1 for individual files (we'll aggregate later)
1662
+ row_dict["N"] = 1
1663
+
1664
+ all_rows.append(row_dict)
1665
+ files_processed += 1
1666
+
1667
+ # Update progress AFTER processing each subject (this is the key fix!)
1668
+ progress.update(
1669
+ task_id,
1670
+ description=f"Completed {subj_id} ({files_processed} files)",
1671
+ completed=i + 1, # i+1 because we just finished this subject
1672
+ )
1673
+
1674
+ # Force refresh to ensure display updates
1675
+ progress.refresh()
1676
+
1677
+ # Small delay to see the progress update
1678
+ time.sleep(0.1)
1679
+
1680
+ # Final update
1681
+ progress.update(task_id, description="Processing complete!", completed=n_subj)
1682
+
1683
+ # Brief pause to show completion
1684
+ time.sleep(0.5)
1685
+
1686
+ # Create DataFrame from all rows
1687
+ df_table = pd.DataFrame(all_rows)
1688
+
1689
+ if df_table.empty:
1690
+ return df_table
1691
+
1692
+ # Group by all columns except 'N' and sum the counts
1693
+ groupby_cols = [col for col in df_table.columns if col != "N"]
1694
+ df_grouped = (
1695
+ df_table.groupby(groupby_cols, dropna=False).agg({"N": "sum"}).reset_index()
1696
+ )
1697
+
1698
+ # Sort the DataFrame for better readability
1699
+ if not df_grouped.empty:
1700
+ sort_cols = []
1701
+ if "sub" in df_grouped.columns:
1702
+ sort_cols.append("sub")
1703
+ if "ses" in df_grouped.columns:
1704
+ sort_cols.append("ses")
1705
+ if "suffix" in df_grouped.columns:
1706
+ sort_cols.append("suffix")
1707
+
1708
+ if sort_cols:
1709
+ df_grouped = df_grouped.sort_values(sort_cols).reset_index(drop=True)
1710
+
1711
+ all_entities = entities4table()
1712
+
1713
+ old_colnames = cltmisc.list_intercept(
1714
+ list(df_grouped.columns), list(all_entities.keys())
1715
+ )
1716
+ new_colnames = list(entities4table(selected_entities=old_colnames).values())
1717
+
1718
+ # Rename columns to match BIDS entities
1719
+ df_grouped.rename(columns=dict(zip(old_colnames, new_colnames)), inplace=True)
1720
+
1721
+ # Save the table if output path is provided
1722
+ if output_table:
1723
+ df_grouped.to_csv(output_table, index=False)
1724
+
1725
+ return df_grouped
1726
+
1727
+
1728
+ ####################################################################################################
1729
+ def get_derivatives_folders(
1730
+ deriv_dir: str,
1731
+ ) -> list:
1732
+ """
1733
+ Get a list of all derivatives folders in the specified directory.
1734
+
1735
+ Parameters
1736
+ ----------
1737
+ deriv_dir : str
1738
+ Path to the derivatives directory.
1739
+
1740
+ Returns
1741
+ -------
1742
+ list
1743
+ List of derivatives folder names.
1744
+
1745
+ Raises
1746
+ ------
1747
+ ValueError
1748
+ If the derivatives directory does not exist.
1749
+
1750
+ TypeError
1751
+ If the derivatives directory is not a string.
1752
+
1753
+ Usage example:
1754
+ >>> deriv_dir = "/path/to/derivatives"
1755
+ >>> print(get_derivatives_folders(deriv_dir))
1756
+
1757
+ """
1758
+
1759
+ # Check if the derivatives directory exists
1760
+ if not os.path.isdir(deriv_dir):
1761
+ raise ValueError("The derivatives directory does not exist.")
1762
+
1763
+ # Get all directories in the derivatives directory
1764
+ directories = os.listdir(deriv_dir)
1765
+
1766
+ # Filter out hidden directories and keep only valid directories
1767
+ der_pipe_folders = []
1768
+ for directory in directories:
1769
+ pipe_dir = os.path.join(deriv_dir, directory)
1770
+ if not directory.startswith(".") and os.path.isdir(pipe_dir):
1771
+ der_pipe_folders.append(directory)
1772
+
1773
+ # Remove the derivatives folders that do not include folders starting with "sub-"
1774
+ der_pipe_folders = [
1775
+ i
1776
+ for i in der_pipe_folders
1777
+ if any(j.startswith("sub-") for j in os.listdir(os.path.join(deriv_dir, i)))
1778
+ ]
1779
+
1780
+ return der_pipe_folders
1781
+
1782
+
1783
+ ####################################################################################################
1784
+ def is_bids_filename(filename: str) -> bool:
1785
+ """
1786
+ Validates a BIDS filename structure, handling extensions and entity order.
1787
+
1788
+ Args:
1789
+ filename (str): The filename to validate.
1790
+
1791
+ Returns
1792
+ bool: True if valid BIDS filename, False otherwise.
1793
+ """
1794
+ # Remove extension if present
1795
+ base_filename = filename.split(".")[0]
1796
+
1797
+ parts = base_filename.split("_")
1798
+ if not parts:
1799
+ return False
1800
+
1801
+ entity_pattern = re.compile(r"^[a-zA-Z0-9]+-[a-zA-Z0-9]+$")
1802
+
1803
+ # Check that at least one entity-label pair is present
1804
+ has_entity_label = False
1805
+ for part in parts:
1806
+ if "-" in part:
1807
+ has_entity_label = True
1808
+ if not entity_pattern.match(part):
1809
+ return False
1810
+ if not has_entity_label:
1811
+ return False
1812
+
1813
+ return True
1814
+
1815
+
1816
+ ####################################################################################################
1817
+ def get_individual_files_and_folders(
1818
+ input_folder: str, cad4query: Union[str, list, dict]
1819
+ ):
1820
+ """
1821
+ This function detects all the files or folders inside a folder and its subfolders containing the strings supplied by the variable cad4query.
1822
+
1823
+ Parameters
1824
+ ----------
1825
+ input_folder : str
1826
+ Path to the input folder.
1827
+
1828
+ cad4query : str, list, or dict
1829
+ String or list of strings to filter the files and folders. If a dictionary is provided, it should contain key-value pairs where the key is the string before '-' and the value is the string after '-'.
1830
+
1831
+ Returns
1832
+ -------
1833
+ list
1834
+ List of files or folders that match the query.
1835
+
1836
+ Raises
1837
+ ------
1838
+ ValueError
1839
+ If the input folder does not exist.
1840
+
1841
+ TypeError
1842
+ If the input folder is not a string.
1843
+
1844
+ Examples
1845
+ --------
1846
+ >>> input_folder = "/path/to/input/folder"
1847
+ >>> cad4query = "sub-01"
1848
+ >>> files = get_individual_files_and_folders(input_folder, cad4query)
1849
+
1850
+
1851
+ """
1852
+
1853
+ # Checking if the folder is not a string or if the folder does not exist
1854
+ if not isinstance(input_folder, str):
1855
+ raise TypeError("The input folder must be a string.")
1856
+
1857
+ if not os.path.isdir(input_folder):
1858
+ raise ValueError(f"The input folder {input_folder} does not exist.")
1859
+
1860
+ # Create a dictionary from cad4query where the key is the string before - and the value is the string after -
1861
+ if isinstance(cad4query, str):
1862
+ cad4query = [cad4query]
1863
+
1864
+ if isinstance(cad4query, list):
1865
+ clean_id_dict = dict([i.split("-") for i in cad4query])
1866
+
1867
+ elif isinstance(cad4query, dict):
1868
+ clean_id_dict = cad4query.copy()
1869
+
1870
+ # Detecting the all the files for the reference subject
1871
+ ind_der_dir = glob(os.path.join(input_folder, "sub-" + clean_id_dict["sub"] + "*"))
1872
+
1873
+ # Filter if multiple directories found
1874
+ if len(ind_der_dir) > 1:
1875
+ ind_der_dir = cltmisc.filter_by_substring(
1876
+ ind_der_dir,
1877
+ or_filter=[clean_id_dict["sub"]],
1878
+ and_filter=cad4query,
1879
+ )
1880
+
1881
+ # Set count to 0 if no directory found
1882
+ if len(ind_der_dir) > 0:
1883
+ # Count files for this subject in this pipeline
1884
+ all_files = cltmisc.get_all_files(ind_der_dir[0])
1885
+ all_files = cltmisc.filter_by_substring(
1886
+ all_files, or_filter=clean_id_dict["sub"], and_filter=cad4query
1887
+ )
1888
+
1889
+ else:
1890
+ all_files = []
1891
+
1892
+ return all_files
1893
+
1894
+
1895
+ ####################################################################################################
1896
+ def generate_bids_tree(
1897
+ bids_root: str,
1898
+ max_depth: Optional[int] = None,
1899
+ show_hidden: bool = False,
1900
+ exclude_patterns: Optional[Set[str]] = None,
1901
+ save_to_file: Optional[str] = None,
1902
+ ) -> str:
1903
+ """
1904
+ Generate an MS-DOS tree-style visualization of a BIDS folder structure.
1905
+
1906
+ Parameters
1907
+ ----------
1908
+ bids_root : str
1909
+ Path to the BIDS root directory.
1910
+
1911
+ max_depth : int, optional
1912
+ Maximum depth to traverse. If None (default), traverses entire directory
1913
+ structure without depth limitation.
1914
+
1915
+ show_hidden : bool, optional
1916
+ Whether to show hidden files and folders (starting with '.').
1917
+ Default is False.
1918
+
1919
+ exclude_patterns : set of str, optional
1920
+ Set of file/folder name patterns to exclude from the tree. If None,
1921
+ defaults to {'.git', '__pycache__', '.DS_Store', 'Thumbs.db'}.
1922
+
1923
+ save_to_file : str, optional
1924
+ Path to save the tree output as a text file. If None, only returns
1925
+ the string without saving.
1926
+
1927
+ Returns
1928
+ -------
1929
+ str
1930
+ MS-DOS tree representation of the BIDS structure with proper tree
1931
+ symbols (├──, └──, │) and directory indicators (/).
1932
+
1933
+ Raises
1934
+ ------
1935
+ FileNotFoundError
1936
+ If the specified bids_root path does not exist.
1937
+
1938
+ NotADirectoryError
1939
+ If the specified bids_root path is not a directory.
1940
+
1941
+ PermissionError
1942
+ If there are insufficient permissions to read certain directories.
1943
+ Individual permission errors are handled gracefully and noted in output.
1944
+
1945
+ OSError
1946
+ If there are file system related errors during tree generation or
1947
+ file saving operations.
1948
+
1949
+ Notes
1950
+ -----
1951
+ - Directories are displayed with a trailing '/' to distinguish from files
1952
+ - Items are sorted with directories first, then files, both alphabetically
1953
+ - Hidden files/folders (starting with '.') are excluded by default
1954
+ - Permission errors for individual subdirectories are handled gracefully
1955
+ - The tree uses standard MS-DOS tree symbols for proper visualization
1956
+ - When max_depth is None, the entire directory structure is traversed
1957
+
1958
+ Examples
1959
+ --------
1960
+ Basic usage with unlimited depth:
1961
+
1962
+ >>> tree = generate_bids_tree('/path/to/bids/dataset')
1963
+ >>> print(tree)
1964
+ my-bids-dataset/
1965
+ ├── dataset_description.json
1966
+ ├── participants.tsv
1967
+ ├── sub-01/
1968
+ │ ├── anat/
1969
+ │ │ └── sub-01_T1w.nii.gz
1970
+ │ └── func/
1971
+ │ ├── sub-01_task-rest_bold.nii.gz
1972
+ │ └── sub-01_task-rest_events.tsv
1973
+ └── derivatives/
1974
+ └── preprocessing/
1975
+ └── sub-01/
1976
+
1977
+ Limited depth with file saving:
1978
+
1979
+ >>> tree = generate_bids_tree('/path/to/bids/dataset',
1980
+ ... max_depth=2,
1981
+ ... save_to_file='bids_tree.txt')
1982
+ >>> print("Tree saved to bids_tree.txt")
1983
+
1984
+ Include hidden files and custom exclusions:
1985
+
1986
+ >>> tree = generate_bids_tree('/path/to/bids/dataset',
1987
+ ... show_hidden=True,
1988
+ ... exclude_patterns={'temp', 'backup'})
1989
+ """
1990
+
1991
+ if exclude_patterns is None:
1992
+ exclude_patterns = {".git", "__pycache__", ".DS_Store", "Thumbs.db"}
1993
+
1994
+ bids_path = Path(bids_root)
1995
+
1996
+ if not bids_path.exists():
1997
+ raise FileNotFoundError(f"The specified path does not exist: {bids_root}")
1998
+
1999
+ if not bids_path.is_dir():
2000
+ raise NotADirectoryError(f"The specified path is not a directory: {bids_root}")
2001
+
2002
+ tree_lines = [f"{bids_path.name}/"]
2003
+
2004
+ def _build_tree(current_path: Path, prefix: str = "", depth: int = 0) -> None:
2005
+ """Recursively build the tree structure."""
2006
+
2007
+ if max_depth is not None and depth >= max_depth:
2008
+ return
2009
+
2010
+ try:
2011
+ # Get all items in current directory
2012
+ items = []
2013
+ for item in current_path.iterdir():
2014
+ # Skip hidden files if not requested
2015
+ if not show_hidden and item.name.startswith("."):
2016
+ continue
2017
+ # Skip excluded patterns
2018
+ if item.name in exclude_patterns:
2019
+ continue
2020
+ items.append(item)
2021
+
2022
+ # Sort items: directories first, then files, both alphabetically
2023
+ items.sort(key=lambda x: (x.is_file(), x.name.lower()))
2024
+
2025
+ for i, item in enumerate(items):
2026
+ is_last = i == len(items) - 1
2027
+
2028
+ # Choose the appropriate tree symbols
2029
+ if is_last:
2030
+ current_prefix = "└── "
2031
+ next_prefix = prefix + " "
2032
+ else:
2033
+ current_prefix = "├── "
2034
+ next_prefix = prefix + "│ "
2035
+
2036
+ # Add item to tree
2037
+ if item.is_dir():
2038
+ tree_lines.append(f"{prefix}{current_prefix}{item.name}/")
2039
+ # Recursively process subdirectory
2040
+ _build_tree(item, next_prefix, depth + 1)
2041
+ else:
2042
+ tree_lines.append(f"{prefix}{current_prefix}{item.name}")
2043
+
2044
+ except PermissionError:
2045
+ tree_lines.append(f"{prefix}├── [Permission Denied]")
2046
+ except Exception as e:
2047
+ tree_lines.append(f"{prefix}├── [Error: {str(e)}]")
2048
+
2049
+ # Build the tree starting from the root
2050
+ _build_tree(bids_path)
2051
+
2052
+ # Join all tree lines
2053
+ tree_output = "\n".join(tree_lines)
2054
+
2055
+ # Save to file if requested
2056
+ if save_to_file:
2057
+ try:
2058
+ with open(save_to_file, "w", encoding="utf-8") as f:
2059
+ f.write(tree_output)
2060
+ except OSError as e:
2061
+ raise OSError(f"Failed to save tree to file '{save_to_file}': {str(e)}")
2062
+
2063
+ return tree_output
2064
+
2065
+
2066
+ ####################################################################################################
2067
+ def generate_bids_tree_with_stats(bids_root: str, **kwargs) -> str:
2068
+ """
2069
+ Generate a BIDS tree with additional statistics.
2070
+
2071
+ Parameters
2072
+ ----------
2073
+ bids_root : str
2074
+ Path to the BIDS root directory.
2075
+
2076
+ **kwargs
2077
+ Additional keyword arguments passed to generate_bids_tree().
2078
+ See generate_bids_tree() documentation for available parameters.
2079
+
2080
+ Returns
2081
+ -------
2082
+ str
2083
+ Tree representation with file and folder count statistics appended.
2084
+
2085
+ Raises
2086
+ ------
2087
+ FileNotFoundError
2088
+ If the specified bids_root path does not exist.
2089
+
2090
+ NotADirectoryError
2091
+ If the specified bids_root path is not a directory.
2092
+
2093
+ PermissionError
2094
+ If there are insufficient permissions to read directories.
2095
+
2096
+ OSError
2097
+ If there are file system related errors.
2098
+
2099
+ Notes
2100
+ -----
2101
+ Statistics are calculated by recursively counting all files and directories
2102
+ in the BIDS structure, regardless of the max_depth parameter used for
2103
+ tree visualization.
2104
+
2105
+ Examples
2106
+ --------
2107
+ >>> tree_with_stats = generate_bids_tree_with_stats('/path/to/bids/dataset')
2108
+ >>> print(tree_with_stats)
2109
+ my-bids-dataset/
2110
+ ├── dataset_description.json
2111
+ └── sub-01/
2112
+ └── anat/
2113
+ └── sub-01_T1w.nii.gz
2114
+
2115
+ Statistics:
2116
+ ├── Directories: 2
2117
+ └── Files: 2
2118
+ """
2119
+
2120
+ tree = generate_bids_tree(bids_root, **kwargs)
2121
+
2122
+ # Count files and directories
2123
+ bids_path = Path(bids_root)
2124
+ file_count = 0
2125
+ dir_count = 0
2126
+
2127
+ try:
2128
+ for item in bids_path.rglob("*"):
2129
+ if item.is_file():
2130
+ file_count += 1
2131
+ elif item.is_dir():
2132
+ dir_count += 1
2133
+ except Exception:
2134
+ pass
2135
+
2136
+ stats = f"\n\nStatistics:\n├── Directories: {dir_count}\n└── Files: {file_count}"
2137
+
2138
+ return tree + stats
2139
+
2140
+
2141
+ ####################################################################################################
2142
+ def validate_bids_structure(bids_root: str) -> List[str]:
2143
+ """
2144
+ Basic validation of BIDS structure and return warnings.
2145
+
2146
+ Parameters
2147
+ ----------
2148
+ bids_root : str
2149
+ Path to the BIDS root directory.
2150
+
2151
+ Returns
2152
+ -------
2153
+ list of str
2154
+ List of validation warnings and notes about the BIDS structure.
2155
+ Empty list indicates no issues found.
2156
+
2157
+ Raises
2158
+ ------
2159
+ FileNotFoundError
2160
+ If the specified bids_root path does not exist.
2161
+ NotADirectoryError
2162
+ If the specified bids_root path is not a directory.
2163
+
2164
+ Notes
2165
+ -----
2166
+ This function performs basic BIDS validation including:
2167
+ - Checking for required files (dataset_description.json)
2168
+ - Verifying presence of subject directories (sub-*)
2169
+ - Noting presence of derivatives directory
2170
+
2171
+ For comprehensive BIDS validation, consider using the official
2172
+ BIDS validator tool.
2173
+
2174
+ Examples
2175
+ --------
2176
+ >>> warnings = validate_bids_structure('/path/to/bids/dataset')
2177
+ >>> if warnings:
2178
+ ... for warning in warnings:
2179
+ ... print(f"⚠️ {warning}")
2180
+ >>> else:
2181
+ ... print("✅ Basic BIDS structure looks good!")
2182
+ """
2183
+
2184
+ warnings = []
2185
+ bids_path = Path(bids_root)
2186
+
2187
+ if not bids_path.exists():
2188
+ raise FileNotFoundError(f"The specified path does not exist: {bids_root}")
2189
+
2190
+ if not bids_path.is_dir():
2191
+ raise NotADirectoryError(f"The specified path is not a directory: {bids_root}")
2192
+
2193
+ # Check for required files
2194
+ required_files = ["dataset_description.json"]
2195
+ for req_file in required_files:
2196
+ if not (bids_path / req_file).exists():
2197
+ warnings.append(f"Missing required file: {req_file}")
2198
+
2199
+ # Check for common BIDS directories
2200
+ common_dirs = ["sub-*"] # Using glob pattern
2201
+ subject_dirs = list(bids_path.glob("sub-*"))
2202
+ if not subject_dirs:
2203
+ warnings.append("No subject directories found (sub-*)")
2204
+
2205
+ # Check for derivatives directory
2206
+ derivatives_dir = bids_path / "derivatives"
2207
+ if derivatives_dir.exists():
2208
+ warnings.append("Derivatives directory found")
2209
+
2210
+ return warnings
2211
+
2212
+
2213
+ ####################################################################################################
2214
+ ####################################################################################################
2215
+ ############ ############
2216
+ ############ ############
2217
+ ############ Section 3: Useful methods for BIDs file organization ############
2218
+ ############ ############
2219
+ ############ ############
2220
+ ####################################################################################################
2221
+ ####################################################################################################
2222
+
2223
+
2224
+ # Loading the JSON file containing the BIDs configuration file. It includes the entities and suffixes
2225
+ # of the BIDs dataset.
2226
+ ####################################################################################################
2227
+ def load_bids_json(bids_json: str = None):
2228
+ """
2229
+ Load the JSON file containing the BIDs configuration file.
2230
+
2231
+ Parameters
2232
+ ----------
2233
+ bids_json : str
2234
+ JSON file containing the BIDs configuration.
2235
+
2236
+ Returns
2237
+ --------
2238
+ config_dict : dict
2239
+ Dictionary containing the default .
2240
+
2241
+ """
2242
+
2243
+ # Get the absolute of this file
2244
+ if bids_json is None:
2245
+ cwd = os.path.dirname(os.path.abspath(__file__))
2246
+ bids_json = os.path.join(cwd, "config", "bids.json")
2247
+ else:
2248
+ if not os.path.isfile(bids_json):
2249
+ raise ValueError(
2250
+ "Please, provide a valid JSON file containing the BIDs configuration dictionary."
2251
+ )
2252
+ try:
2253
+ with open(bids_json) as f:
2254
+ config_dict = json.load(f)
2255
+ except FileNotFoundError:
2256
+ raise FileNotFoundError(f"Default configuration file not found at: {bids_json}")
2257
+ except json.JSONDecodeError:
2258
+ raise ValueError(f"Error parsing the default configuration file: {bids_json}")
2259
+
2260
+ return config_dict