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.
Files changed (41) hide show
  1. mnutils/GEExam.py +495 -0
  2. mnutils/GESeries.py +1578 -0
  3. mnutils/__init__.py +54 -0
  4. mnutils/_scripts.py +177 -0
  5. mnutils/fitting/AMARES.py +831 -0
  6. mnutils/fitting/__init__.py +5 -0
  7. mnutils/matlab/__init__.py +13 -0
  8. mnutils/matlab/setup_matlab.py +116 -0
  9. mnutils/plotting/__init__.py +38 -0
  10. mnutils/plotting/_widgets/__init__.py +5 -0
  11. mnutils/plotting/_widgets/_html.py +34 -0
  12. mnutils/plotting/_widgets/_shared/__init__.py +50 -0
  13. mnutils/plotting/_widgets/_shared/dom.js +124 -0
  14. mnutils/plotting/_widgets/_shared/theme.css +140 -0
  15. mnutils/plotting/_widgets/image_grid.css +202 -0
  16. mnutils/plotting/_widgets/image_grid.js +212 -0
  17. mnutils/plotting/_widgets/image_grid.py +86 -0
  18. mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
  19. mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
  20. mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
  21. mnutils/plotting/_widgets/slice_viewer.css +38 -0
  22. mnutils/plotting/_widgets/slice_viewer.js +41 -0
  23. mnutils/plotting/_widgets/slice_viewer.py +35 -0
  24. mnutils/plotting/images.py +1399 -0
  25. mnutils/plotting/saving.py +138 -0
  26. mnutils/plotting/spectra.py +357 -0
  27. mnutils/py.typed +0 -0
  28. mnutils/testing/__init__.py +3 -0
  29. mnutils/testing/_spectra.py +338 -0
  30. mnutils/testing/synthetic.py +373 -0
  31. mnutils/utils/__init__.py +25 -0
  32. mnutils/utils/data_loaders.py +218 -0
  33. mnutils/utils/file_helpers.py +708 -0
  34. mnutils/utils/images.py +202 -0
  35. mnutils/utils/nifti.py +379 -0
  36. mnutils/utils/spectra.py +149 -0
  37. mnutils-1.2.0.dist-info/METADATA +161 -0
  38. mnutils-1.2.0.dist-info/RECORD +41 -0
  39. mnutils-1.2.0.dist-info/WHEEL +4 -0
  40. mnutils-1.2.0.dist-info/entry_points.txt +7 -0
  41. 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}")