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
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
|
+
)
|