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,941 @@
1
+ import os
2
+ from glob import glob
3
+ import tarfile
4
+ import shutil
5
+ import pandas as pd
6
+ import sys
7
+ import pydicom
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from shutil import copyfile
11
+ from concurrent.futures import ThreadPoolExecutor
12
+
13
+ import numpy as np
14
+ from rich.progress import Progress
15
+ from threading import Lock
16
+ import time
17
+
18
+ from typing import List, Optional, Union
19
+
20
+
21
+ # Importing local modules
22
+ from . import misctools as cltmisc
23
+
24
+
25
+ # simple progress indicator callback function
26
+ def progress_indicator(future):
27
+ """
28
+ A simple progress indicator for the concurrent futures
29
+ :param future: future object
30
+
31
+ """
32
+ global lock, n_dics, n_comp, pb, pb1, subj_id, dicom_files
33
+ # obtain the lock
34
+ with lock:
35
+ # update the counter
36
+ n_comp += 1
37
+ # report progress
38
+ # print(f'{tasks_completed}/{n_subj} completed, {n_subj-tasks_completed} remain.')
39
+ # pb.update(task_id=pb1, description= f'[red]Completed {n_comp}/{n_subj}', completed=n_subj)
40
+ pb.update(
41
+ task_id=pb1,
42
+ description=f"[red]{subj_id}: Finished ({n_comp}/{n_dics})",
43
+ completed=n_comp,
44
+ )
45
+
46
+
47
+ ####################################################################################################
48
+ ####################################################################################################
49
+ ############ ############
50
+ ############ ############
51
+ ############ Section 1: Methods dedicated to organice and copy DICOM files ############
52
+ ############ ############
53
+ ############ ############
54
+ ####################################################################################################
55
+ ####################################################################################################
56
+ def org_conv_dicoms(
57
+ in_dic_dir: str,
58
+ out_dic_dir: str,
59
+ demog_file: str = None,
60
+ ids_file: str = None,
61
+ ses_id: str = None,
62
+ nosub: bool = False,
63
+ booldic: bool = True,
64
+ boolcomp: bool = False,
65
+ force: bool = False,
66
+ nthreads: int = 0,
67
+ ):
68
+ """
69
+ This method organizes the DICOM files in sessions and series. It could also use the demographics file to define the session ID.
70
+
71
+ Parameters
72
+ ----------
73
+ in_dic_dir : str
74
+ Directory containing the subjects. It assumes all individual folders inside the directory as individual subjects.
75
+ The subjects directory should start with 'sub-' otherwise the subjects will not be considered unless the "nosub"
76
+ variable is set to True.
77
+
78
+ out_dic_dir : str
79
+ Output directory where the organized DICOM files will be saved. A new folder called 'Dicom' will be created inside this directory.
80
+
81
+ demog_file : str, optional
82
+ Demographics file containing the information about the subjects. The file should contain the following mandatory columns:
83
+ 'participant_id', 'session_id', 'acq_date'. Other columns such as 'birth_date', 'sex', 'group_id' or 'scanner_id' could be added.
84
+
85
+ ids_file : str, optional
86
+ Text file containing the list of subject IDs to be considered. The file should contain the subject IDs in a single column.
87
+
88
+ ses_id : str, optional
89
+ Session ID to be added to the session name. If not provided, the session ID will be the date of the study or the session ID
90
+ extracted from the demographics table.
91
+
92
+ nosub : bool, optional, default=False
93
+ Boolean variable to consider the subjects that do not start with 'sub-'.
94
+
95
+ booldic : bool, optional, default=True
96
+ Boolean variable to organize the DICOM files. If False it will leave the folders as they are.
97
+
98
+ boolcomp : bool, optional, default=False
99
+ Boolean variable to compress the sessions containing the organized DICOM files. If True it will compress the sessions.
100
+
101
+ force : bool, optional, default=False
102
+ Boolean variable to force the copy of the DICOM file if the file already exists.
103
+
104
+ nthreds : int, optional, default=0
105
+ Number of threads to be used in the process. Default is 0 that means automatic selection of the number of cores.
106
+
107
+ Returns
108
+ -------
109
+ None
110
+ This method performs file organization operations and does not return a value.
111
+
112
+ Raises
113
+ ------
114
+ FileNotFoundError
115
+ If the input directory does not exist.
116
+
117
+ ValueError
118
+ If the demographics file is provided but does not contain the mandatory columns.
119
+
120
+ PermissionError
121
+ If there are insufficient permissions to write to the output directory.
122
+
123
+ Examples
124
+ --------
125
+ >>> # Basic usage with input and output directories
126
+ >>> organize_dicom_files('/path/to/input/dicoms', '/path/to/output')
127
+
128
+ >>> # Using demographics file and custom session ID
129
+ >>> organize_dicom_files(
130
+ ... in_dic_dir='/path/to/input/dicoms',
131
+ ... out_dic_dir='/path/to/output',
132
+ ... demog_file='/path/to/demographics.csv',
133
+ ... ses_id='session01'
134
+ ... )
135
+
136
+ >>> # Process only specific subjects with compression
137
+ >>> organize_dicom_files(
138
+ ... in_dic_dir='/path/to/input/dicoms',
139
+ ... out_dic_dir='/path/to/output',
140
+ ... ids_file='/path/to/subject_ids.txt',
141
+ ... boolcomp=True,
142
+ ... nthreds=4
143
+ ... )
144
+ """
145
+
146
+ # Declaring global variables
147
+ global pb, pb1, n_dics, n_comp, lock, subj_id, dicom_files
148
+
149
+ # Detecting the number of cores to be used
150
+ ncores = os.cpu_count()
151
+ if nthreads == 0:
152
+ nthreads = ncores
153
+ if nthreads > 4:
154
+ nthreads = nthreads - 4
155
+ else:
156
+ nthreads = 1
157
+
158
+ # Listing the subject ids inside the dicom folder
159
+ my_list = os.listdir(in_dic_dir)
160
+ subj_ids = []
161
+ for it in my_list:
162
+ if nosub == False:
163
+ if "sub-" in it:
164
+ subj_ids.append(it)
165
+ else:
166
+ subj_ids.append(it)
167
+
168
+ subj_ids.sort()
169
+
170
+ # If subj_ids is empty do not continue
171
+ if not subj_ids:
172
+ print("No subjects found in the input directory")
173
+ sys.exit()
174
+
175
+ if ids_file != None:
176
+ if os.path.isfile(ids_file):
177
+ subj_ids = cltmisc.select_ids_from_file(subj_ids, ids_file)
178
+
179
+ else:
180
+ s_ids = ids_file.split(",")
181
+
182
+ if nosub == False:
183
+ temp_ids = [s.strip("sub-") for s in subj_ids]
184
+ s_ids = cltmisc.list_intercept(s_ids, temp_ids)
185
+
186
+ if not s_ids:
187
+ s_ids = subj_ids
188
+ else:
189
+ s_ids = ["sub-" + s for s in s_ids]
190
+ subj_ids = s_ids
191
+
192
+ # Reading demographics
193
+ demobool = False # Boolean variable to use the demographics table for the session id definition
194
+ if demog_file != None:
195
+ if os.path.isfile(demog_file):
196
+ demobool = True # Boolean variable to use the demographics table for the session id definition
197
+ demoDB = pd.read_csv(demog_file)
198
+
199
+ all_ser_dirs = []
200
+ cont_subj = 0
201
+ n_subj = len(subj_ids)
202
+ failed_ids = []
203
+ # Creating the progress bars
204
+ with Progress() as pb:
205
+ pb2 = pb.add_task("[green]Subjects...", total=n_subj)
206
+
207
+ for cont_subj, subj_id in enumerate(subj_ids): # Loop along the IDs
208
+
209
+ # create a lock for the counter
210
+ lock = Lock()
211
+
212
+ n_comp = 0
213
+ failed = []
214
+
215
+ pb.update(
216
+ task_id=pb2,
217
+ description=f"[green]Subject: {subj_id} ({cont_subj+1}/{n_subj})",
218
+ completed=cont_subj + 1,
219
+ )
220
+
221
+ subj_dir = os.path.join(in_dic_dir, subj_id)
222
+ if os.path.isdir(subj_dir):
223
+ # Default value for these variables for each subject
224
+ gendVar = "Unknown"
225
+ groupVar = "Unknown"
226
+ AgeatScan = "Unknown"
227
+ subTB = None
228
+ date_times = []
229
+
230
+ if demobool:
231
+ # Sub-table containing only the selected ID
232
+ subTB = demoDB[
233
+ demoDB["participant_id"].str.contains(subj_id.split("-")[-1])
234
+ ]
235
+
236
+ # Date times of all the series acquired for the current subject
237
+ nrows = np.shape(subTB)[0]
238
+ for nr in np.arange(0, nrows):
239
+ temp = subTB.iloc[nr]["acq_date"]
240
+ tempVar = temp.split("/")
241
+ date_time = datetime(
242
+ day=int(tempVar[1]),
243
+ month=int(tempVar[0]),
244
+ year=int(tempVar[2]),
245
+ )
246
+ date_times.append(date_time)
247
+ try:
248
+ if booldic:
249
+ dicom_files = cltmisc.get_all_files(subj_dir)
250
+ ses_idprev = []
251
+ ser_idprev = []
252
+
253
+ n_dics = len(dicom_files)
254
+ if nthreads == 1:
255
+
256
+ pb1 = pb.add_task(
257
+ f"[red]Copying DICOMs: Subject {subj_id} ({cont_subj + 1}/{n_subj}) ",
258
+ total=n_dics,
259
+ )
260
+ for cont_dic, dfiles in enumerate(dicom_files):
261
+ ser_dir = copy_dicom_file(
262
+ dfiles,
263
+ subj_id,
264
+ out_dic_dir,
265
+ ses_id,
266
+ date_times,
267
+ demobool,
268
+ subTB,
269
+ force,
270
+ )
271
+ all_ser_dirs.append(ser_dir)
272
+ pb.update(
273
+ task_id=pb1,
274
+ description=f"[red]Copying DICOMs: Subject {subj_id} ({cont_dic+1}/{n_dics})",
275
+ completed=cont_dic + 1,
276
+ )
277
+
278
+ else:
279
+
280
+ # create a progress bar for the subjects
281
+ pb1 = pb.add_task(
282
+ f"[red]Copying DICOMs: Subject {subj_id} ({cont_subj + 1}/{n_subj}) ",
283
+ total=n_dics,
284
+ )
285
+
286
+ # Adjusting the number of threads to the number of subjects
287
+ if n_dics < nthreads:
288
+ nthreads = n_dics
289
+
290
+ # start the thread pool
291
+ with ThreadPoolExecutor(nthreads) as executor:
292
+ # send in the tasks
293
+ # futures = [executor.submit(build_parcellation, t1s[i],
294
+ # bids_dir, deriv_dir, parccode, growwm) for i in range(n_subj)]
295
+
296
+ futures = [
297
+ executor.submit(
298
+ copy_dicom_file,
299
+ dicom_files[i],
300
+ subj_id,
301
+ out_dic_dir,
302
+ ses_id,
303
+ date_times,
304
+ demobool,
305
+ subTB,
306
+ force,
307
+ )
308
+ for i in range(n_dics)
309
+ ]
310
+ # futures = [executor.submit(test, i) for i in range(n_dics)]
311
+
312
+ # register the progress indicator callback
313
+ for future in futures:
314
+ future.add_done_callback(progress_indicator)
315
+ # wait for all tasks to complete
316
+
317
+ else:
318
+
319
+ for ses_id in os.listdir(subj_dir): # Loop along the session
320
+ ses_dir = os.path.join(subj_dir, ses_id)
321
+ if not ses_id[-2].isalpha():
322
+ if (
323
+ demobool
324
+ ): # Adding the Visit ID to the last part o the session ID only in the DICOM Folder
325
+ tempVar = ses_id.split("-")[-1]
326
+ sdate_time = datetime.strptime(
327
+ tempVar, "%Y%m%d%H%M%S"
328
+ )
329
+ timediff = np.array(date_times) - np.array(
330
+ sdate_time
331
+ )
332
+ clostd = np.argmin(abs(timediff))
333
+ visitVar = subTB.iloc[clostd]["session_id"]
334
+ newses_id = ses_id + visitVar
335
+ newses_dir = os.path.join(subj_dir, newses_id)
336
+ os.rename(ses_dir, newses_dir)
337
+ ses_dir = newses_dir
338
+
339
+ if os.path.isdir(ses_dir):
340
+ for ser_id in os.listdir(
341
+ ses_dir
342
+ ): # Loop along the series
343
+ serDir = os.path.join(ses_dir, ser_id)
344
+
345
+ if os.path.isdir(serDir):
346
+ all_ser_dirs.append(serDir)
347
+ except:
348
+ failed_ids.append(subj_id)
349
+ print("Error at subject: " + subj_id)
350
+ else:
351
+ print("Subject: " + subj_id + " does not exist.")
352
+
353
+ # pb.update(task_id=t2, completed=cont_subj+1)
354
+ # pb.update(task_id=t2, completed=n_subj)
355
+
356
+ all_ser_dirs = list(set(all_ser_dirs))
357
+ all_ser_dirs.sort()
358
+
359
+ if boolcomp:
360
+ compress_dicom_session(out_dic_dir)
361
+
362
+
363
+ ####################################################################################################
364
+ def copy_dicom_file(
365
+ dic_file: str,
366
+ subj_id: str,
367
+ out_dic_dir: str,
368
+ ses_id: str = None,
369
+ date_times: list = None,
370
+ demogbool: bool = False,
371
+ demog_tab: pd.DataFrame = None,
372
+ force: bool = False,
373
+ ):
374
+ """
375
+ Function to copy the DICOM files to the output directory.
376
+
377
+ Parameters
378
+ -----------
379
+ dic_file: str
380
+ Path to the DICOM file.
381
+
382
+ subj_id: str
383
+ Subject ID.
384
+
385
+ out_dic_dir: str
386
+ Output directory where the DICOM files will be saved.
387
+
388
+ ses_id: str
389
+ Session ID to be added to the session name. If not provided, the session ID will be the date of the study or the session ID
390
+ extracted from the demographics table.
391
+
392
+ date_times: list
393
+ List containing the date and time of all the studies for that subject ID.
394
+
395
+ demogbool: bool
396
+ Boolean variable to use the demographics table for the session id definition.
397
+
398
+ demog_tab: pd.DataFrame
399
+ Demographics table containing the information about the subjects.
400
+
401
+ force: bool
402
+ Boolean variable to force the copy of the DICOM file.
403
+
404
+ Returns
405
+ --------
406
+ dest_dic_dir: str
407
+ Destination directory where the DICOM file was copied.
408
+
409
+
410
+ """
411
+
412
+ try:
413
+ dataset = pydicom.dcmread(dic_file)
414
+ dic_path = os.path.dirname(dic_file)
415
+ dic_name = os.path.basename(dic_file)
416
+
417
+ # Extracting the study date from DICOM file
418
+ attributes = dataset.dir("")
419
+
420
+ if attributes:
421
+ sdate = dataset.data_element("StudyDate").value
422
+ stime = dataset.data_element("StudyTime").value
423
+ year = int(sdate[:4])
424
+ month = int(sdate[4:6])
425
+ day = int(sdate[6:8])
426
+
427
+ # Date format
428
+ sdate_time = datetime(day=day, month=month, year=year)
429
+
430
+ # Creating default current Session ID
431
+ ses_id, ser_id = create_session_series_names(dataset)
432
+
433
+ if not ses_id == None:
434
+ ses_id = "ses-" + ses_id
435
+
436
+ if "000000" in ses_id and ser_id in ser_idprev:
437
+ ses_id = ses_idprev
438
+
439
+ # visitId = dfiles.split('/')[8].split('-')[1]
440
+ # ses_id = 'ses-'+ visitId
441
+
442
+ ses_idprev = ses_id
443
+ ser_idprev = ser_id
444
+
445
+ # Changing the session Id in case we have access to the demographics file
446
+ if demogbool:
447
+ timediff = np.array(date_times) - np.array(sdate_time)
448
+ clostd = np.argmin(abs(timediff))
449
+ visitVar = demog_tab.iloc[clostd]["session_id"]
450
+ ses_id = ses_id + visitVar
451
+
452
+ dest_dic_dir = os.path.join(out_dic_dir, subj_id, ses_id, ser_id)
453
+
454
+ # Create the destination path
455
+ if not os.path.isdir(dest_dic_dir):
456
+ path = Path(dest_dic_dir)
457
+ path.mkdir(parents=True, exist_ok=True)
458
+ # print(newPath)
459
+ dest_dic = os.path.join(dest_dic_dir, dic_name)
460
+ if force:
461
+ if os.path.isfile(dest_dic):
462
+ os.remove(dest_dic)
463
+ else:
464
+ copyfile(dic_file, dest_dic)
465
+ else:
466
+ if not os.path.isfile(dest_dic):
467
+ copyfile(dic_file, dest_dic)
468
+
469
+ except pydicom.errors.InvalidDicomError:
470
+ print("Error at file at path : " + dic_file)
471
+ pass
472
+
473
+ return dest_dic_dir
474
+
475
+
476
+ ####################################################################################################
477
+ def create_session_series_names(dataset):
478
+ """
479
+ Function to create names from a DICOM object.
480
+
481
+ Parameters
482
+ ----------
483
+ dataset: pydicom.dataset.FileDataset
484
+ DICOM dataset object.
485
+
486
+ Returns
487
+ -------
488
+ ses_id: str
489
+ Session ID.
490
+
491
+ ser_id: str
492
+ Series ID.
493
+
494
+ """
495
+ # % This function creates the session and the series name for a dicom object
496
+
497
+ # Extracting the study date from DICOM file
498
+ attributes = dataset.dir("")
499
+ sdate = dataset.data_element("StudyDate").value
500
+ stime = dataset.data_element("StudyTime").value
501
+
502
+ ########### ========== Creating current Session ID
503
+ if sdate and stime:
504
+ ses_id = str(sdate) + str(int(np.floor(float(stime))))
505
+ elif sdate and not stime:
506
+ ses_id = str(sdate) + "000000"
507
+ elif stime and not sdate:
508
+ ses_id = "00000000" + str(stime)
509
+
510
+ ########### ========== Creating current Series ID
511
+ if any("SeriesDescription" in s for s in attributes):
512
+ ser_id = dataset.data_element("SeriesDescription").value
513
+ elif any("SeriesDescription" in s for s in attributes) == False and any(
514
+ "SequenceName" in s for s in attributes
515
+ ):
516
+ ser_id = dataset.data_element("SequenceName").value
517
+ elif (
518
+ any("SeriesDescription" in s for s in attributes) == False
519
+ and any("SequenceName" in s for s in attributes) == False
520
+ and any("ProtocolName" in s for s in attributes)
521
+ ):
522
+ ser_id = dataset.data_element("ProtocolName").value
523
+ elif (
524
+ any("SeriesDescription" in s for s in attributes) == False
525
+ and any("SequenceName" in s for s in attributes) == False
526
+ and any("ProtocolName" in s for s in attributes) == False
527
+ and any("ScanningSequence" in s for s in attributes)
528
+ and any("SequenceVariant" in s for s in attributes)
529
+ ):
530
+ ser_id = (
531
+ dataset.data_element("ScanningSequence").value
532
+ + "_"
533
+ + dataset.data_element("SequenceVariant").value
534
+ )
535
+ else:
536
+ ser_id = "NoSerName"
537
+
538
+ # Removing and substituting unwanted characters
539
+ ser_id = ser_id.replace(" ", "_")
540
+ ser_id = ser_id.replace("/", "_")
541
+
542
+ # This function removes some characters from a string
543
+ ser2rem = [
544
+ "*",
545
+ "+",
546
+ "(",
547
+ ")",
548
+ "=",
549
+ ",",
550
+ ">",
551
+ "<",
552
+ ";",
553
+ ":",
554
+ '"',
555
+ "'",
556
+ "?",
557
+ "!",
558
+ "@",
559
+ "#",
560
+ "$",
561
+ "%",
562
+ "^",
563
+ "&",
564
+ "*",
565
+ ]
566
+ for cad in ser2rem:
567
+ ser_id = ser_id.replace(cad, "")
568
+
569
+ # Removing the dupplicated _ characters and replacing the remaining by -
570
+ ser_id = cltmisc.rem_duplicate_char(ser_id, "_")
571
+ ser_id = ser_id.replace("_", "-")
572
+
573
+ if any("SeriesNumber" in s for s in attributes):
574
+ serNumb = dataset.data_element("SeriesNumber").value
575
+
576
+ # Adding the series number
577
+ sNumb = f"{int(serNumb):04d}"
578
+ ser_id = sNumb + "-" + ser_id
579
+
580
+ return ses_id, ser_id
581
+
582
+
583
+ ####################################################################################################
584
+ def uncompress_dicom_session(
585
+ dic_dir: str,
586
+ boolrmtar: bool = False,
587
+ subj_ids: Optional[Union[str, List[str]]] = None,
588
+ ) -> List[str]:
589
+ """
590
+ Uncompress session folders containing the DICOM files for all the series.
591
+
592
+ Parameters
593
+ ----------
594
+ dic_dir : str
595
+ Directory containing the subjects. It assumes an organization in:
596
+ <subj_id>/<session_id>/<series_id>
597
+
598
+ boolrmtar : bool, optional, default=False
599
+ Boolean variable to remove the tar files after uncompressing the session.
600
+
601
+ subj_ids : str, list of str, or None, optional
602
+ Subject IDs to be considered. Can be:
603
+ - None: consider all subjects in the directory (default)
604
+ - str: path to text file containing subject IDs (one per line)
605
+ - list of str: explicit list of subject IDs
606
+
607
+ Returns
608
+ -------
609
+ list of str
610
+ List of tar files that failed to be uncompressed. Empty list if all successful.
611
+
612
+ Raises
613
+ ------
614
+ FileNotFoundError
615
+ If the specified directory does not exist.
616
+
617
+ ValueError
618
+ If subj_ids is not None, str, or list, or if subject IDs file cannot be read.
619
+
620
+ tarfile.TarError
621
+ If there are issues with reading or extracting tar files.
622
+
623
+ PermissionError
624
+ If there are insufficient permissions to extract files or remove tar archives.
625
+
626
+ OSError
627
+ If there are filesystem-related errors during extraction.
628
+
629
+ Examples
630
+ --------
631
+ >>> # Basic usage - uncompress all sessions in directory
632
+ >>> failed = uncompress_dicom_session('/path/to/dicom/directory')
633
+ >>> if not failed:
634
+ ... print("All sessions uncompressed successfully")
635
+
636
+ >>> # Uncompress sessions and remove tar files after extraction
637
+ >>> failed = uncompress_dicom_session('/path/to/dicom/directory', boolrmtar=True)
638
+
639
+ >>> # Uncompress sessions for specific subjects only
640
+ >>> failed = uncompress_dicom_session(
641
+ ... dic_dir='/path/to/dicom/directory',
642
+ ... subj_ids=['sub-001', 'sub-002', 'sub-003']
643
+ ... )
644
+
645
+ >>> # Use subject IDs from file
646
+ >>> failed = uncompress_dicom_session(
647
+ ... dic_dir='/path/to/dicom/directory',
648
+ ... subj_ids='/path/to/subject_ids.txt',
649
+ ... boolrmtar=True
650
+ ... )
651
+ """
652
+
653
+ # Validate input directory
654
+ dic_path = Path(dic_dir)
655
+ if not dic_path.exists():
656
+ raise FileNotFoundError(f"Directory {dic_dir} does not exist")
657
+ if not dic_path.is_dir():
658
+ raise ValueError(f"{dic_dir} is not a directory")
659
+
660
+ # Process subject IDs
661
+ if subj_ids is None:
662
+ # Get all subjects with 'sub-' prefix
663
+ subj_ids = [
664
+ item.name
665
+ for item in dic_path.iterdir()
666
+ if item.is_dir() and item.name.startswith("sub-")
667
+ ]
668
+ subj_ids.sort()
669
+ elif isinstance(subj_ids, str):
670
+ # Read subject IDs from file
671
+ try:
672
+ with open(subj_ids, "r", encoding="utf-8") as file:
673
+ subj_ids = [line.strip() for line in file if line.strip()]
674
+ except FileNotFoundError:
675
+ raise FileNotFoundError(f"Subject IDs file {subj_ids} not found")
676
+ except Exception as e:
677
+ raise ValueError(f"Error reading subject IDs file: {e}")
678
+ elif isinstance(subj_ids, list):
679
+ # Validate list elements
680
+ if not all(isinstance(subj_id, str) for subj_id in subj_ids):
681
+ raise ValueError("All subject IDs must be strings")
682
+ else:
683
+ raise ValueError("subj_ids must be None, str (file path), or list of str")
684
+
685
+ if not subj_ids:
686
+ print("No subjects found to process")
687
+ return []
688
+
689
+ n_subj = len(subj_ids)
690
+ failed_sessions = []
691
+
692
+ with Progress() as pb:
693
+ task = pb.add_task("[green]Uncompressing sessions...", total=n_subj)
694
+
695
+ for i, subj_id in enumerate(subj_ids):
696
+ subj_dir = dic_path / subj_id
697
+
698
+ pb.update(
699
+ task_id=task,
700
+ description=f"[green]Processing {subj_id} ({i+1}/{n_subj})",
701
+ completed=i,
702
+ )
703
+
704
+ # Skip if subject directory doesn't exist
705
+ if not subj_dir.exists():
706
+ print(f"Warning: Subject directory {subj_dir} not found, skipping...")
707
+ continue
708
+
709
+ # Find all tar.gz files in subject directory
710
+ tar_files = list(subj_dir.glob("*.tar.gz"))
711
+
712
+ for tar_file in tar_files:
713
+ try:
714
+ # Use Python's tarfile module for better error handling
715
+ with tarfile.open(tar_file, "r:gz") as tar:
716
+ # Extract to subject directory
717
+ tar.extractall(path=subj_dir)
718
+
719
+ # Remove tar file if requested
720
+ if boolrmtar:
721
+ tar_file.unlink()
722
+
723
+ except tarfile.TarError as e:
724
+ print(f"Error extracting {tar_file}: {e}")
725
+ failed_sessions.append(str(tar_file))
726
+ except PermissionError as e:
727
+ print(f"Permission error with {tar_file}: {e}")
728
+ failed_sessions.append(str(tar_file))
729
+ except Exception as e:
730
+ print(f"Unexpected error with {tar_file}: {e}")
731
+ failed_sessions.append(str(tar_file))
732
+
733
+ pb.update(
734
+ task_id=task,
735
+ description=f"[green]Completed uncompression",
736
+ completed=n_subj,
737
+ )
738
+
739
+ # Report results
740
+ if failed_sessions:
741
+ print("\nTHE PROCESS FAILED TO UNCOMPRESS THE FOLLOWING TAR FILES:")
742
+ for failed_file in failed_sessions:
743
+ print(f" - {failed_file}")
744
+ else:
745
+ print("\nAll sessions uncompressed successfully!")
746
+
747
+ print(f"\nProcessed {n_subj} subjects with {len(failed_sessions)} failures.")
748
+ return failed_sessions
749
+
750
+
751
+ ####################################################################################################
752
+ def compress_dicom_session(
753
+ dic_dir: str,
754
+ subj_ids: Optional[Union[str, List[str]]] = None,
755
+ remove_original: bool = True,
756
+ ) -> List[str]:
757
+ """
758
+ Compress session folders containing DICOM files into tar.gz archives.
759
+
760
+ Parameters
761
+ ----------
762
+ dic_dir : str
763
+ Directory containing the subjects. It assumes an organization in:
764
+ <subj_id>/<session_id>/<series_id>
765
+
766
+ subj_ids : str, list of str, or None, optional
767
+ Subject IDs to be considered. Can be:
768
+ - None: consider all subjects in the directory (default)
769
+ - str: path to text file containing subject IDs (one per line)
770
+ - list of str: explicit list of subject IDs
771
+
772
+ remove_original : bool, optional, default=True
773
+ Whether to remove the original session directories after successful compression.
774
+
775
+ Returns
776
+ -------
777
+ list of str
778
+ List of session directories that failed to be compressed. Empty list if all successful.
779
+
780
+ Raises
781
+ ------
782
+ FileNotFoundError
783
+ If the specified directory does not exist.
784
+
785
+ ValueError
786
+ If subj_ids is not None, str, or list, or if subject IDs file cannot be read.
787
+
788
+ tarfile.TarError
789
+ If there are issues with creating or writing tar files.
790
+
791
+ PermissionError
792
+ If there are insufficient permissions to compress files or remove directories.
793
+
794
+ OSError
795
+ If there are filesystem-related errors during compression.
796
+
797
+ Examples
798
+ --------
799
+ >>> # Basic usage - compress all sessions in directory
800
+ >>> failed = compress_dicom_session('/path/to/dicom/directory')
801
+ >>> if not failed:
802
+ ... print("All sessions compressed successfully")
803
+
804
+ >>> # Compress sessions but keep original directories
805
+ >>> failed = compress_dicom_session(
806
+ ... dic_dir='/path/to/dicom/directory',
807
+ ... remove_original=False
808
+ ... )
809
+
810
+ >>> # Compress sessions for specific subjects only
811
+ >>> failed = compress_dicom_session(
812
+ ... dic_dir='/path/to/dicom/directory',
813
+ ... subj_ids=['sub-001', 'sub-002', 'sub-003']
814
+ ... )
815
+
816
+ >>> # Use subject IDs from file
817
+ >>> failed = compress_dicom_session(
818
+ ... dic_dir='/path/to/dicom/directory',
819
+ ... subj_ids='/path/to/subject_ids.txt'
820
+ ... )
821
+ """
822
+ # Validate input directory
823
+ dic_path = Path(dic_dir)
824
+ if not dic_path.exists():
825
+ raise FileNotFoundError(f"Directory {dic_dir} does not exist")
826
+ if not dic_path.is_dir():
827
+ raise ValueError(f"{dic_dir} is not a directory")
828
+
829
+ # Process subject IDs
830
+ if subj_ids is None:
831
+ # Get all subjects with 'sub-' prefix
832
+ subj_ids = [
833
+ item.name
834
+ for item in dic_path.iterdir()
835
+ if item.is_dir() and item.name.startswith("sub-")
836
+ ]
837
+ subj_ids.sort()
838
+ elif isinstance(subj_ids, str):
839
+ # Read subject IDs from file
840
+ try:
841
+ with open(subj_ids, "r", encoding="utf-8") as file:
842
+ subj_ids = [line.strip() for line in file if line.strip()]
843
+ except FileNotFoundError:
844
+ raise FileNotFoundError(f"Subject IDs file {subj_ids} not found")
845
+ except Exception as e:
846
+ raise ValueError(f"Error reading subject IDs file: {e}")
847
+ elif isinstance(subj_ids, list):
848
+ # Validate list elements
849
+ if not all(isinstance(subj_id, str) for subj_id in subj_ids):
850
+ raise ValueError("All subject IDs must be strings")
851
+ else:
852
+ raise ValueError("subj_ids must be None, str (file path), or list of str")
853
+
854
+ if not subj_ids:
855
+ print("No subjects found to process")
856
+ return []
857
+
858
+ n_subj = len(subj_ids)
859
+ failed_sessions = []
860
+ total_sessions = 0
861
+ compressed_sessions = 0
862
+
863
+ with Progress() as pb:
864
+ task = pb.add_task("[green]Compressing sessions...", total=n_subj)
865
+
866
+ for i, subj_id in enumerate(subj_ids):
867
+ subj_dir = dic_path / subj_id
868
+
869
+ pb.update(
870
+ task_id=task,
871
+ description=f"[green]Processing {subj_id} ({i+1}/{n_subj})",
872
+ completed=i,
873
+ )
874
+
875
+ # Skip if subject directory doesn't exist
876
+ if not subj_dir.exists():
877
+ print(f"Warning: Subject directory {subj_dir} not found, skipping...")
878
+ continue
879
+
880
+ # Find all session directories (starting with 'ses-')
881
+ session_dirs = [
882
+ item
883
+ for item in subj_dir.iterdir()
884
+ if item.is_dir() and item.name.startswith("ses-")
885
+ ]
886
+
887
+ total_sessions += len(session_dirs)
888
+
889
+ for ses_dir in session_dirs:
890
+ tar_file_path = ses_dir.with_suffix(".tar.gz")
891
+
892
+ # Skip if tar file already exists
893
+ if tar_file_path.exists():
894
+ print(f"Warning: {tar_file_path} already exists, skipping...")
895
+ continue
896
+
897
+ try:
898
+ # Create tar.gz archive using Python's tarfile module
899
+ with tarfile.open(tar_file_path, "w:gz") as tar:
900
+ # Add the session directory to the archive
901
+ # Use arcname to preserve the directory structure
902
+ tar.add(ses_dir, arcname=ses_dir.name)
903
+
904
+ # Remove original directory if requested and compression succeeded
905
+ if remove_original:
906
+ shutil.rmtree(ses_dir)
907
+
908
+ compressed_sessions += 1
909
+
910
+ except tarfile.TarError as e:
911
+ print(f"Error compressing {ses_dir}: {e}")
912
+ failed_sessions.append(str(ses_dir))
913
+ # Clean up partially created tar file
914
+ if tar_file_path.exists():
915
+ try:
916
+ tar_file_path.unlink()
917
+ except Exception:
918
+ pass
919
+ except PermissionError as e:
920
+ print(f"Permission error with {ses_dir}: {e}")
921
+ failed_sessions.append(str(ses_dir))
922
+ except Exception as e:
923
+ print(f"Unexpected error with {ses_dir}: {e}")
924
+ failed_sessions.append(str(ses_dir))
925
+
926
+ pb.update(
927
+ task_id=task, description=f"[green]Completed compression", completed=n_subj
928
+ )
929
+
930
+ # Report results
931
+ if failed_sessions:
932
+ print("\nTHE PROCESS FAILED TO COMPRESS THE FOLLOWING SESSIONS:")
933
+ for failed_session in failed_sessions:
934
+ print(f" - {failed_session}")
935
+ else:
936
+ print("\nAll sessions compressed successfully!")
937
+
938
+ print(
939
+ f"\nProcessed {n_subj} subjects, {compressed_sessions}/{total_sessions} sessions compressed successfully."
940
+ )
941
+ return failed_sessions