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
mnutils/GEExam.py ADDED
@@ -0,0 +1,495 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from datetime import datetime
5
+ from operator import itemgetter
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+ import xarray as xr
11
+ from loguru import logger
12
+
13
+ from . import GESeries, plotting
14
+ from .utils import file_helpers
15
+
16
+ ANATOMICAL_SERIES_TO_SKIP = ["FGRE", "localizer", "GE_HOS", "localiser"]
17
+
18
+
19
+ class ExamBase:
20
+ """Base class for a GE exam, loading series metadata and providing series access."""
21
+
22
+ def __init__(
23
+ self,
24
+ BASE_FOLDER: str | Path,
25
+ DATA_FOLDER: str | Path | None = None,
26
+ OUTPUT_FOLDER: str | Path | None = None,
27
+ ) -> None:
28
+ self.BASE_FOLDER = Path(BASE_FOLDER)
29
+ if DATA_FOLDER is None:
30
+ self.DATA_FOLDER = self.BASE_FOLDER / "data"
31
+ else:
32
+ self.DATA_FOLDER = Path(DATA_FOLDER)
33
+ if OUTPUT_FOLDER is None:
34
+ self.OUTPUT_FOLDER = self.BASE_FOLDER / "output"
35
+ # If it does not exist, create it
36
+ self.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
37
+ else:
38
+ self.OUTPUT_FOLDER = Path(OUTPUT_FOLDER)
39
+
40
+ self.exam_overview = file_helpers.get_exam_overview(self.DATA_FOLDER, print_overview=True)
41
+ self.series = self.exam_overview.index.tolist()
42
+ self.series_dict = self._create_series_dict()
43
+
44
+ self.all: dict[int, GESeries.MRISeries] = {}
45
+
46
+ def __repr__(self) -> str:
47
+ """Return a short representation showing the class and base folder name."""
48
+ class_name = self.__class__.__name__
49
+ folder_name = self.BASE_FOLDER.name
50
+ return f"{class_name}({folder_name})"
51
+
52
+ def __str__(self) -> str:
53
+ """Return a formatted overview of the exam's series."""
54
+ width = 72
55
+ line_sep = "\n" + "-" * width + "\n"
56
+ topline = f" {self.__repr__()} ".center(width, "-") + "\n"
57
+ return f"{topline}{self.exam_overview.to_string(max_colwidth=40)}{line_sep}"
58
+
59
+ def __getitem__(self, key: int) -> GESeries.MRISeries:
60
+ """Return the loaded series with the given series ID."""
61
+ try:
62
+ return self.all[key]
63
+ except KeyError as ke:
64
+ valid_series = list(self.all.keys())
65
+ ke.add_note(f"Series ID {key} is not valid. Valid series IDs are: {valid_series}")
66
+ raise
67
+
68
+ def __iter__(self) -> Iterator[tuple[int, GESeries.MRISeries]]:
69
+ """Iterate over loaded series as (series ID, series object) pairs."""
70
+ yield from self.all.items()
71
+
72
+ def _create_series_dict(self) -> dict[int, type[GESeries.MRISeries]]:
73
+ series_class_dict = {}
74
+ for series_num in self.series:
75
+ folder_name = self.exam_overview.at[series_num, "Folder Name"]
76
+ has_exam = self.exam_overview.at[series_num, "Has Exam"]
77
+ has_dicom = self.exam_overview.at[series_num, "Has DICOM"]
78
+
79
+ if has_exam and not has_dicom:
80
+ logger.debug(
81
+ f"Series {series_num} does not have DICOM. Assuming it is an MRS series."
82
+ )
83
+ self.exam_overview.at[series_num, "Folder Name"] = "ASSUMING MRS"
84
+ series_class_dict[series_num] = GESeries.MRSSeries
85
+ continue
86
+
87
+ # Make sure folder name is a string
88
+ if isinstance(folder_name, str) and has_dicom:
89
+ folder_name = folder_name.strip().lower()
90
+ else:
91
+ logger.warning(
92
+ f"Folder name for series {series_num} is not a string: {folder_name}. Skipping."
93
+ )
94
+ continue
95
+
96
+ if not has_exam and not has_dicom:
97
+ logger.debug(f"Series {series_num} does not have exam or DICOM data. Skipping")
98
+ continue
99
+
100
+ if has_dicom and not has_exam:
101
+ logger.trace(f"Series {series_num} does not have raw data.")
102
+ # Based on the folder name determine if it is anatomical data we want to load
103
+ if any(skip_name.lower() in folder_name for skip_name in ANATOMICAL_SERIES_TO_SKIP):
104
+ logger.debug(f"Series {series_num} is anatomical data to skip.")
105
+ continue
106
+ series_class_dict[series_num] = GESeries.MRISeries
107
+ continue
108
+
109
+ if has_exam and has_dicom:
110
+ logger.trace(
111
+ f"Series {series_num} has both exam and DICOM data. Checking folder name."
112
+ )
113
+ if "mrs_washin" in folder_name:
114
+ series_class_dict[series_num] = GESeries.MRSWashinSeries
115
+ elif "mrsi_pseudo" in folder_name:
116
+ series_class_dict[series_num] = GESeries.MRSISeries
117
+ elif "mrs_" in folder_name:
118
+ series_class_dict[series_num] = GESeries.MRSSeries
119
+ elif "bs_prescan" in folder_name:
120
+ continue # Skip the bs_prescan series
121
+ else:
122
+ logger.warning(
123
+ f"Series {series_num} with folder name '{folder_name}' does not "
124
+ f"match any known series types. Skipping."
125
+ )
126
+
127
+ return series_class_dict
128
+
129
+ def load_all(self, overwrite: bool = False) -> None:
130
+ """Load all series listed in the series dictionary.
131
+
132
+ Parameters
133
+ ----------
134
+ overwrite : bool, optional
135
+ If True, reload series that are already loaded, by default False.
136
+ """
137
+ for series_id, series_class in self.series_dict.items():
138
+ if (
139
+ overwrite
140
+ or series_id not in self.all
141
+ or not isinstance(self.all[series_id], series_class)
142
+ ):
143
+ self.all[series_id] = series_class(self.DATA_FOLDER, series_id)
144
+ else:
145
+ logger.debug(f"Series {series_id} already loaded, skipping.")
146
+
147
+ def load_series(self, series_ids: int | list[int], overwrite: bool = False) -> None:
148
+ """Load one or more specific series by ID.
149
+
150
+ Parameters
151
+ ----------
152
+ series_ids : int or list of int
153
+ Series ID or list of series IDs to load.
154
+ overwrite : bool, optional
155
+ If True, reload series that are already loaded, by default False.
156
+ """
157
+ if isinstance(series_ids, int):
158
+ series_ids = [series_ids]
159
+ for series_id in series_ids:
160
+ if series_id not in self.series_dict:
161
+ logger.warning(f"Series {series_id} not found in series dictionary. Skipping.")
162
+ continue
163
+ series_class = self.series_dict[series_id]
164
+ if (
165
+ overwrite
166
+ or series_id not in self.all
167
+ or not isinstance(self.all[series_id], series_class)
168
+ ):
169
+ self.all[series_id] = series_class(self.DATA_FOLDER, series_id)
170
+ else:
171
+ logger.debug(f"Series {series_id} already loaded, skipping.")
172
+
173
+
174
+ class DMIExam(ExamBase):
175
+ """Exam containing anatomical, MRS, and MRSI series for a DMI acquisition."""
176
+
177
+ def __init__(
178
+ self,
179
+ BASE_FOLDER: str | Path,
180
+ DATA_FOLDER: str | Path | None = None,
181
+ OUTPUT_FOLDER: str | Path | None = None,
182
+ ANATOMICAL_series: str | int | None = None,
183
+ ) -> None:
184
+ super().__init__(BASE_FOLDER, DATA_FOLDER, OUTPUT_FOLDER)
185
+
186
+ if isinstance(ANATOMICAL_series, int):
187
+ self.ANATOMICAL_series = ANATOMICAL_series
188
+ else:
189
+ if ANATOMICAL_series is not None:
190
+ identifier = ANATOMICAL_series.strip().lower()
191
+ else:
192
+ identifier = "t1_bravo"
193
+ self.ANATOMICAL_series = (
194
+ self.exam_overview[
195
+ self.exam_overview["Folder Name"].str.contains(identifier, na=False, case=False)
196
+ ]
197
+ .index.to_numpy()
198
+ .astype(int)[0]
199
+ )
200
+
201
+ self.MRS_series = (
202
+ self.exam_overview[
203
+ self.exam_overview["Folder Name"].str.contains("MRS_unloc", na=False)
204
+ ]
205
+ .index.to_numpy()
206
+ .astype(int)
207
+ )
208
+ # By default append all series where Has Dicom in the exam overview table is False
209
+ no_DICOM_series = (
210
+ self.exam_overview[~self.exam_overview["Has DICOM"]].index.to_numpy().astype(int)
211
+ )
212
+ self.MRS_series = np.union1d(self.MRS_series, no_DICOM_series)
213
+
214
+ self.MRSI_series = (
215
+ self.exam_overview[
216
+ self.exam_overview["Folder Name"].str.contains("MRSI_pseudo", na=False)
217
+ ]
218
+ .index.to_numpy()
219
+ .astype(int)
220
+ )
221
+
222
+ self.anatomical: GESeries.MRISeries
223
+ self.all_MRS: list[GESeries.MRSSeries] = []
224
+ self.all_MRSI: list[GESeries.MRSISeries] = []
225
+
226
+ def load_all_series(self, overwrite: bool = False) -> None:
227
+ """Load all series and sort them into anatomical, MRS, and MRSI lists.
228
+
229
+ Parameters
230
+ ----------
231
+ overwrite : bool, optional
232
+ If True, reload series that are already loaded, by default False.
233
+ """
234
+ super().load_all(overwrite=overwrite)
235
+
236
+ # Iterate through all series and assign to appropriate lists
237
+ # To make sure that we do not have duplicates, reset all lists first
238
+ self.washin = None # type: ignore
239
+ self.all_MRS = []
240
+ self.all_MRSI = []
241
+
242
+ for series_obj in self.all.values():
243
+ # Order is important as MRSWashinSeries is a subclass of MRSSeries
244
+ # and all raw classes are subclasses of MRISeries
245
+ if isinstance(series_obj, GESeries.MRSSeries):
246
+ self.all_MRS.append(series_obj)
247
+ elif isinstance(series_obj, GESeries.MRSISeries):
248
+ self.all_MRSI.append(series_obj)
249
+ elif isinstance(series_obj, GESeries.MRISeries):
250
+ if not self.ANATOMICAL_series == series_obj.SERIES_ID:
251
+ continue
252
+ if hasattr(self, "anatomical") and self.anatomical is not None:
253
+ logger.warning(
254
+ f"Multiple anatomical datasets detected. "
255
+ f"Previous: Series ID {self.anatomical.SERIES_ID}, "
256
+ f"Name '{self.exam_overview.at[self.anatomical.SERIES_ID, 'Folder Name']}'."
257
+ f" New: Series ID {series_obj.SERIES_ID}, "
258
+ f"Name '{self.exam_overview.at[series_obj.SERIES_ID, 'Folder Name']}'."
259
+ )
260
+ self.anatomical = series_obj
261
+
262
+
263
+ class MS_DMIExam(DMIExam):
264
+ """DMI exam for a multi-slice acquisition with a single MRSI and two MRS series."""
265
+
266
+ def __init__(
267
+ self,
268
+ BASE_FOLDER: str | Path,
269
+ DATA_FOLDER: str | Path | None = None,
270
+ OUTPUT_FOLDER: str | Path | None = None,
271
+ ANATOMICAL_series: str | int | None = None,
272
+ ) -> None:
273
+ super().__init__(BASE_FOLDER, DATA_FOLDER, OUTPUT_FOLDER, ANATOMICAL_series)
274
+ logger.info("MS DMI Exam initialized.")
275
+
276
+ self.MRSI: GESeries.MRSISeries
277
+ self.pre_MRS: GESeries.MRSSeries
278
+ self.post_MRS: GESeries.MRSSeries
279
+ self.normalisation_factor: np.complexfloating
280
+
281
+ def load_all_series(self, overwrite: bool = False) -> None:
282
+ """Load all series and identify the MRSI series and the pre-/post-MRS pair.
283
+
284
+ Parameters
285
+ ----------
286
+ overwrite : bool, optional
287
+ If True, reload series that are already loaded, by default False.
288
+ """
289
+ super().load_all_series(overwrite)
290
+
291
+ if len(self.all_MRSI) != 1:
292
+ logger.warning(
293
+ f"Expected exactly one MRSI series, but found {len(self.all_MRSI)}. "
294
+ f"Please check the exam overview and series loading."
295
+ )
296
+ else:
297
+ self.MRSI = self.all_MRSI[0]
298
+
299
+ mrs_candidates = self.all_MRS
300
+ if len(mrs_candidates) > 2 and hasattr(self, "MRSI"):
301
+ mrsi_id = self.MRSI.SERIES_ID
302
+ before = [s for s in mrs_candidates if s.SERIES_ID < mrsi_id]
303
+ after = [s for s in mrs_candidates if s.SERIES_ID > mrsi_id]
304
+ if before and after:
305
+ mrs_candidates = [
306
+ max(before, key=lambda s: s.SERIES_ID),
307
+ min(after, key=lambda s: s.SERIES_ID),
308
+ ]
309
+ logger.info(
310
+ "More than two MRS series found; selected series "
311
+ f"directly before ({mrs_candidates[0].SERIES_ID}) and after "
312
+ f"({mrs_candidates[1].SERIES_ID}) the MRSI series "
313
+ f"({mrsi_id})."
314
+ )
315
+
316
+ if len(mrs_candidates) != 2:
317
+ logger.warning(f"Expected exactly two MRS series, but found {len(mrs_candidates)}. ")
318
+ else:
319
+ self.pre_MRS = mrs_candidates[0]
320
+ self.post_MRS = mrs_candidates[1]
321
+ self.normalisation_factor = self.pre_MRS.avg_fid.__abs__().max().values.item() / 10
322
+
323
+
324
+ class DMIinjExam(DMIExam):
325
+ """DMI exam for an intravenous injection, splitting series into pre-/post-injection."""
326
+
327
+ def __init__(
328
+ self,
329
+ BASE_FOLDER: str | Path,
330
+ DATA_FOLDER: str | Path | None = None,
331
+ OUTPUT_FOLDER: str | Path | None = None,
332
+ ANATOMICAL_series: str | int | None = None,
333
+ ) -> None:
334
+ super().__init__(BASE_FOLDER, DATA_FOLDER, OUTPUT_FOLDER, ANATOMICAL_series)
335
+
336
+ self.WASHIN_series = (
337
+ self.exam_overview[
338
+ self.exam_overview["Folder Name"].str.contains("MRS_washin", na=False)
339
+ ]
340
+ .index.to_numpy()
341
+ .astype(int)[0]
342
+ )
343
+
344
+ # Define pre and post series based on wash-in series
345
+ self.pre_MRS_series, self.post_MRS_series = self._split_series(
346
+ self.MRS_series, self.WASHIN_series, "MRS"
347
+ )
348
+ self.pre_MRSI_series, self.post_MRSI_series = self._split_series(
349
+ self.MRSI_series, self.WASHIN_series, "MRSI"
350
+ )
351
+
352
+ logger.info("DMI Intravenous Injection Exam initialized.")
353
+
354
+ self.washin: GESeries.MRSWashinSeries
355
+ self.pre_MRS: GESeries.MRSSeries
356
+ self.post_MRS: list[GESeries.MRSSeries] = []
357
+ self.pre_MRSI: GESeries.MRSISeries
358
+ self.post_MRSI: list[GESeries.MRSISeries] = []
359
+ self.last_MRS: GESeries.MRSSeries
360
+ self.last_MRSI: GESeries.MRSISeries
361
+
362
+ self.normalisation_factor: np.complexfloating = np.complex64(1.0)
363
+ self.injection_time: datetime
364
+
365
+ def load_all_series(self, overwrite: bool = False) -> None:
366
+ """Load all series and split MRS/MRSI series into pre- and post-injection groups.
367
+
368
+ Parameters
369
+ ----------
370
+ overwrite : bool, optional
371
+ If True, reload series that are already loaded, by default False.
372
+ """
373
+ super().load_all_series(overwrite=overwrite)
374
+
375
+ for series_obj in self.all.values():
376
+ if isinstance(series_obj, GESeries.MRSWashinSeries):
377
+ self.washin = series_obj
378
+
379
+ logger.debug("Splitting all series into pre- and post-injection series.")
380
+ series_mapping = {
381
+ "pre_MRS": self.pre_MRS_series,
382
+ "post_MRS": self.post_MRS_series,
383
+ "pre_MRSI": self.pre_MRSI_series,
384
+ "post_MRSI": self.post_MRSI_series,
385
+ }
386
+
387
+ for attr_name, series in series_mapping.items():
388
+ # If pre in the name than attr name is singular
389
+ if attr_name.startswith("pre_"):
390
+ pre = True
391
+ else:
392
+ pre = False
393
+
394
+ # Convert to numpy array if it is a single integer for consistent processing
395
+ series = np.atleast_1d(series)
396
+
397
+ if series.size == 0 or np.all(series < 0):
398
+ logger.debug(f"No series found for {attr_name}, skipping.")
399
+ objs = []
400
+ elif series.size == 1:
401
+ logger.debug(f"Single series found for {attr_name}: {series.item()}.")
402
+ if pre:
403
+ objs = self.all[series.item()]
404
+ else:
405
+ objs = [self.all[series.item()]]
406
+ else:
407
+ logger.debug(f"Multiple series found for {attr_name}: {series}.")
408
+ objs = list(itemgetter(*series)(self.all))
409
+ setattr(self, attr_name, objs)
410
+
411
+ # Set normalisation factor based on pre-injection MRS series if available
412
+ self.normalisation_factor = np.max(self.pre_MRS.avg_fid)
413
+ self.injection_time = self.washin.scan_datetime
414
+ if len(self.post_MRS) > 0:
415
+ self.last_MRS = self.post_MRS[-1]
416
+ else:
417
+ logger.warning("No post-injection MRS series found. Double check exam.")
418
+ if len(self.post_MRSI) > 0:
419
+ self.last_MRSI = self.post_MRSI[-1]
420
+ else:
421
+ logger.warning("No post-injection MRSI series found. Double check exam.")
422
+
423
+ def _split_series(
424
+ self, series: npt.NDArray, reference_series: int, series_type: str
425
+ ) -> tuple[int, npt.NDArray]:
426
+ logger.debug(
427
+ f"Splitting {series_type} series into pre- and post-injection based on "
428
+ f"reference series {reference_series} for series list: {series}."
429
+ )
430
+ pre_series_idx = np.where(series < reference_series)[0]
431
+
432
+ if pre_series_idx.size == 0:
433
+ logger.debug(f"No pre-{series_type} series found before the reference series.")
434
+ pre_series = -1
435
+ elif pre_series_idx.size == 1:
436
+ pre_series = int(series[pre_series_idx].item())
437
+ else:
438
+ logger.debug(
439
+ f"Multiple pre-{series_type} series found before the reference series. "
440
+ f"Selecting the last one as pre-injection series."
441
+ )
442
+ last_pre_idx = pre_series_idx[-1]
443
+ pre_series = int(series[last_pre_idx])
444
+
445
+ post_series_idx = np.where(series > reference_series)[0]
446
+ if post_series_idx.size == 0:
447
+ logger.warning(f"No post-{series_type} series found after the reference series.")
448
+ post_series = np.array([], dtype=int)
449
+ else:
450
+ post_series = series[post_series_idx]
451
+
452
+ return pre_series, post_series
453
+
454
+ def plot_MRS_over_time(
455
+ self, phase_params: dict | None = None, plot_params: dict | None = None
456
+ ) -> None:
457
+ """Plot phased MRS spectra from all series over time relative to injection.
458
+
459
+ Parameters
460
+ ----------
461
+ phase_params : dict, optional
462
+ Keyword arguments passed to each series' ``phase_avg_spec``.
463
+ plot_params : dict, optional
464
+ Keyword arguments passed to ``plot_spectra_over_time``.
465
+ """
466
+ plot_kwargs = plot_params if plot_params is not None else {}
467
+ phase_kwargs = phase_params if phase_params is not None else {}
468
+
469
+ inj_start = self.washin.scan_datetime
470
+ inj_start_str = inj_start.strftime("%H:%M")
471
+ title = f"Phased spectra over time\n(Injection at {inj_start_str})"
472
+
473
+ labels = []
474
+ phased_spectra = []
475
+ for mrs_obj in self.all_MRS:
476
+ # phase_avg_spec returns a single chemical_shift-indexed DataArray
477
+ phased_spec = mrs_obj.phase_avg_spec(**phase_kwargs)
478
+ phased_spectra.append(phased_spec.assign_coords(Spectra_ID=mrs_obj.SERIES_ID))
479
+ # Set labels
480
+ scan_time_str = mrs_obj.scan_time.strftime("%H:%M")
481
+ time_delta_min = (mrs_obj.scan_datetime - inj_start).total_seconds() / 60
482
+ labels.append(f"{scan_time_str}\n({time_delta_min:.0f} min)")
483
+
484
+ combined = xr.concat(phased_spectra, dim="Spectra_ID")
485
+ melted_spectra_df = (
486
+ combined.rename("Intensity")
487
+ .to_dataframe()
488
+ .reset_index()
489
+ .rename(columns={"chemical_shift": "ppm"})
490
+ .sort_values("Spectra_ID", ascending=True)
491
+ )
492
+
493
+ plotting.spectra.plot_spectra_over_time(
494
+ melted_spectra_df, labels, title=title, **plot_kwargs
495
+ )