MNutils 1.2.0__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.
- mnutils/GEExam.py +495 -0
- mnutils/GESeries.py +1578 -0
- mnutils/__init__.py +54 -0
- mnutils/_scripts.py +177 -0
- mnutils/fitting/AMARES.py +831 -0
- mnutils/fitting/__init__.py +5 -0
- mnutils/matlab/__init__.py +13 -0
- mnutils/matlab/setup_matlab.py +116 -0
- mnutils/plotting/__init__.py +38 -0
- mnutils/plotting/_widgets/__init__.py +5 -0
- mnutils/plotting/_widgets/_html.py +34 -0
- mnutils/plotting/_widgets/_shared/__init__.py +50 -0
- mnutils/plotting/_widgets/_shared/dom.js +124 -0
- mnutils/plotting/_widgets/_shared/theme.css +140 -0
- mnutils/plotting/_widgets/image_grid.css +202 -0
- mnutils/plotting/_widgets/image_grid.js +212 -0
- mnutils/plotting/_widgets/image_grid.py +86 -0
- mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
- mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
- mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
- mnutils/plotting/_widgets/slice_viewer.css +38 -0
- mnutils/plotting/_widgets/slice_viewer.js +41 -0
- mnutils/plotting/_widgets/slice_viewer.py +35 -0
- mnutils/plotting/images.py +1399 -0
- mnutils/plotting/saving.py +138 -0
- mnutils/plotting/spectra.py +357 -0
- mnutils/py.typed +0 -0
- mnutils/testing/__init__.py +3 -0
- mnutils/testing/_spectra.py +338 -0
- mnutils/testing/synthetic.py +373 -0
- mnutils/utils/__init__.py +25 -0
- mnutils/utils/data_loaders.py +218 -0
- mnutils/utils/file_helpers.py +708 -0
- mnutils/utils/images.py +202 -0
- mnutils/utils/nifti.py +379 -0
- mnutils/utils/spectra.py +149 -0
- mnutils-1.2.0.dist-info/METADATA +161 -0
- mnutils-1.2.0.dist-info/RECORD +41 -0
- mnutils-1.2.0.dist-info/WHEEL +4 -0
- mnutils-1.2.0.dist-info/entry_points.txt +7 -0
- mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import h5py
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from IPython.core.getipython import get_ipython
|
|
9
|
+
from IPython.display import display
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
_DEFAULT_DICOM_FOLDER_PATTERN = r"(?:Series)?(\d{1,5})_"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _relative_to_cwd(path: str | Path) -> Path:
|
|
16
|
+
"""Express a path relative to the current working directory, for log messages.
|
|
17
|
+
|
|
18
|
+
Falls back to the unchanged path when no relative form exists -- notably on
|
|
19
|
+
Windows, where a path on another drive (e.g. a ``C:`` temp dir while the cwd
|
|
20
|
+
is on ``D:``) has a different anchor and ``Path.relative_to`` raises.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
path : str or Path
|
|
25
|
+
The path to shorten.
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
Path
|
|
30
|
+
The path relative to the cwd, or the original path if that is impossible.
|
|
31
|
+
"""
|
|
32
|
+
path = Path(path)
|
|
33
|
+
try:
|
|
34
|
+
return path.relative_to(os.getcwd(), walk_up=True)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_all_dicom_series_ids(data_folder: str | Path) -> tuple[list[int], list[int]]:
|
|
40
|
+
"""Retrieve all DICOM series IDs from a specified folder.
|
|
41
|
+
|
|
42
|
+
Scans the given folder for subdirectories whose names match the pattern
|
|
43
|
+
"0XX", where "XX" is a one- or two-digit number. Not including series
|
|
44
|
+
with three digits.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
data_folder : str or Path
|
|
49
|
+
The path to the folder containing the DICOM series in folders.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
tuple[list[int], list[int]]
|
|
54
|
+
A sorted list of integers representing the DICOM series IDs, and a
|
|
55
|
+
list of missing series IDs, if any.
|
|
56
|
+
"""
|
|
57
|
+
data_folder = Path(data_folder)
|
|
58
|
+
pattern = re.compile(_DEFAULT_DICOM_FOLDER_PATTERN)
|
|
59
|
+
series_numbers = []
|
|
60
|
+
for folder in data_folder.iterdir():
|
|
61
|
+
match = pattern.match(folder.name)
|
|
62
|
+
if match and folder.is_dir():
|
|
63
|
+
series_id = int(match.group(1))
|
|
64
|
+
series_numbers.append(series_id)
|
|
65
|
+
|
|
66
|
+
series_numbers.sort()
|
|
67
|
+
logger.debug(f"Found series IDs: {series_numbers}")
|
|
68
|
+
|
|
69
|
+
# This should now be an increasing list of numbers without any missing ones
|
|
70
|
+
# Briefly check if any indices are missing and raise.
|
|
71
|
+
missing = sorted(set(range(series_numbers[0], series_numbers[-1] + 1)) - set(series_numbers))
|
|
72
|
+
if missing:
|
|
73
|
+
logger.error(f"Missing series IDs: {missing}. DOUBLE CHECK DATA FOLDER!")
|
|
74
|
+
|
|
75
|
+
return series_numbers, missing
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_all_exam_series_ids(exam_folder: str | Path) -> list[int]:
|
|
79
|
+
"""Retrieve all exam series IDs from a specified folder.
|
|
80
|
+
|
|
81
|
+
Scans the given folder for subdirectories whose names match the pattern
|
|
82
|
+
"SeriesX" or "SeriesXX", where X represents a digit.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
exam_folder : str or Path
|
|
87
|
+
The path to the folder containing the exam series.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
list[int]
|
|
92
|
+
A sorted list of integers representing the series IDs.
|
|
93
|
+
"""
|
|
94
|
+
exam_folder = Path(exam_folder)
|
|
95
|
+
series_folders = [
|
|
96
|
+
folder
|
|
97
|
+
for folder in exam_folder.iterdir()
|
|
98
|
+
if folder.is_dir() and re.match(r"Series\d{1,2}", folder.name)
|
|
99
|
+
]
|
|
100
|
+
series_numbers = [
|
|
101
|
+
int(re.search(r"\d{1,2}", folder.name).group()) # type: ignore
|
|
102
|
+
for folder in series_folders
|
|
103
|
+
]
|
|
104
|
+
series_numbers.sort()
|
|
105
|
+
return series_numbers
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_exam_folder(base_path: str | Path) -> Path:
|
|
109
|
+
"""Find a folder containing 'Exam' in its name within the specified base path.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
base_path : str or Path
|
|
114
|
+
The base directory to search.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
Path
|
|
119
|
+
The Path object to the found 'Exam' folder.
|
|
120
|
+
|
|
121
|
+
Raises
|
|
122
|
+
------
|
|
123
|
+
ValueError
|
|
124
|
+
If no 'Exam' folder is found or if multiple 'Exam' folders exist.
|
|
125
|
+
"""
|
|
126
|
+
base = Path(base_path)
|
|
127
|
+
if not base.is_dir():
|
|
128
|
+
raise ValueError(f"The base path does not exist or is not a folder: '{base_path}'")
|
|
129
|
+
|
|
130
|
+
exam_folders = [item for item in base.iterdir() if "Exam" in item.name and item.is_dir()]
|
|
131
|
+
|
|
132
|
+
if len(exam_folders) == 0:
|
|
133
|
+
raise ValueError(f"No folder with 'Exam' in its name found in '{base_path}'")
|
|
134
|
+
elif len(exam_folders) > 1:
|
|
135
|
+
raise ValueError(f"Multiple folders with 'Exam' in their name found in '{base_path}'")
|
|
136
|
+
|
|
137
|
+
return exam_folders[0]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_mat_data_from_series(base_folder: str | Path, series: int) -> Path:
|
|
141
|
+
"""Find a specific .mat file within a designated series folder.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
base_folder : str or Path
|
|
146
|
+
The base directory.
|
|
147
|
+
series : int
|
|
148
|
+
The series number.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
Path
|
|
153
|
+
The Path object to the first found .mat file.
|
|
154
|
+
|
|
155
|
+
Raises
|
|
156
|
+
------
|
|
157
|
+
ValueError
|
|
158
|
+
If `base_folder` does not exist or `series` is not a positive integer.
|
|
159
|
+
FileNotFoundError
|
|
160
|
+
If no .mat files are found in the series folder.
|
|
161
|
+
"""
|
|
162
|
+
base = Path(base_folder)
|
|
163
|
+
if not base.is_dir():
|
|
164
|
+
raise ValueError(f"The base folder does not exist: '{base_folder}'")
|
|
165
|
+
if not isinstance(series, int) or series <= 0:
|
|
166
|
+
raise ValueError("Series must be a positive integer.")
|
|
167
|
+
|
|
168
|
+
exam_folder = get_exam_folder(base)
|
|
169
|
+
series_folder = exam_folder / f"Series{series}"
|
|
170
|
+
|
|
171
|
+
mat_files = list(series_folder.glob("ScanArchive*.mat"))
|
|
172
|
+
|
|
173
|
+
if not mat_files:
|
|
174
|
+
raise FileNotFoundError(
|
|
175
|
+
f"No .mat files found in the Series folder.\nFolder path: '{series_folder}'"
|
|
176
|
+
)
|
|
177
|
+
elif len(mat_files) == 1:
|
|
178
|
+
return mat_files[0]
|
|
179
|
+
else:
|
|
180
|
+
logger.warning(
|
|
181
|
+
"Multiple .mat files found in the Series folder. Selecting the first one.\n"
|
|
182
|
+
f"File '{mat_files[0].name}', Folder path: '{series_folder}'"
|
|
183
|
+
)
|
|
184
|
+
return mat_files[0]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_h5_data_from_series(
|
|
188
|
+
base_folder: str | Path, series: int, file_start: str = "ScanArchive"
|
|
189
|
+
) -> Path:
|
|
190
|
+
"""Find a specific .h5 file within a designated series folder.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
base_folder : str or Path
|
|
195
|
+
The base directory.
|
|
196
|
+
series : int
|
|
197
|
+
The series number.
|
|
198
|
+
file_start : str, optional
|
|
199
|
+
The filename prefix to match. Defaults to "ScanArchive".
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
Path
|
|
204
|
+
The Path object to the first found .h5 file.
|
|
205
|
+
|
|
206
|
+
Raises
|
|
207
|
+
------
|
|
208
|
+
ValueError
|
|
209
|
+
If `base_folder` does not exist or `series` is not a positive integer.
|
|
210
|
+
FileNotFoundError
|
|
211
|
+
If no .h5 files are found in the series folder.
|
|
212
|
+
"""
|
|
213
|
+
base = Path(base_folder)
|
|
214
|
+
if not base.is_dir():
|
|
215
|
+
raise ValueError(f"The base folder does not exist: '{base_folder}'")
|
|
216
|
+
if not isinstance(series, int) or series <= 0:
|
|
217
|
+
raise ValueError("Series must be a positive integer.")
|
|
218
|
+
|
|
219
|
+
exam_folder = get_exam_folder(base)
|
|
220
|
+
series_folder = exam_folder / f"Series{series}"
|
|
221
|
+
|
|
222
|
+
h5_files = list(series_folder.glob(f"{file_start}*.h5"))
|
|
223
|
+
|
|
224
|
+
if not h5_files:
|
|
225
|
+
raise FileNotFoundError(
|
|
226
|
+
f"No .h5 files found in the Series folder.\nFolder path: '{series_folder}'"
|
|
227
|
+
)
|
|
228
|
+
elif len(h5_files) == 1:
|
|
229
|
+
return h5_files[0]
|
|
230
|
+
else:
|
|
231
|
+
logger.warning(
|
|
232
|
+
"Multiple .h5 files found in the Series folder. Selecting the first one.\n"
|
|
233
|
+
f"File '{h5_files[0].name}', Folder path: '{series_folder}'"
|
|
234
|
+
)
|
|
235
|
+
return h5_files[0]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_latest_processing_file(output_folder: Path, series_id: int) -> Path:
|
|
239
|
+
"""Get the latest processing file for a given series ID.
|
|
240
|
+
|
|
241
|
+
Parameters
|
|
242
|
+
----------
|
|
243
|
+
output_folder : Path
|
|
244
|
+
The output folder where processing files are stored.
|
|
245
|
+
series_id : int
|
|
246
|
+
The series ID to look for.
|
|
247
|
+
|
|
248
|
+
Returns
|
|
249
|
+
-------
|
|
250
|
+
Path
|
|
251
|
+
The path to the latest processing file.
|
|
252
|
+
|
|
253
|
+
Raises
|
|
254
|
+
------
|
|
255
|
+
FileNotFoundError
|
|
256
|
+
If no processing files are found for the given series ID.
|
|
257
|
+
"""
|
|
258
|
+
processing_files = list(output_folder.glob(f"*Series{series_id:02d}_processing_data.h5"))
|
|
259
|
+
|
|
260
|
+
if not processing_files:
|
|
261
|
+
raise FileNotFoundError(
|
|
262
|
+
f"No processing files found for series ID {series_id} in {output_folder}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Sort files by modification time and return the latest one
|
|
266
|
+
latest_file = max(processing_files, key=os.path.getmtime)
|
|
267
|
+
return latest_file
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_dicom_folder(base_folder: str | Path, series: int) -> Path:
|
|
271
|
+
"""Find a specific DICOM folder within a designated series folder.
|
|
272
|
+
|
|
273
|
+
Parameters
|
|
274
|
+
----------
|
|
275
|
+
base_folder : str or Path
|
|
276
|
+
The base directory.
|
|
277
|
+
series : int
|
|
278
|
+
The series number.
|
|
279
|
+
|
|
280
|
+
Returns
|
|
281
|
+
-------
|
|
282
|
+
Path
|
|
283
|
+
The Path object to the DICOM folder.
|
|
284
|
+
|
|
285
|
+
Raises
|
|
286
|
+
------
|
|
287
|
+
ValueError
|
|
288
|
+
If `base_folder` does not exist, or no (or multiple) DICOM folders
|
|
289
|
+
match `series`.
|
|
290
|
+
KeyError
|
|
291
|
+
If `series` is not a positive integer.
|
|
292
|
+
"""
|
|
293
|
+
base = Path(base_folder)
|
|
294
|
+
if not base.is_dir():
|
|
295
|
+
raise ValueError(f"The base folder does not exist: '{base_folder}'")
|
|
296
|
+
if not isinstance(series, int) or series <= 0:
|
|
297
|
+
raise KeyError("Series must be a positive integer.")
|
|
298
|
+
|
|
299
|
+
# Compile the regex pattern
|
|
300
|
+
pattern = re.compile(_DEFAULT_DICOM_FOLDER_PATTERN)
|
|
301
|
+
|
|
302
|
+
# Look for a folder that matches the series number using the regex pattern
|
|
303
|
+
dicom_folders = []
|
|
304
|
+
for item in base.iterdir():
|
|
305
|
+
if item.is_dir():
|
|
306
|
+
match = pattern.match(item.name)
|
|
307
|
+
if match and int(match.group(1)) == series:
|
|
308
|
+
dicom_folders.append(item)
|
|
309
|
+
|
|
310
|
+
if len(dicom_folders) == 0:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"No DICOM folder matching the series number '{series}' found in '{base_folder}'. "
|
|
313
|
+
f"Ensure the folder name follows the pattern '{_DEFAULT_DICOM_FOLDER_PATTERN}'."
|
|
314
|
+
)
|
|
315
|
+
elif len(dicom_folders) > 1:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"Multiple DICOM folders matching the series number '{series}' found "
|
|
318
|
+
f"in '{base_folder}'. Check for duplicate folders or naming inconsistencies."
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
# For later processing its important that the folder name does not contain whitespaces
|
|
322
|
+
# Rename the folder to the same with all whitespaces replaced by underscores
|
|
323
|
+
dicom_folder = dicom_folders[0]
|
|
324
|
+
new_name = dicom_folder.name.replace(" ", "_")
|
|
325
|
+
if new_name != dicom_folder.name:
|
|
326
|
+
new_path = dicom_folder.parent / new_name
|
|
327
|
+
dicom_folder.rename(new_path)
|
|
328
|
+
dicom_folder = new_path
|
|
329
|
+
return dicom_folder
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_nifti_file(
|
|
333
|
+
folder: Path,
|
|
334
|
+
filter_out_keywords: list[str] | None = None,
|
|
335
|
+
filter_in_keywords: list[str] | None = None,
|
|
336
|
+
) -> Path | None:
|
|
337
|
+
"""Find the first NIfTI file in a folder matching the given keyword filters.
|
|
338
|
+
|
|
339
|
+
Parameters
|
|
340
|
+
----------
|
|
341
|
+
folder : Path
|
|
342
|
+
The folder to search for NIfTI files.
|
|
343
|
+
filter_out_keywords : list[str] or None, optional
|
|
344
|
+
Keywords whose presence in a filename excludes it. Defaults to
|
|
345
|
+
["bet", "skullstrip", "ss", "resampled"] when None.
|
|
346
|
+
filter_in_keywords : list[str] or None, optional
|
|
347
|
+
Keywords a filename must contain to be included. Defaults to no
|
|
348
|
+
filtering when None.
|
|
349
|
+
|
|
350
|
+
Returns
|
|
351
|
+
-------
|
|
352
|
+
Path or None
|
|
353
|
+
The first matching NIfTI file, or None if no file matches.
|
|
354
|
+
"""
|
|
355
|
+
if filter_out_keywords is None:
|
|
356
|
+
filter_out_keywords = ["bet", "skullstrip", "ss", "resampled"]
|
|
357
|
+
if filter_in_keywords is None:
|
|
358
|
+
filter_in_keywords = []
|
|
359
|
+
|
|
360
|
+
# Get all nifti files of the folder
|
|
361
|
+
# Make sure the filename does not contain "bet" or "skullstrip" or "ss"
|
|
362
|
+
all_nifti_files = list(folder.glob("*.nii")) + list(folder.glob("*.nii.gz"))
|
|
363
|
+
# Make sure to filter out files that contain "bet", "skullstrip", or "ss" in their names
|
|
364
|
+
filter_out_regex = (
|
|
365
|
+
re.compile("|".join(filter_out_keywords), re.IGNORECASE) if filter_out_keywords else None
|
|
366
|
+
)
|
|
367
|
+
filter_in_regex = (
|
|
368
|
+
re.compile("|".join(filter_in_keywords), re.IGNORECASE) if filter_in_keywords else None
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
logger.trace(
|
|
372
|
+
f"Filtering NIfTI files in '{folder}' with keywords not in word: "
|
|
373
|
+
f"{filter_out_keywords} and keywords in word: {filter_in_keywords}"
|
|
374
|
+
)
|
|
375
|
+
filtered_nifti_files = all_nifti_files
|
|
376
|
+
if filter_out_regex:
|
|
377
|
+
logger.trace(f"Filter out regex: {filter_out_regex.pattern}")
|
|
378
|
+
filtered_nifti_files = [
|
|
379
|
+
f for f in filtered_nifti_files if not filter_out_regex.search(f.name)
|
|
380
|
+
]
|
|
381
|
+
if filter_in_regex:
|
|
382
|
+
filtered_nifti_files = [f for f in filtered_nifti_files if filter_in_regex.search(f.name)]
|
|
383
|
+
|
|
384
|
+
selected_file = filtered_nifti_files[0] if filtered_nifti_files else None
|
|
385
|
+
logger.trace(f"Selected NIfTI file: {selected_file}")
|
|
386
|
+
return selected_file
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def get_niftis_from_series(
|
|
390
|
+
base_folder: str | Path, series: int, convert_dicoms: bool = False, **kwargs
|
|
391
|
+
) -> Path:
|
|
392
|
+
"""Find a specific NIfTI file within a designated series folder.
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
----------
|
|
396
|
+
base_folder : str or Path
|
|
397
|
+
The base directory.
|
|
398
|
+
series : int
|
|
399
|
+
The series number.
|
|
400
|
+
convert_dicoms : bool, optional
|
|
401
|
+
If True, converts DICOM files to NIfTI format if no NIfTI files are
|
|
402
|
+
found. Defaults to False.
|
|
403
|
+
**kwargs
|
|
404
|
+
Additional keyword arguments passed to `get_nifti_data`.
|
|
405
|
+
|
|
406
|
+
Returns
|
|
407
|
+
-------
|
|
408
|
+
Path
|
|
409
|
+
The Path object to the first found NIfTI file.
|
|
410
|
+
|
|
411
|
+
Raises
|
|
412
|
+
------
|
|
413
|
+
FileNotFoundError
|
|
414
|
+
If no NIfTI files are found in the series folder and `convert_dicoms`
|
|
415
|
+
is False (or conversion produced no NIfTI files).
|
|
416
|
+
"""
|
|
417
|
+
series_folder = get_dicom_folder(base_folder, series)
|
|
418
|
+
return get_nifti_data(series_folder, convert_dicoms=convert_dicoms, **kwargs)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def get_nifti_data(folder: str | Path, convert_dicoms: bool = False, **kwargs) -> Path:
|
|
422
|
+
"""
|
|
423
|
+
Retrieve NIfTI files from a folder, optionally converting DICOM files to NIfTI format.
|
|
424
|
+
|
|
425
|
+
This function searches for NIfTI files in the specified folder. If no NIfTI files are found
|
|
426
|
+
and convert_dicoms is True, it attempts to convert DICOM files in the folder to NIfTI format.
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
folder : str | Path
|
|
431
|
+
The folder path to search for NIfTI files or containing DICOM files to convert.
|
|
432
|
+
convert_dicoms : bool, optional
|
|
433
|
+
If True, attempt to convert DICOM files to NIfTI format if no NIfTI files are found.
|
|
434
|
+
Default is False.
|
|
435
|
+
**kwargs
|
|
436
|
+
Additional keyword arguments to pass to get_nifti_file().
|
|
437
|
+
|
|
438
|
+
Returns
|
|
439
|
+
-------
|
|
440
|
+
Path
|
|
441
|
+
The path to a NIfTI file found in the folder.
|
|
442
|
+
|
|
443
|
+
Raises
|
|
444
|
+
------
|
|
445
|
+
FileNotFoundError
|
|
446
|
+
If no NIfTI files are found in the folder and either:
|
|
447
|
+
- convert_dicoms is False, or
|
|
448
|
+
- convert_dicoms is True but the conversion attempt did not produce any NIfTI files.
|
|
449
|
+
|
|
450
|
+
Notes
|
|
451
|
+
-----
|
|
452
|
+
- The function first attempts to find existing NIfTI files without conversion.
|
|
453
|
+
- If convert_dicoms is True and no NIfTI files are found, DICOM files are converted
|
|
454
|
+
using dcm2niiw with the filename format "%s_%d_%a".
|
|
455
|
+
- The dcm2niiw package must be installed to use the convert_dicoms functionality.
|
|
456
|
+
|
|
457
|
+
Examples
|
|
458
|
+
--------
|
|
459
|
+
>>> nifti_path = get_niftis('/path/to/series/folder')
|
|
460
|
+
>>> nifti_path = get_niftis('/path/to/dicom/folder', convert_dicoms=True)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
"""
|
|
464
|
+
folder = Path(folder)
|
|
465
|
+
|
|
466
|
+
if nifti_file := get_nifti_file(folder, **kwargs):
|
|
467
|
+
return nifti_file
|
|
468
|
+
|
|
469
|
+
if convert_dicoms:
|
|
470
|
+
# Check if dcm2niiw is available else return with error and recommend installing it
|
|
471
|
+
from dcm2niiw import dcm2nii
|
|
472
|
+
|
|
473
|
+
dcm2nii(
|
|
474
|
+
in_folder=folder,
|
|
475
|
+
out_folder=folder,
|
|
476
|
+
filename_format="%s_%d_%a",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if nifti_file := get_nifti_file(folder, **kwargs):
|
|
480
|
+
return nifti_file
|
|
481
|
+
|
|
482
|
+
raise FileNotFoundError(
|
|
483
|
+
"No NIfTI files found in the Series folder after conversion attempt.\n"
|
|
484
|
+
f"Folder path: '{folder}'"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
error_msg = f"No NIfTI files found in the Series folder.\nFolder path: '{folder}'"
|
|
488
|
+
if not convert_dicoms:
|
|
489
|
+
error_msg += (
|
|
490
|
+
"\nYou may try running with `convert_dicoms=True` to convert DICOM files "
|
|
491
|
+
"to NIfTI format."
|
|
492
|
+
)
|
|
493
|
+
raise FileNotFoundError(error_msg)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def print_hdf5_tree(hdf5_file: h5py.File | h5py.Group, prefix: str = "") -> str:
|
|
497
|
+
"""Return a string containing the tree representation of the HDF5 file.
|
|
498
|
+
|
|
499
|
+
Parameters
|
|
500
|
+
----------
|
|
501
|
+
hdf5_file : h5py.File or h5py.Group
|
|
502
|
+
The HDF5 file object.
|
|
503
|
+
prefix : str, optional
|
|
504
|
+
The line prefix, used for recursive indentation. Defaults to "".
|
|
505
|
+
|
|
506
|
+
Returns
|
|
507
|
+
-------
|
|
508
|
+
str
|
|
509
|
+
The full or partial tree representation of the file.
|
|
510
|
+
"""
|
|
511
|
+
tree_string = ""
|
|
512
|
+
items_index = len(hdf5_file)
|
|
513
|
+
|
|
514
|
+
for key, hdf5_value in hdf5_file.items():
|
|
515
|
+
items_index -= 1
|
|
516
|
+
|
|
517
|
+
branch_symbol = "├──"
|
|
518
|
+
prefix_symbol = "|"
|
|
519
|
+
|
|
520
|
+
if items_index == 0:
|
|
521
|
+
branch_symbol = "└──"
|
|
522
|
+
prefix_symbol = " "
|
|
523
|
+
|
|
524
|
+
if isinstance(hdf5_value, h5py.Group):
|
|
525
|
+
tree_string += f"{prefix}{branch_symbol} {key}\n"
|
|
526
|
+
tree_string += print_hdf5_tree(hdf5_value, f"{prefix}{prefix_symbol} ")
|
|
527
|
+
|
|
528
|
+
else:
|
|
529
|
+
try:
|
|
530
|
+
tree_string += f"{prefix}{branch_symbol} "
|
|
531
|
+
if hdf5_value.shape == ():
|
|
532
|
+
tree_string += f"{key} {str(hdf5_value[()])}\n"
|
|
533
|
+
else:
|
|
534
|
+
tree_string += f"{key} {hdf5_value.shape}\n"
|
|
535
|
+
|
|
536
|
+
except TypeError:
|
|
537
|
+
tree_string += f"{prefix}{branch_symbol} {key} (scalar)\n"
|
|
538
|
+
|
|
539
|
+
return tree_string
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def get_exam_overview(data_folder: str | Path, print_overview: bool = True) -> pd.DataFrame:
|
|
543
|
+
"""Build (and optionally print) an overview of all exam series in a data folder.
|
|
544
|
+
|
|
545
|
+
Parameters
|
|
546
|
+
----------
|
|
547
|
+
data_folder : str or Path
|
|
548
|
+
The path to the folder containing the DICOM series in folders.
|
|
549
|
+
print_overview : bool, optional
|
|
550
|
+
Whether to print/display the overview. Defaults to True.
|
|
551
|
+
|
|
552
|
+
Returns
|
|
553
|
+
-------
|
|
554
|
+
pandas.DataFrame
|
|
555
|
+
A DataFrame containing the overview of all exam series, indexed by
|
|
556
|
+
series ID, with "Has Exam", "Has DICOM" and "Folder Name" columns.
|
|
557
|
+
"""
|
|
558
|
+
series_ids, missing = get_all_dicom_series_ids(data_folder)
|
|
559
|
+
try:
|
|
560
|
+
exam_folder = get_exam_folder(data_folder)
|
|
561
|
+
exam_ids = get_all_exam_series_ids(exam_folder)
|
|
562
|
+
except ValueError:
|
|
563
|
+
logger.warning("No exam folder found. Skipping exam overview.")
|
|
564
|
+
exam_folder = None
|
|
565
|
+
exam_ids = []
|
|
566
|
+
|
|
567
|
+
overview_data = []
|
|
568
|
+
|
|
569
|
+
all_ids = sorted(set(series_ids).union(exam_ids))
|
|
570
|
+
for series_id in all_ids:
|
|
571
|
+
try:
|
|
572
|
+
dicom_folder = get_dicom_folder(data_folder, series_id)
|
|
573
|
+
folder_name = dicom_folder.name
|
|
574
|
+
has_dicom = True
|
|
575
|
+
except ValueError:
|
|
576
|
+
folder_name = None
|
|
577
|
+
has_dicom = False
|
|
578
|
+
|
|
579
|
+
overview_data.append(
|
|
580
|
+
{
|
|
581
|
+
"Series ID": series_id,
|
|
582
|
+
"Has Exam": series_id in exam_ids,
|
|
583
|
+
"Has DICOM": has_dicom,
|
|
584
|
+
"Folder Name": folder_name,
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
overview_df = pd.DataFrame(overview_data).set_index("Series ID")
|
|
589
|
+
|
|
590
|
+
if print_overview:
|
|
591
|
+
if is_running_in_jupyter():
|
|
592
|
+
|
|
593
|
+
def highlight_rows(row):
|
|
594
|
+
if not row["Has DICOM"]:
|
|
595
|
+
return ["background-color: lightsalmon"] * len(row)
|
|
596
|
+
elif row["Has Exam"]:
|
|
597
|
+
return ["background-color: lightblue"] * len(row)
|
|
598
|
+
return [""] * len(row)
|
|
599
|
+
|
|
600
|
+
styled_df = overview_df.style.apply(highlight_rows, axis=1)
|
|
601
|
+
display(styled_df)
|
|
602
|
+
# Print info about the color coding
|
|
603
|
+
logger.debug("Color coding: blue = Has Exam Data, orange = Missing DICOM")
|
|
604
|
+
# Log exam path
|
|
605
|
+
logger.debug(f"Exam folder path: {exam_folder}")
|
|
606
|
+
else:
|
|
607
|
+
logger.info(f"\n{overview_df.to_string()}")
|
|
608
|
+
|
|
609
|
+
return overview_df
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def is_running_in_jupyter() -> bool:
|
|
613
|
+
"""Check if the code is being run within an IPython kernel (Jupyter Notebook or Console)."""
|
|
614
|
+
try:
|
|
615
|
+
# Check if the function exists
|
|
616
|
+
shell = get_ipython().__class__.__name__
|
|
617
|
+
|
|
618
|
+
if shell == "ZMQInteractiveShell":
|
|
619
|
+
# This is the shell used by Jupyter notebooks/labs
|
|
620
|
+
return True
|
|
621
|
+
elif shell == "TerminalInteractiveShell":
|
|
622
|
+
# This is the shell used by the IPython terminal (not a notebook)
|
|
623
|
+
return False
|
|
624
|
+
else:
|
|
625
|
+
# Other interactive shells
|
|
626
|
+
return False
|
|
627
|
+
|
|
628
|
+
except NameError:
|
|
629
|
+
# get_ipython is not defined, so it's a standard Python interpreter/script
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def move_files_with_glob(
|
|
634
|
+
folder: str | Path,
|
|
635
|
+
file_glob: str,
|
|
636
|
+
target_folder_name: str = "old",
|
|
637
|
+
prepend_creation_date: bool = True,
|
|
638
|
+
) -> None:
|
|
639
|
+
"""Move files matching a glob pattern from a folder to a target folder.
|
|
640
|
+
|
|
641
|
+
Parameters
|
|
642
|
+
----------
|
|
643
|
+
folder : str or Path
|
|
644
|
+
The folder to search for files.
|
|
645
|
+
file_glob : str
|
|
646
|
+
The glob pattern to look for (e.g. '*.csv'), passed directly to
|
|
647
|
+
`Path.glob`.
|
|
648
|
+
target_folder_name : str, optional
|
|
649
|
+
The name of the target folder, created inside `folder`. Defaults to
|
|
650
|
+
"old".
|
|
651
|
+
prepend_creation_date : bool, optional
|
|
652
|
+
Whether to prepend the creation date to the filename when moving.
|
|
653
|
+
Defaults to True.
|
|
654
|
+
|
|
655
|
+
Raises
|
|
656
|
+
------
|
|
657
|
+
ValueError
|
|
658
|
+
If the folder does not exist or is not a directory.
|
|
659
|
+
"""
|
|
660
|
+
folder = Path(folder)
|
|
661
|
+
if not folder.is_dir():
|
|
662
|
+
raise ValueError(f"The specified folder does not exist or is not a directory: '{folder}'")
|
|
663
|
+
|
|
664
|
+
target_folder = folder / target_folder_name
|
|
665
|
+
# Check if there are any files matching the glob pattern
|
|
666
|
+
files_to_move = list(folder.glob(file_glob))
|
|
667
|
+
if not files_to_move:
|
|
668
|
+
logger.debug(f"No files found matching the pattern '{file_glob}' in '{folder}'.")
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
# Create the target folder only if there are files to move
|
|
672
|
+
target_folder.mkdir(exist_ok=True)
|
|
673
|
+
|
|
674
|
+
for file in folder.glob(file_glob):
|
|
675
|
+
target_file = target_folder / file.name
|
|
676
|
+
# Check if target file already exists
|
|
677
|
+
target_file_exists = target_file.exists()
|
|
678
|
+
if target_file_exists:
|
|
679
|
+
logger.warning(
|
|
680
|
+
f"File '{target_file}' already exists. Renaming the existing file "
|
|
681
|
+
"by prepending its creation date."
|
|
682
|
+
)
|
|
683
|
+
# Rename the existing file in the target folder by prepending its creation date
|
|
684
|
+
creation_time = target_file.stat().st_mtime
|
|
685
|
+
date_str = datetime.fromtimestamp(creation_time).strftime("%Y%m%d_%H%M%S")
|
|
686
|
+
timestamped_existing_file = f"{date_str}_{target_file.name}"
|
|
687
|
+
target_file.rename(target_file.parent / timestamped_existing_file)
|
|
688
|
+
logger.debug(f"Renamed existing file to: {timestamped_existing_file}")
|
|
689
|
+
|
|
690
|
+
if prepend_creation_date:
|
|
691
|
+
# Before prepending date check if the file already has a date prefix, if so, do not
|
|
692
|
+
# prepend another one
|
|
693
|
+
if re.match(r"^\d{8}_\d{6}_", file.name):
|
|
694
|
+
logger.debug(
|
|
695
|
+
f"File '{file.name}' already has a date prefix. Moving without "
|
|
696
|
+
"prepending another date."
|
|
697
|
+
)
|
|
698
|
+
else:
|
|
699
|
+
creation_time = file.stat().st_mtime
|
|
700
|
+
date_str = datetime.fromtimestamp(creation_time).strftime("%Y%m%d_%H%M%S")
|
|
701
|
+
new_name = f"{date_str}_{file.name}"
|
|
702
|
+
target_file = target_folder / new_name
|
|
703
|
+
|
|
704
|
+
file.rename(target_file)
|
|
705
|
+
if target_file.is_absolute():
|
|
706
|
+
logger.debug(f"Moved file to: {_relative_to_cwd(target_file)}")
|
|
707
|
+
else:
|
|
708
|
+
logger.debug(f"Moved file to: {target_file}")
|