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.
- clabtoolkit/__init__.py +5 -0
- clabtoolkit/bidstools.py +2260 -0
- clabtoolkit/config/bids.json +147 -0
- clabtoolkit/config/config.json +58 -0
- clabtoolkit/config/lobes.json +112 -0
- clabtoolkit/config/stats_mapping.json +140 -0
- clabtoolkit/config/viz_views.json +268 -0
- clabtoolkit/connectivitytools.py +0 -0
- clabtoolkit/dicomtools.py +941 -0
- clabtoolkit/dwitools.py +1999 -0
- clabtoolkit/freesurfertools.py +5378 -0
- clabtoolkit/imagetools.py +2734 -0
- clabtoolkit/misctools.py +5204 -0
- clabtoolkit/morphometrytools.py +2970 -0
- clabtoolkit/networktools.py +351 -0
- clabtoolkit/parcellationtools.py +3239 -0
- clabtoolkit/pipelinetools.py +1187 -0
- clabtoolkit/plottools.py +682 -0
- clabtoolkit/qcqatools.py +1305 -0
- clabtoolkit/segmentationtools.py +356 -0
- clabtoolkit/surfacetools.py +2785 -0
- clabtoolkit/visualizationtools.py +5575 -0
- clabtoolkit-0.3.3.dist-info/METADATA +212 -0
- clabtoolkit-0.3.3.dist-info/RECORD +28 -0
- clabtoolkit-0.3.3.dist-info/WHEEL +5 -0
- clabtoolkit-0.3.3.dist-info/licenses/AUTHORS.rst +13 -0
- clabtoolkit-0.3.3.dist-info/licenses/LICENSE +16 -0
- clabtoolkit-0.3.3.dist-info/top_level.txt +1 -0
|
@@ -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
|