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/GESeries.py
ADDED
|
@@ -0,0 +1,1578 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime, time, timedelta
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import h5py
|
|
8
|
+
import nibabel as nib
|
|
9
|
+
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import pyAMARES
|
|
13
|
+
import xarray as xr
|
|
14
|
+
import xmris # noqa: F401, necessary for xarray accessor to work
|
|
15
|
+
from loguru import logger
|
|
16
|
+
from nibabel import nifti1, spatialimages
|
|
17
|
+
|
|
18
|
+
from . import fitting, plotting
|
|
19
|
+
from .utils import data_loaders, file_helpers, nifti, spectra
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NiiBase:
|
|
23
|
+
"""Wraps a NIfTI image with orientation-aware loading, display, and I/O helpers."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
nii: spatialimages.SpatialImage | Path,
|
|
28
|
+
orientation: tuple[str, str, str] = ("L", "P", "S"),
|
|
29
|
+
) -> None:
|
|
30
|
+
if isinstance(nii, Path):
|
|
31
|
+
self.nii_path = nii
|
|
32
|
+
nii = nifti1.load(nii)
|
|
33
|
+
else:
|
|
34
|
+
self.nii_path = None
|
|
35
|
+
self.nii: spatialimages.SpatialImage = nii
|
|
36
|
+
self.orientation = orientation
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def affine(self) -> np.ndarray:
|
|
40
|
+
"""The affine matrix of the NIfTI image."""
|
|
41
|
+
if self.nii.affine is not None:
|
|
42
|
+
return self.nii.affine
|
|
43
|
+
else:
|
|
44
|
+
raise ValueError("Affine matrix of the NIfTI image is None.")
|
|
45
|
+
|
|
46
|
+
def resample_self_to(
|
|
47
|
+
self, target_nii: spatialimages.SpatialImage | NiiBase, **kwargs
|
|
48
|
+
) -> NiiBase:
|
|
49
|
+
"""Return a new NiiBase with this image resampled onto target_nii's grid."""
|
|
50
|
+
# Create new object of with resampled data
|
|
51
|
+
if isinstance(target_nii, NiiBase):
|
|
52
|
+
target_nii = target_nii.nii
|
|
53
|
+
resampled_nii = nifti.resample_nifti(self.nii, target_nii, **kwargs)
|
|
54
|
+
return NiiBase(resampled_nii, orientation=self.orientation)
|
|
55
|
+
|
|
56
|
+
def images(
|
|
57
|
+
self,
|
|
58
|
+
orientation: tuple[str, str, str] | None = None,
|
|
59
|
+
display_plane: nifti.DISPLAY_PLANES | None = None,
|
|
60
|
+
caching: bool = True,
|
|
61
|
+
**kwargs,
|
|
62
|
+
) -> npt.NDArray[np.float64]:
|
|
63
|
+
"""Return the image data array, oriented per orientation or display_plane."""
|
|
64
|
+
if orientation is None and display_plane is None:
|
|
65
|
+
orientation = self.orientation
|
|
66
|
+
return nifti.orient_nifti(
|
|
67
|
+
self.nii,
|
|
68
|
+
orientation=orientation,
|
|
69
|
+
display_plane=display_plane,
|
|
70
|
+
caching=caching,
|
|
71
|
+
**kwargs,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def with_new_data(
|
|
75
|
+
self,
|
|
76
|
+
new_data: npt.NDArray | xr.DataArray,
|
|
77
|
+
new_affine: npt.NDArray | None = None,
|
|
78
|
+
) -> NiiBase:
|
|
79
|
+
"""Return a new NiiBase wrapping new_data, keeping this image's header/affine."""
|
|
80
|
+
if new_affine is None and new_data.shape != self.nii.shape:
|
|
81
|
+
# A change in shape is okay if the voxel sizes are different. Trust the
|
|
82
|
+
# user that a correct affine is provided
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"New data shape {new_data.shape} does not match original "
|
|
85
|
+
f"NIfTI shape {self.nii.shape}."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if new_affine is not None:
|
|
89
|
+
affine = new_affine
|
|
90
|
+
else:
|
|
91
|
+
if self.nii.affine is None:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"Original NIfTI affine is None, cannot create new NIfTI with new data."
|
|
94
|
+
"\nPass new_affine argument to specify affine for new NIfTI."
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
affine = self.nii.affine
|
|
98
|
+
|
|
99
|
+
# Image data is to be oriented the following
|
|
100
|
+
orientated_data = nib.orientations.apply_orientation(
|
|
101
|
+
new_data, nib.orientations.axcodes2ornt(("P", "L", "S"))
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
new_nii = self.nii.__class__(
|
|
105
|
+
orientated_data, # pyright: ignore[reportArgumentType]
|
|
106
|
+
affine=affine.copy(),
|
|
107
|
+
header=self.nii.header,
|
|
108
|
+
extra=self.nii.extra,
|
|
109
|
+
file_map=self.nii.file_map,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
new_nii.set_data_dtype(new_data.dtype)
|
|
113
|
+
|
|
114
|
+
return NiiBase(new_nii, orientation=self.orientation)
|
|
115
|
+
|
|
116
|
+
def apply_mask(self, mask: npt.NDArray | NiiBase) -> npt.NDArray:
|
|
117
|
+
"""Return the image data with voxels outside mask set to NaN."""
|
|
118
|
+
images = self.images()
|
|
119
|
+
if isinstance(mask, NiiBase):
|
|
120
|
+
mask = mask.images()
|
|
121
|
+
if mask.shape != images.shape:
|
|
122
|
+
raise ValueError(f"Mask shape {mask.shape} does not match image shape {images.shape}.")
|
|
123
|
+
masked_images = np.where(mask, images, np.nan)
|
|
124
|
+
return masked_images
|
|
125
|
+
|
|
126
|
+
def display(
|
|
127
|
+
self,
|
|
128
|
+
orientation: tuple[str, str, str] | None = None,
|
|
129
|
+
display_plane: nifti.DISPLAY_PLANES | None = None,
|
|
130
|
+
mask: npt.NDArray | NiiBase | None = None,
|
|
131
|
+
**kwargs,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Display the image, optionally masked, via plotting.images.display_images.
|
|
134
|
+
|
|
135
|
+
Displays an interactive image-grid widget and returns nothing.
|
|
136
|
+
"""
|
|
137
|
+
# Check if orientation or display_plane is provided in kwargs and orient images
|
|
138
|
+
logger.debug(
|
|
139
|
+
f"Displaying NIfTI with orientation {orientation} and display plane {display_plane}."
|
|
140
|
+
)
|
|
141
|
+
images = self.images(orientation=orientation, display_plane=display_plane)
|
|
142
|
+
# If mask is provided, check if it has same shape as images and apply mask to images
|
|
143
|
+
if mask is not None:
|
|
144
|
+
try:
|
|
145
|
+
images = self.apply_mask(mask)
|
|
146
|
+
except ValueError as e:
|
|
147
|
+
logger.error(f"Error applying mask: {e}. Displaying unmasked images.")
|
|
148
|
+
|
|
149
|
+
plotting.images.display_images(images, **kwargs)
|
|
150
|
+
|
|
151
|
+
def overlay_on_T1(
|
|
152
|
+
self,
|
|
153
|
+
t1: NiiBase | spatialimages.SpatialImage | npt.NDArray,
|
|
154
|
+
resample_kwargs: dict | None = None,
|
|
155
|
+
orientation: tuple[str, str, str] | None = None,
|
|
156
|
+
display_plane: nifti.DISPLAY_PLANES | None = None,
|
|
157
|
+
mask: npt.NDArray | NiiBase | None = None,
|
|
158
|
+
**kwargs,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Overlay this image on a T1 image, resampling and masking as needed."""
|
|
161
|
+
if resample_kwargs is None:
|
|
162
|
+
resample_kwargs = {}
|
|
163
|
+
if orientation is None and display_plane is None:
|
|
164
|
+
orientation = self.orientation
|
|
165
|
+
if isinstance(t1, np.ndarray):
|
|
166
|
+
if mask is not None and isinstance(mask, NiiBase):
|
|
167
|
+
mask = mask.images(orientation=orientation, display_plane=display_plane).astype(
|
|
168
|
+
bool
|
|
169
|
+
)
|
|
170
|
+
plotting.images.overlay_image_data_on_T1(
|
|
171
|
+
t1,
|
|
172
|
+
self.images(orientation=orientation, display_plane=display_plane),
|
|
173
|
+
mask=mask,
|
|
174
|
+
**kwargs,
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
if isinstance(t1, NiiBase):
|
|
178
|
+
t1 = t1.nii
|
|
179
|
+
if isinstance(t1, (NiiBase, spatialimages.SpatialImage)):
|
|
180
|
+
plotting.images.overlay_nifti_data_on_T1(
|
|
181
|
+
t1,
|
|
182
|
+
self.nii,
|
|
183
|
+
orientation=orientation,
|
|
184
|
+
display_plane=display_plane,
|
|
185
|
+
resample_kwargs=resample_kwargs,
|
|
186
|
+
mask=mask,
|
|
187
|
+
**kwargs,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
raise TypeError("t1 must be either a NIfTI image or a numpy array.")
|
|
191
|
+
|
|
192
|
+
def get_brain_mask(
|
|
193
|
+
self,
|
|
194
|
+
create: bool = False,
|
|
195
|
+
mask_keyword: str = "_bet.nii",
|
|
196
|
+
**extract_kwargs,
|
|
197
|
+
) -> NiiBase:
|
|
198
|
+
"""Find (or optionally create) the brain mask NIfTI next to this image.
|
|
199
|
+
|
|
200
|
+
Looks for a brain mask NIfTI (matching ``mask_keyword``) in the same
|
|
201
|
+
folder as ``self.nii_path``. If none is found and ``create`` is True, a
|
|
202
|
+
mask is generated with HD-BET via :func:`utils.images.extract_brain` and
|
|
203
|
+
loaded. The mask is returned as its own :class:`NiiBase`.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
create : bool, optional
|
|
208
|
+
If True, run HD-BET brain extraction when no mask is found.
|
|
209
|
+
mask_keyword : str, optional
|
|
210
|
+
Substring identifying the brain mask NIfTI file.
|
|
211
|
+
**extract_kwargs
|
|
212
|
+
Forwarded to ``utils.images.extract_brain``.
|
|
213
|
+
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
NiiBase
|
|
217
|
+
The brain mask wrapped as a NiiBase.
|
|
218
|
+
|
|
219
|
+
Raises
|
|
220
|
+
------
|
|
221
|
+
ValueError
|
|
222
|
+
If this NIfTI was not loaded from a path (no folder to search).
|
|
223
|
+
FileNotFoundError
|
|
224
|
+
If no mask is found and ``create`` is False.
|
|
225
|
+
"""
|
|
226
|
+
if self.nii_path is None:
|
|
227
|
+
raise ValueError("Cannot locate a brain mask: this NIfTI was not loaded from a path.")
|
|
228
|
+
folder = self.nii_path.parent
|
|
229
|
+
mask_path = file_helpers.get_nifti_file(
|
|
230
|
+
folder,
|
|
231
|
+
filter_out_keywords=[],
|
|
232
|
+
filter_in_keywords=[mask_keyword],
|
|
233
|
+
)
|
|
234
|
+
if mask_path is None:
|
|
235
|
+
if not create:
|
|
236
|
+
raise FileNotFoundError(
|
|
237
|
+
f"No brain mask (matching '{mask_keyword}') found in {folder}. "
|
|
238
|
+
"Pass create=True to generate one with HD-BET."
|
|
239
|
+
)
|
|
240
|
+
# Local import avoids the images <-> GESeries circular import and
|
|
241
|
+
# defers the heavy torch/HD-BET import until a mask must be created.
|
|
242
|
+
from .utils import images
|
|
243
|
+
|
|
244
|
+
logger.info(f"No brain mask found in {folder}. Creating one with HD-BET.")
|
|
245
|
+
extract_kwargs.setdefault("save_mask", True)
|
|
246
|
+
extract_kwargs.setdefault("extract_brain", False)
|
|
247
|
+
mask_path = images.extract_brain(self.nii_path, **extract_kwargs)
|
|
248
|
+
|
|
249
|
+
return NiiBase(mask_path, orientation=self.orientation)
|
|
250
|
+
|
|
251
|
+
@cached_property
|
|
252
|
+
def brain_mask(self) -> NiiBase:
|
|
253
|
+
"""The brain mask as a NiiBase, loaded if present else created on access."""
|
|
254
|
+
return self.get_brain_mask(create=True)
|
|
255
|
+
|
|
256
|
+
def save(self, output_path: Path) -> None:
|
|
257
|
+
"""Save the NIfTI image to output_path as a Nifti1Image."""
|
|
258
|
+
if self.nii_path is not None:
|
|
259
|
+
logger.warning(
|
|
260
|
+
f"NIfTI was originally loaded from {self.nii_path}. "
|
|
261
|
+
f"Saving to a new location {output_path}."
|
|
262
|
+
)
|
|
263
|
+
# Create directory if it does not exist
|
|
264
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
file_helpers.move_files_with_glob(output_path.parent, f"*{output_path.stem}*")
|
|
266
|
+
|
|
267
|
+
# To correctly store them in often required Nifti1 format, we need to
|
|
268
|
+
# convert them to Nifti1Image before saving
|
|
269
|
+
nifti1.Nifti1Image(
|
|
270
|
+
self.nii.dataobj,
|
|
271
|
+
self.nii.affine,
|
|
272
|
+
self.nii.header,
|
|
273
|
+
self.nii.extra,
|
|
274
|
+
self.nii.file_map,
|
|
275
|
+
).to_filename(output_path)
|
|
276
|
+
logger.info(f"Saved NIfTI to {output_path}.")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class MRISeries(NiiBase):
|
|
280
|
+
"""A reconstructed MRI series, loaded as a NIfTI from a scan's DATA_FOLDER."""
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
DATA_FOLDER: Path,
|
|
285
|
+
SERIES_ID: int,
|
|
286
|
+
orientation: tuple[str, str, str] = ("L", "P", "S"),
|
|
287
|
+
) -> None:
|
|
288
|
+
self.DATA_FOLDER = DATA_FOLDER
|
|
289
|
+
self.OUTPUT_FOLDER = (DATA_FOLDER / ".." / "output").resolve()
|
|
290
|
+
self.SERIES_ID = SERIES_ID
|
|
291
|
+
try:
|
|
292
|
+
nii_path: Path = file_helpers.get_niftis_from_series(
|
|
293
|
+
self.DATA_FOLDER, self.SERIES_ID, convert_dicoms=True
|
|
294
|
+
)
|
|
295
|
+
super().__init__(nii_path, orientation)
|
|
296
|
+
except (ValueError, FileNotFoundError) as e:
|
|
297
|
+
logger.warning(
|
|
298
|
+
f"Could not create or find NIfTIs for series {self.SERIES_ID}. "
|
|
299
|
+
f"Proceeding without initializing NiiBase.\nError: {e}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class RawMRISeries(MRISeries):
|
|
304
|
+
"""An MRI series with the associated raw reconstructed .mat data loaded."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
|
|
307
|
+
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
308
|
+
self.mat_path: Path = file_helpers.get_mat_data_from_series(
|
|
309
|
+
self.DATA_FOLDER, self.SERIES_ID
|
|
310
|
+
)
|
|
311
|
+
self.recon: dict = data_loaders.load_mat_file(self.mat_path)
|
|
312
|
+
self.header: dict = self.recon.get("h", {})
|
|
313
|
+
self.bb = self.recon.get("bb", np.array([]))
|
|
314
|
+
self.bbabs = self.recon.get("bbabs", np.abs(self.bb))
|
|
315
|
+
|
|
316
|
+
@cached_property
|
|
317
|
+
def fids(self) -> xr.DataArray:
|
|
318
|
+
"""The raw FIDs, unchopped and conjugated, as a DataArray over id and time."""
|
|
319
|
+
fids, _ = data_loaders.load_raw_fids(self.DATA_FOLDER, self.SERIES_ID)
|
|
320
|
+
fids = fids.copy()
|
|
321
|
+
if self.fids_are_chopped:
|
|
322
|
+
fids[1::2, :] *= -1
|
|
323
|
+
fids = np.conj(fids)
|
|
324
|
+
return xr.DataArray(
|
|
325
|
+
fids,
|
|
326
|
+
dims=["id", "time"],
|
|
327
|
+
coords={"time": np.arange(fids.shape[1]) * self.dwelltime},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def fids_are_chopped(self) -> bool:
|
|
332
|
+
"""Whether the FIDs are chopped or not."""
|
|
333
|
+
value = self._get_header_value(["rdb_hdr", "data_collect_type"])
|
|
334
|
+
if isinstance(value, float):
|
|
335
|
+
is_chopped = int(value) % 2 == 0
|
|
336
|
+
return is_chopped
|
|
337
|
+
else:
|
|
338
|
+
logger.error(
|
|
339
|
+
"Value for data_collect_type in header does not have correct type. Returning False."
|
|
340
|
+
)
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def pfile_number(self) -> int:
|
|
345
|
+
"""The P-file number of the scan."""
|
|
346
|
+
value = self._get_header_value(["rdb_hdr", "run_int"])
|
|
347
|
+
if isinstance(value, float):
|
|
348
|
+
return int(value)
|
|
349
|
+
else:
|
|
350
|
+
return -1
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def protocol_name(self) -> str:
|
|
354
|
+
"""The protocol name of the scan."""
|
|
355
|
+
value = self._get_header_value(["series", "prtcl"])
|
|
356
|
+
if isinstance(value, str):
|
|
357
|
+
return str(value)
|
|
358
|
+
else:
|
|
359
|
+
return ""
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def series_name(self) -> str:
|
|
363
|
+
"""The series name of the scan."""
|
|
364
|
+
value = self._get_header_value(["series", "se_desc"])
|
|
365
|
+
if isinstance(value, str):
|
|
366
|
+
return str(value)
|
|
367
|
+
else:
|
|
368
|
+
return ""
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def exam_number(self) -> int:
|
|
372
|
+
"""The exam number of the scan."""
|
|
373
|
+
value = self._get_header_value(["exam", "ex_no"])
|
|
374
|
+
if isinstance(value, float):
|
|
375
|
+
return int(value)
|
|
376
|
+
else:
|
|
377
|
+
return -1
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def scan_datetime(self) -> datetime:
|
|
381
|
+
"""The scan datetime."""
|
|
382
|
+
date = self._get_header_value(["rdb_hdr", "scan_date"])
|
|
383
|
+
time = self._get_header_value(["rdb_hdr", "scan_time"])
|
|
384
|
+
if isinstance(date, str) and isinstance(time, str):
|
|
385
|
+
datetime_str = f"{date} {time}"
|
|
386
|
+
return datetime.strptime(datetime_str, "%m/%d/1%y %H:%M")
|
|
387
|
+
else:
|
|
388
|
+
return datetime.max
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def scan_date(self) -> date:
|
|
392
|
+
"""The scan date."""
|
|
393
|
+
return self.scan_datetime.date()
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def scan_time(self) -> time:
|
|
397
|
+
"""The scan time."""
|
|
398
|
+
return self.scan_datetime.time()
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def transients(self) -> float:
|
|
402
|
+
"""The number of transients."""
|
|
403
|
+
value = self._get_header_value(["rdb_hdr", "nframes"])
|
|
404
|
+
if isinstance(value, (int, float)):
|
|
405
|
+
return float(value)
|
|
406
|
+
else:
|
|
407
|
+
return np.nan
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def bandwidth(self) -> float:
|
|
411
|
+
"""The bandwidth of the scan (Hz)."""
|
|
412
|
+
value = self._get_header_value(["rdb_hdr", "user0"])
|
|
413
|
+
if isinstance(value, (int, float)):
|
|
414
|
+
return float(value)
|
|
415
|
+
else:
|
|
416
|
+
return np.nan
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def dwelltime(self) -> float:
|
|
420
|
+
"""The dwell time of the scan (s, 1/Hz)."""
|
|
421
|
+
return 1 / self.bandwidth
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def deadtime(self) -> float:
|
|
425
|
+
"""The dead time is assumed to be a single echo time in seconds."""
|
|
426
|
+
return self.echo_time / 1e6 # Convert from microseconds to seconds
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def centre_frequency(self) -> float:
|
|
430
|
+
"""The centre frequency of the scan (MHz)."""
|
|
431
|
+
value = self._get_header_value(["rdb_hdr", "ps_mps_freq"])
|
|
432
|
+
if isinstance(value, (int, float)):
|
|
433
|
+
return float(value) / 1e7 # For some reason stored in Hz x10
|
|
434
|
+
else:
|
|
435
|
+
return np.nan
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def nucleus(self) -> int:
|
|
439
|
+
"""The nucleus of the scan as integer (e.g. 1 for 1H, 13 for 13C)."""
|
|
440
|
+
value = self._get_header_value(["image", "specnuc"])
|
|
441
|
+
# Try casting to int if not possible return 0
|
|
442
|
+
try:
|
|
443
|
+
return int(value) # pyright: ignore[reportArgumentType]
|
|
444
|
+
except (ValueError, TypeError):
|
|
445
|
+
logger.error(f"Failed to convert nucleus value '{value}' to integer.")
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def carrier_ppm(self) -> float:
|
|
450
|
+
"""The carrier ppm of the scan centre frequency (ppm). Based on nucleus."""
|
|
451
|
+
if self.nucleus in [1, 2]:
|
|
452
|
+
return 4.68
|
|
453
|
+
else:
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def echo_time(self) -> float:
|
|
458
|
+
"""The echo time (TE) of the scan (us)."""
|
|
459
|
+
value = self._get_header_value(["rdb_hdr", "te"])
|
|
460
|
+
if isinstance(value, (int, float)):
|
|
461
|
+
return value
|
|
462
|
+
else:
|
|
463
|
+
return np.nan
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def field_of_view(self) -> float:
|
|
467
|
+
"""The field of view (FOV) of the scan (mm)."""
|
|
468
|
+
value = self._get_header_value(["rdb_hdr", "fov"])
|
|
469
|
+
if isinstance(value, (int, float)):
|
|
470
|
+
return float(value)
|
|
471
|
+
else:
|
|
472
|
+
return np.nan
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def flip_angle(self) -> float:
|
|
476
|
+
"""The flip angle of the scan (in degrees)."""
|
|
477
|
+
value = self._get_header_value(["image", "mr_flip"])
|
|
478
|
+
if isinstance(value, (int, float)):
|
|
479
|
+
return float(value)
|
|
480
|
+
else:
|
|
481
|
+
return np.nan
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def inversion_time(self) -> float:
|
|
485
|
+
"""The inversion time (TI) of the scan (us)."""
|
|
486
|
+
value = self._get_header_value(["rdb_hdr", "user25"])
|
|
487
|
+
if isinstance(value, float):
|
|
488
|
+
return float(value)
|
|
489
|
+
else:
|
|
490
|
+
return np.nan
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def number_of_excitations(self) -> int:
|
|
494
|
+
"""The number of excitations (NEX) of the scan."""
|
|
495
|
+
value = self._get_header_value(["image", "nex"])
|
|
496
|
+
if isinstance(value, float):
|
|
497
|
+
return int(value)
|
|
498
|
+
else:
|
|
499
|
+
return -1
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def repetition_time(self) -> float:
|
|
503
|
+
"""The repetition time (TR) of the scan (ms)."""
|
|
504
|
+
value = self._get_header_value(["image", "tr"])
|
|
505
|
+
if isinstance(value, float):
|
|
506
|
+
return float(value) / 1e3
|
|
507
|
+
else:
|
|
508
|
+
return np.nan
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def rf_pulse_type(self) -> int:
|
|
512
|
+
"""The RF pulse type used in the scan."""
|
|
513
|
+
value = self._get_header_value(["rdb_hdr", "user14"])
|
|
514
|
+
if isinstance(value, float):
|
|
515
|
+
return int(value)
|
|
516
|
+
else:
|
|
517
|
+
return -1
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def slice_thickness(self) -> float:
|
|
521
|
+
"""The slice thickness of the scan (mm)."""
|
|
522
|
+
value = self._get_header_value(["image", "slthick"])
|
|
523
|
+
if isinstance(value, float):
|
|
524
|
+
return float(value)
|
|
525
|
+
else:
|
|
526
|
+
return np.nan
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def scan_duration(self) -> timedelta:
|
|
530
|
+
"""The scan duration."""
|
|
531
|
+
value = self._get_header_value(["image", "sctime"])
|
|
532
|
+
if isinstance(value, float):
|
|
533
|
+
return timedelta(microseconds=value)
|
|
534
|
+
else:
|
|
535
|
+
raise ValueError("Scan duration not found in header.")
|
|
536
|
+
|
|
537
|
+
@property
|
|
538
|
+
def reconstruction_used(self) -> int:
|
|
539
|
+
"""The reconstruction method used."""
|
|
540
|
+
value = self._get_header_value(["rdb_hdr", "recon"])
|
|
541
|
+
if isinstance(value, float):
|
|
542
|
+
return int(value)
|
|
543
|
+
else:
|
|
544
|
+
return -1
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def waveform_type_scan_mode(self) -> float:
|
|
548
|
+
"""The waveform type or scan mode."""
|
|
549
|
+
value = self._get_header_value(["rdb_hdr", "user3"])
|
|
550
|
+
if isinstance(value, float):
|
|
551
|
+
return float(value)
|
|
552
|
+
else:
|
|
553
|
+
return np.nan
|
|
554
|
+
|
|
555
|
+
@property
|
|
556
|
+
def receive_gain_1(self) -> float:
|
|
557
|
+
"""The first receive gain."""
|
|
558
|
+
value = self._get_header_value(["rdb_hdr", "ps_mps_r1"])
|
|
559
|
+
if isinstance(value, float):
|
|
560
|
+
return float(value)
|
|
561
|
+
else:
|
|
562
|
+
return np.nan
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def receive_gain_2(self) -> float:
|
|
566
|
+
"""The second receive gain."""
|
|
567
|
+
value = self._get_header_value(["rdb_hdr", "ps_mps_r2"])
|
|
568
|
+
if isinstance(value, float):
|
|
569
|
+
return float(value)
|
|
570
|
+
else:
|
|
571
|
+
return np.nan
|
|
572
|
+
|
|
573
|
+
@property
|
|
574
|
+
def transmit_gain(self) -> int:
|
|
575
|
+
"""The transmit gain (TG)."""
|
|
576
|
+
value = self._get_header_value(["rdb_hdr", "ps_mps_tg"])
|
|
577
|
+
if isinstance(value, float):
|
|
578
|
+
return int(value)
|
|
579
|
+
else:
|
|
580
|
+
return -1
|
|
581
|
+
|
|
582
|
+
@property
|
|
583
|
+
def field_strength(self) -> float:
|
|
584
|
+
"""The field strength of the scan (T)."""
|
|
585
|
+
value = self._get_header_value(["mrconfig", "fieldStrength"])
|
|
586
|
+
if isinstance(value, float):
|
|
587
|
+
return float(value) / 10000
|
|
588
|
+
else:
|
|
589
|
+
return np.nan
|
|
590
|
+
|
|
591
|
+
def _get_header_value(self, key_path: list[str]) -> None | str | np.double | npt.NDArray:
|
|
592
|
+
"""Traverse the header dictionary using a key path.
|
|
593
|
+
|
|
594
|
+
Raises AttributeError if the path is invalid.
|
|
595
|
+
"""
|
|
596
|
+
value = self.header
|
|
597
|
+
try:
|
|
598
|
+
for key in key_path:
|
|
599
|
+
value = value[key]
|
|
600
|
+
# Depending on the data type, return the appropriate value
|
|
601
|
+
# If string
|
|
602
|
+
if value is None:
|
|
603
|
+
raise KeyError
|
|
604
|
+
elif isinstance(value, str):
|
|
605
|
+
return value
|
|
606
|
+
elif isinstance(value, (np.ndarray, np.number, int, float)):
|
|
607
|
+
value = np.asarray(value)
|
|
608
|
+
if value.size == 1:
|
|
609
|
+
return np.double(value.item())
|
|
610
|
+
else:
|
|
611
|
+
return value.flatten()
|
|
612
|
+
# Any other type is treated as a missing/unsupported value.
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
except (TypeError, KeyError):
|
|
616
|
+
raise AttributeError(
|
|
617
|
+
f"Mapped attribute not found. Expected path: {' -> '.join(key_path)}"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class MRSISeries(RawMRISeries):
|
|
622
|
+
"""A raw MRSI series: spectroscopic imaging spectra, fitting, and voxel maps."""
|
|
623
|
+
|
|
624
|
+
def __init__(
|
|
625
|
+
self, DATA_FOLDER: Path, SERIES_ID: int, load_processed_data: bool = False
|
|
626
|
+
) -> None:
|
|
627
|
+
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
628
|
+
# Expect spec and header to be present
|
|
629
|
+
spec_data = self.recon.get("spec", np.ndarray([]))
|
|
630
|
+
if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
|
|
631
|
+
logger.error(
|
|
632
|
+
"Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
|
|
633
|
+
)
|
|
634
|
+
else:
|
|
635
|
+
self.spec: xr.DataArray = xr.DataArray(
|
|
636
|
+
spec_data,
|
|
637
|
+
dims=["chemical_shift", "i", "j", "k"],
|
|
638
|
+
coords={
|
|
639
|
+
"chemical_shift": spectra.calculate_ppm_axis(
|
|
640
|
+
spectral_width=self.bandwidth,
|
|
641
|
+
frequency=self.centre_frequency,
|
|
642
|
+
carrier_ppm=self.carrier_ppm,
|
|
643
|
+
npts=spec_data.shape[0],
|
|
644
|
+
),
|
|
645
|
+
"i": np.arange(spec_data.shape[1]),
|
|
646
|
+
"j": np.arange(spec_data.shape[2]),
|
|
647
|
+
"k": np.arange(spec_data.shape[3]),
|
|
648
|
+
},
|
|
649
|
+
attrs={
|
|
650
|
+
"field_strength": self.field_strength,
|
|
651
|
+
"reference_frequency": self.centre_frequency,
|
|
652
|
+
"carrier_ppm": self.carrier_ppm,
|
|
653
|
+
"spectral_width": self.bandwidth,
|
|
654
|
+
"deadtime": self.deadtime,
|
|
655
|
+
},
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
self.dims: tuple = self.spec.shape[1:4]
|
|
659
|
+
self.npts: int = self.spec.shape[0]
|
|
660
|
+
self.ppm: xr.DataArray = self.spec.coords["chemical_shift"]
|
|
661
|
+
self.RAW_exp: NiiBase = self.create_MRSI_nii()
|
|
662
|
+
# Update attributes of spec
|
|
663
|
+
self.spec.attrs.update(
|
|
664
|
+
{
|
|
665
|
+
"affine": self.RAW_exp.affine.tolist(),
|
|
666
|
+
"orientation": self.RAW_exp.orientation,
|
|
667
|
+
}
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
self.fitted_metabolite_maps: dict = {}
|
|
671
|
+
self.goodness_of_fit_maps: dict = {}
|
|
672
|
+
if load_processed_data:
|
|
673
|
+
self.load_processed_data()
|
|
674
|
+
|
|
675
|
+
@cached_property
|
|
676
|
+
def spec_flat(self) -> xr.DataArray:
|
|
677
|
+
"""The spectra with the voxel dimensions stacked into a single voxel dim."""
|
|
678
|
+
return self.spec.stack(voxel=self.spec.dims[1:4])
|
|
679
|
+
|
|
680
|
+
@cached_property
|
|
681
|
+
def fids_flat(self) -> xr.DataArray:
|
|
682
|
+
"""The FIDs with the voxel dimensions stacked into a single voxel dim."""
|
|
683
|
+
return self.fids.stack(voxel=self.fids.dims[1:4])
|
|
684
|
+
|
|
685
|
+
@cached_property
|
|
686
|
+
def fids(self) -> xr.DataArray:
|
|
687
|
+
"""The FIDs, derived from the spectra via FFT."""
|
|
688
|
+
return self.spec.xmr.to_hz().xmr.to_fid()
|
|
689
|
+
|
|
690
|
+
@cached_property
|
|
691
|
+
def avg_fid(self) -> xr.DataArray:
|
|
692
|
+
"""Average fid across voxels with SNR above threshold."""
|
|
693
|
+
return self.fids.where(self.get_SNR_mask()).mean(["i", "j", "k"], skipna=True)
|
|
694
|
+
|
|
695
|
+
@cached_property
|
|
696
|
+
def SNR_map(self) -> xr.DataArray:
|
|
697
|
+
"""The per-voxel SNR, computed from the FIDs via pyAMARES.fidSNR."""
|
|
698
|
+
return xr.apply_ufunc(
|
|
699
|
+
pyAMARES.fidSNR, self.fids, input_core_dims=[["time"]], vectorize=True
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
@property
|
|
703
|
+
def voxel_size(self) -> float:
|
|
704
|
+
"""The in-plane voxel size (mm), derived from the field of view and matrix."""
|
|
705
|
+
mtx = self.dims[0:2]
|
|
706
|
+
# Assert that mtx is the same in x and y
|
|
707
|
+
if mtx[0] != mtx[1]:
|
|
708
|
+
logger.warning(
|
|
709
|
+
f"Matrix size in x and y are not the same: {mtx[0]} vs {mtx[1]}. "
|
|
710
|
+
"Using x size for voxel size calculation."
|
|
711
|
+
)
|
|
712
|
+
return self.field_of_view / mtx[0]
|
|
713
|
+
|
|
714
|
+
@cached_property
|
|
715
|
+
def default_SNR_mask(self) -> xr.DataArray:
|
|
716
|
+
"""The default SNR mask, computed with get_SNR_mask's default threshold."""
|
|
717
|
+
return self.get_SNR_mask()
|
|
718
|
+
|
|
719
|
+
def get_SNR_mask(self, threshold=1.5) -> xr.DataArray:
|
|
720
|
+
"""Return a boolean mask of voxels with SNR at or above threshold."""
|
|
721
|
+
return self.SNR_map >= threshold
|
|
722
|
+
|
|
723
|
+
def create_MRSI_affine(self) -> npt.NDArray:
|
|
724
|
+
"""Return an affine matrix rescaled to the MRSI voxel size and offset."""
|
|
725
|
+
# Create new affine matrix based on original nifti affine and mat voxel size
|
|
726
|
+
# This assumes the same orientation and slice thickness and spacing
|
|
727
|
+
|
|
728
|
+
if self.nii.affine is None:
|
|
729
|
+
raise ValueError("The affine matrix of the NIfTI image is None.")
|
|
730
|
+
|
|
731
|
+
# Check if the original affine has uniform scaling in x and y
|
|
732
|
+
if not np.isclose(self.nii.affine[0, 0], self.nii.affine[1, 1]):
|
|
733
|
+
logger.warning(
|
|
734
|
+
"Original affine matrix has non-uniform scaling in x and y: "
|
|
735
|
+
f"{self.nii.affine[0, 0]} vs {self.nii.affine[1, 1]}. "
|
|
736
|
+
"Using x scaling for new affine."
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
interpolated_voxel_sizes = self.nii.affine[0, 0]
|
|
740
|
+
scaling_factor = self.voxel_size / interpolated_voxel_sizes
|
|
741
|
+
|
|
742
|
+
new_affine = np.copy(self.nii.affine)
|
|
743
|
+
new_affine[0, 0] *= scaling_factor
|
|
744
|
+
new_affine[1, 1] *= scaling_factor
|
|
745
|
+
# Round voxel sizes to 3 decimal places to avoid floating point issues
|
|
746
|
+
new_affine[0:3, 0:3] = np.round(new_affine[0:3, 0:3], 3)
|
|
747
|
+
|
|
748
|
+
# Offset x and y by centre offset
|
|
749
|
+
# The shift is important as we want to make sure the overlay is in the correct position
|
|
750
|
+
# The correct shift is the following
|
|
751
|
+
shift = (np.diag(new_affine[:3]) - np.diag(self.nii.affine[:3])).round(3) / 2
|
|
752
|
+
# However this creates the correct FOV but the overlay is not in the correct position
|
|
753
|
+
# For a correct shift we need to add an additonal half voxel
|
|
754
|
+
shift += np.array([new_affine[0, 0], new_affine[1, 1], 0]) / 2
|
|
755
|
+
logger.trace(
|
|
756
|
+
f"Calculated shift for new affine: {shift}. "
|
|
757
|
+
f"Original affine translation: {self.nii.affine[:3, 3]}"
|
|
758
|
+
)
|
|
759
|
+
new_affine[:3, 3] += shift
|
|
760
|
+
|
|
761
|
+
return new_affine
|
|
762
|
+
|
|
763
|
+
def create_MRSI_nii(self) -> NiiBase:
|
|
764
|
+
"""Return the RAW magnitude image as a NiiBase, with the MRSI affine."""
|
|
765
|
+
magnitude_data = np.sum(np.abs(self.spec), axis=0).astype(np.float32)
|
|
766
|
+
return self.with_new_data(new_data=magnitude_data, new_affine=self.create_MRSI_affine())
|
|
767
|
+
|
|
768
|
+
def create_map_nii(self, map_data: npt.NDArray) -> NiiBase:
|
|
769
|
+
"""Wrap a per-voxel map array as a NiiBase, with the MRSI affine."""
|
|
770
|
+
# Make sure map_data has correct shape
|
|
771
|
+
if map_data.shape != self.dims:
|
|
772
|
+
raise ValueError(
|
|
773
|
+
f"map_data shape {map_data.shape} does not match expected dimensions {self.dims}."
|
|
774
|
+
)
|
|
775
|
+
return self.with_new_data(new_data=map_data, new_affine=self.create_MRSI_affine())
|
|
776
|
+
|
|
777
|
+
def get_voxel_spectrum(self, x: int, y: int, slice: int) -> xr.DataArray:
|
|
778
|
+
"""Return the spectrum at voxel (x, y, slice)."""
|
|
779
|
+
logger.debug(
|
|
780
|
+
f"Assuming voxel coordinates (x, y, slice) = ({x}, {y}, {slice}) "
|
|
781
|
+
"are in correct order and within bounds."
|
|
782
|
+
)
|
|
783
|
+
return self.spec.sel(i=x, j=y, k=slice)
|
|
784
|
+
|
|
785
|
+
def fit_single_voxel(
|
|
786
|
+
self,
|
|
787
|
+
x: int,
|
|
788
|
+
y: int,
|
|
789
|
+
slice: int,
|
|
790
|
+
init_params: dict,
|
|
791
|
+
fit_params: dict | None = None,
|
|
792
|
+
):
|
|
793
|
+
"""Fit the FID at voxel (x, y, slice) with AMARES."""
|
|
794
|
+
if fit_params is None:
|
|
795
|
+
fit_params = {}
|
|
796
|
+
fid = self.fids.sel(i=x, j=y, k=slice)
|
|
797
|
+
|
|
798
|
+
# Include series information in init_params
|
|
799
|
+
passed_init_params = {
|
|
800
|
+
"MHz": self.centre_frequency,
|
|
801
|
+
"sw": self.bandwidth,
|
|
802
|
+
"deadtime": self.deadtime,
|
|
803
|
+
}
|
|
804
|
+
passed_init_params.update(init_params)
|
|
805
|
+
|
|
806
|
+
fit_result = fitting.AMARES.fit_single_fid(
|
|
807
|
+
fid,
|
|
808
|
+
passed_init_params,
|
|
809
|
+
fit_params,
|
|
810
|
+
)
|
|
811
|
+
return fit_result
|
|
812
|
+
|
|
813
|
+
def fit_all_voxels(
|
|
814
|
+
self,
|
|
815
|
+
init_params: dict,
|
|
816
|
+
batch_fitting_params: dict | None = None,
|
|
817
|
+
bad_fit_chisqr_threshold: float = -1.0,
|
|
818
|
+
testing_mode: bool = False,
|
|
819
|
+
) -> npt.NDArray:
|
|
820
|
+
"""Fit all voxels above the SNR threshold with AMARES and store the maps.
|
|
821
|
+
|
|
822
|
+
Parameters
|
|
823
|
+
----------
|
|
824
|
+
init_params : dict
|
|
825
|
+
Initial fit parameters, merged with series-derived MHz/sw/deadtime.
|
|
826
|
+
batch_fitting_params : dict, optional
|
|
827
|
+
Forwarded to ``fitting.AMARES.fit_multiple_fids``.
|
|
828
|
+
bad_fit_chisqr_threshold : float, optional
|
|
829
|
+
Chi-squared threshold above which a fit is flagged as bad. Defaults to -1.0
|
|
830
|
+
(disabled).
|
|
831
|
+
testing_mode : bool, optional
|
|
832
|
+
If True, limit fitting to the first 100 voxels above the SNR threshold.
|
|
833
|
+
|
|
834
|
+
Returns
|
|
835
|
+
-------
|
|
836
|
+
numpy.ndarray
|
|
837
|
+
Flattened voxel indices of fits flagged as bad.
|
|
838
|
+
"""
|
|
839
|
+
if batch_fitting_params is None:
|
|
840
|
+
batch_fitting_params = {}
|
|
841
|
+
SNR_mask = self.get_SNR_mask()
|
|
842
|
+
# If in testing mode, limit to first 100 voxels with SNR above threshold
|
|
843
|
+
if testing_mode:
|
|
844
|
+
flat_SNR_mask = SNR_mask.stack(voxel=SNR_mask.dims)
|
|
845
|
+
indices = np.where(flat_SNR_mask)[0][:100]
|
|
846
|
+
SNR_mask = xr.full_like(flat_SNR_mask, False)
|
|
847
|
+
SNR_mask[indices] = True
|
|
848
|
+
SNR_mask = SNR_mask.unstack("voxel")
|
|
849
|
+
|
|
850
|
+
# Include series information in init_params
|
|
851
|
+
passed_init_params = {
|
|
852
|
+
"MHz": self.centre_frequency,
|
|
853
|
+
"sw": self.bandwidth,
|
|
854
|
+
"deadtime": self.deadtime,
|
|
855
|
+
}
|
|
856
|
+
passed_init_params.update(init_params)
|
|
857
|
+
|
|
858
|
+
results = fitting.AMARES.fit_multiple_fids(
|
|
859
|
+
self.fids.where(SNR_mask).stack(voxel=SNR_mask.dims).dropna("voxel"),
|
|
860
|
+
batch_fitting_params=batch_fitting_params,
|
|
861
|
+
init_params=passed_init_params,
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Extract results back into original shape
|
|
865
|
+
extracted_results = fitting.AMARES.extract_from_fit_results(
|
|
866
|
+
results, bad_fit_chisqr_threshold
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# Save extracted results into processing data folder with additional attributes
|
|
870
|
+
additional_attributes: dict[str, str | int] = {
|
|
871
|
+
"SERIES_ID": self.SERIES_ID,
|
|
872
|
+
"exam_number": self.exam_number,
|
|
873
|
+
"ACQUISITION_TIME": self.scan_datetime.isoformat(),
|
|
874
|
+
"SAVED_ON": datetime.now().isoformat(),
|
|
875
|
+
}
|
|
876
|
+
additional_datasets = {
|
|
877
|
+
"SNR_mask": SNR_mask.values,
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
fitting.AMARES.save_extracted_results(
|
|
881
|
+
self.OUTPUT_FOLDER
|
|
882
|
+
/ "processing_data"
|
|
883
|
+
/ "dev"
|
|
884
|
+
/ f"Series{self.SERIES_ID:02d}_extracted_results.h5",
|
|
885
|
+
extracted_results,
|
|
886
|
+
additional_attributes,
|
|
887
|
+
additional_datasets=additional_datasets,
|
|
888
|
+
prepend_datetime=True,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
combined_fit_results = extracted_results.combined_fit_results
|
|
892
|
+
fitted_metabolites_temp = np.full((*self.dims, combined_fit_results.columns.size), np.nan)
|
|
893
|
+
fitted_metabolites_temp[SNR_mask] = combined_fit_results.loc[
|
|
894
|
+
(slice(None), "amplitude", "value"), :
|
|
895
|
+
].to_numpy()
|
|
896
|
+
for i, metabolite in enumerate(combined_fit_results.columns):
|
|
897
|
+
self.fitted_metabolite_maps[metabolite] = fitted_metabolites_temp[:, :, :, i]
|
|
898
|
+
|
|
899
|
+
goodness_of_fit = extracted_results.goodness_of_fit
|
|
900
|
+
gof_metrics_temp = np.full((*self.dims, goodness_of_fit.columns.size), np.nan)
|
|
901
|
+
gof_metrics_temp[SNR_mask] = goodness_of_fit.to_numpy()
|
|
902
|
+
for i, metric in enumerate(goodness_of_fit.columns):
|
|
903
|
+
self.goodness_of_fit_maps[metric] = gof_metrics_temp[:, :, :, i]
|
|
904
|
+
|
|
905
|
+
# The badfit_ids correspond to the flattened indices of the fitted voxels
|
|
906
|
+
# Create mapping from badfit_ids to original voxel flattened indices
|
|
907
|
+
if extracted_results.badfit_ids.size == 0:
|
|
908
|
+
original_badfit_ids = np.array([], dtype=int)
|
|
909
|
+
else:
|
|
910
|
+
original_badfit_ids = np.where(SNR_mask.flatten())[0][extracted_results.badfit_ids]
|
|
911
|
+
|
|
912
|
+
return original_badfit_ids
|
|
913
|
+
|
|
914
|
+
def save_processed_data(self, output_folder: Path | None = None) -> Path:
|
|
915
|
+
"""Save fitted maps, FIDs, and identifying attributes to an HDF5 file.
|
|
916
|
+
|
|
917
|
+
Parameters
|
|
918
|
+
----------
|
|
919
|
+
output_folder : Path, optional
|
|
920
|
+
Directory to save into. Defaults to ``OUTPUT_FOLDER / "processing_data"``.
|
|
921
|
+
|
|
922
|
+
Returns
|
|
923
|
+
-------
|
|
924
|
+
Path
|
|
925
|
+
Path to the saved (or, if a file already exists, the existing) HDF5 file.
|
|
926
|
+
|
|
927
|
+
Raises
|
|
928
|
+
------
|
|
929
|
+
OSError
|
|
930
|
+
If the saved file fails the post-save integrity check.
|
|
931
|
+
"""
|
|
932
|
+
attrs_to_save = [
|
|
933
|
+
"SERIES_ID", # Necessary to identify series later, do not remove
|
|
934
|
+
"exam_number", # Necessary to identify series later, do not remove
|
|
935
|
+
]
|
|
936
|
+
|
|
937
|
+
data_to_save = [
|
|
938
|
+
"fids",
|
|
939
|
+
"avg_fid",
|
|
940
|
+
"SNR_map",
|
|
941
|
+
"default_SNR_mask",
|
|
942
|
+
]
|
|
943
|
+
|
|
944
|
+
# Save results to output folder / processing_data
|
|
945
|
+
if output_folder is None:
|
|
946
|
+
output_folder = self.OUTPUT_FOLDER / "processing_data"
|
|
947
|
+
output_folder.mkdir(exist_ok=True, parents=True)
|
|
948
|
+
|
|
949
|
+
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
950
|
+
filename = f"Series{self.SERIES_ID:02d}_processing_data.h5"
|
|
951
|
+
|
|
952
|
+
# Move previous runs to old folder. Making it easier to determine which is the latest
|
|
953
|
+
file_helpers.move_files_with_glob(output_folder, f"*{filename}*")
|
|
954
|
+
|
|
955
|
+
filename = f"{date_str}_{filename}"
|
|
956
|
+
full_path = output_folder / filename
|
|
957
|
+
|
|
958
|
+
# If file exists, log warning and return
|
|
959
|
+
if full_path.exists():
|
|
960
|
+
logger.warning(
|
|
961
|
+
f"File {full_path} already exists. Not overwriting. Returning existing file path."
|
|
962
|
+
)
|
|
963
|
+
return full_path
|
|
964
|
+
|
|
965
|
+
with h5py.File(full_path, "a") as f:
|
|
966
|
+
# Save fitted metabolite maps
|
|
967
|
+
fitted_data_group = f.create_group("fitted_data")
|
|
968
|
+
for metabolite, metabolite_map in self.fitted_metabolite_maps.items():
|
|
969
|
+
logger.debug(f"Saving metabolite map for {metabolite}")
|
|
970
|
+
fitted_data_group.create_dataset(
|
|
971
|
+
metabolite, data=metabolite_map, dtype=metabolite_map.dtype
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
# Save goodness of fit metrics
|
|
975
|
+
gof_group = fitted_data_group.create_group("goodness_of_fit")
|
|
976
|
+
for goodness_metric, metric_map in self.goodness_of_fit_maps.items():
|
|
977
|
+
logger.debug(f"Saving goodness of fit metric map for {goodness_metric}")
|
|
978
|
+
gof_group.create_dataset(goodness_metric, data=metric_map, dtype=metric_map.dtype)
|
|
979
|
+
|
|
980
|
+
# Save acquisition time of series in HDF5 in readable format
|
|
981
|
+
f.attrs["ACQUISITION_TIME"] = self.scan_datetime.strftime("%Y-%m-%d_%H:%M:%S")
|
|
982
|
+
f.attrs["saved_on"] = datetime.now().isoformat()
|
|
983
|
+
for attr in attrs_to_save:
|
|
984
|
+
value = getattr(self, attr)
|
|
985
|
+
logger.debug(f"Saving attribute {attr} with value {value}")
|
|
986
|
+
f.attrs[attr] = value
|
|
987
|
+
|
|
988
|
+
# Save additional attributes
|
|
989
|
+
for attr in data_to_save:
|
|
990
|
+
data = getattr(self, attr)
|
|
991
|
+
logger.debug(f"Saving attribute {attr}")
|
|
992
|
+
if hasattr(data, "dtype"):
|
|
993
|
+
f.create_dataset(attr, data=data, dtype=data.dtype)
|
|
994
|
+
else:
|
|
995
|
+
f.create_dataset(attr, data=data)
|
|
996
|
+
|
|
997
|
+
logger.info(f"Saved processed data to {'/'.join(full_path.parts[-4:])}")
|
|
998
|
+
logger.debug("Checking saved data.")
|
|
999
|
+
is_okay = self._check_saved_data(full_path)
|
|
1000
|
+
if not is_okay:
|
|
1001
|
+
logger.error("Saved data did not pass the integrity check! Renaming file!!")
|
|
1002
|
+
full_path.rename(full_path.with_suffix(".corrupt"))
|
|
1003
|
+
raise OSError(
|
|
1004
|
+
"Saved data did not pass the integrity check! "
|
|
1005
|
+
"Debug this to make sure data is saved correctly."
|
|
1006
|
+
)
|
|
1007
|
+
else:
|
|
1008
|
+
logger.debug("Saved data passed the integrity check.")
|
|
1009
|
+
|
|
1010
|
+
return full_path
|
|
1011
|
+
|
|
1012
|
+
def load_processed_data(
|
|
1013
|
+
self, output_folder: Path | None = None, force_load: bool = False
|
|
1014
|
+
) -> bool:
|
|
1015
|
+
"""Load the most recent processed-data HDF5 file for this series.
|
|
1016
|
+
|
|
1017
|
+
Parameters
|
|
1018
|
+
----------
|
|
1019
|
+
output_folder : Path, optional
|
|
1020
|
+
Directory to search in. Defaults to ``OUTPUT_FOLDER / "processing_data"``.
|
|
1021
|
+
force_load : bool, optional
|
|
1022
|
+
If True, load the data even if SERIES_ID, exam_number, or acquisition
|
|
1023
|
+
time do not match this series.
|
|
1024
|
+
|
|
1025
|
+
Returns
|
|
1026
|
+
-------
|
|
1027
|
+
bool
|
|
1028
|
+
True if data was loaded successfully, False otherwise.
|
|
1029
|
+
"""
|
|
1030
|
+
if output_folder is None:
|
|
1031
|
+
output_folder = self.OUTPUT_FOLDER / "processing_data"
|
|
1032
|
+
|
|
1033
|
+
try:
|
|
1034
|
+
old_processed_data = file_helpers.get_latest_processing_file(
|
|
1035
|
+
output_folder, self.SERIES_ID
|
|
1036
|
+
)
|
|
1037
|
+
except FileNotFoundError:
|
|
1038
|
+
logger.warning(
|
|
1039
|
+
f"No processed data file found in directory {output_folder}. "
|
|
1040
|
+
"Returning without loading."
|
|
1041
|
+
)
|
|
1042
|
+
return False
|
|
1043
|
+
with h5py.File(old_processed_data, "r") as f:
|
|
1044
|
+
# Do some sanity checks, check Series id, exam_number and acquisition time
|
|
1045
|
+
try:
|
|
1046
|
+
same_series_id = f.attrs["SERIES_ID"] == self.SERIES_ID # type: ignore
|
|
1047
|
+
same_exam_number = f.attrs["exam_number"] == self.exam_number # type: ignore
|
|
1048
|
+
same_acq_time = (
|
|
1049
|
+
datetime.strptime(
|
|
1050
|
+
f.attrs["ACQUISITION_TIME"], # type: ignore
|
|
1051
|
+
"%Y-%m-%d_%H:%M:%S",
|
|
1052
|
+
)
|
|
1053
|
+
== self.scan_datetime
|
|
1054
|
+
)
|
|
1055
|
+
logger.debug(
|
|
1056
|
+
f"Series ID match: {same_series_id}, "
|
|
1057
|
+
f"Exam number match: {same_exam_number}, "
|
|
1058
|
+
f"Acquisition time match: {same_acq_time}"
|
|
1059
|
+
)
|
|
1060
|
+
if not all([same_series_id, same_exam_number, same_acq_time]):
|
|
1061
|
+
logger.warning("Mismatch found in saved attributes! Not loading data")
|
|
1062
|
+
if force_load:
|
|
1063
|
+
logger.warning("Force loading data despite mismatches.")
|
|
1064
|
+
else:
|
|
1065
|
+
raise ValueError("Cannot load data due to mismatches in attributes.")
|
|
1066
|
+
except KeyError as e:
|
|
1067
|
+
logger.error(
|
|
1068
|
+
f"KeyError: {e}. The following keys must be present in the "
|
|
1069
|
+
"file: 'SERIES_ID', 'exam_number', 'acquisition_time'."
|
|
1070
|
+
)
|
|
1071
|
+
logger.error(f"Tree of the HDF5 file:\n{file_helpers.print_hdf5_tree(f)}")
|
|
1072
|
+
return False
|
|
1073
|
+
|
|
1074
|
+
# Load fitted metabolite maps
|
|
1075
|
+
self.fitted_metabolite_maps = {}
|
|
1076
|
+
self.goodness_of_fit_maps = {}
|
|
1077
|
+
try:
|
|
1078
|
+
fitted_data_group = f["fitted_data"]
|
|
1079
|
+
if isinstance(fitted_data_group, h5py.Group):
|
|
1080
|
+
for key in fitted_data_group.keys():
|
|
1081
|
+
data = fitted_data_group[key]
|
|
1082
|
+
if isinstance(data, h5py.Dataset):
|
|
1083
|
+
metabolite_map = data[:]
|
|
1084
|
+
self.fitted_metabolite_maps[key] = metabolite_map
|
|
1085
|
+
logger.debug(f"Loaded metabolite map for {key}")
|
|
1086
|
+
logger.debug(
|
|
1087
|
+
f" dtype: {metabolite_map.dtype}, shape: {metabolite_map.shape}"
|
|
1088
|
+
)
|
|
1089
|
+
logger.debug(f" sum: {np.nansum(metabolite_map)}")
|
|
1090
|
+
if isinstance(data, h5py.Group):
|
|
1091
|
+
logger.debug(f"Loaded group for {key}")
|
|
1092
|
+
for subkey in data.keys():
|
|
1093
|
+
subdata = data[subkey]
|
|
1094
|
+
if isinstance(subdata, h5py.Dataset):
|
|
1095
|
+
subdata = subdata[:]
|
|
1096
|
+
self.goodness_of_fit_maps[subkey] = subdata
|
|
1097
|
+
logger.debug(f" Loaded dataset for {subkey}")
|
|
1098
|
+
logger.debug(
|
|
1099
|
+
f" dtype: {subdata.dtype}, shape: {subdata.shape}"
|
|
1100
|
+
)
|
|
1101
|
+
logger.debug(f" sum: {np.nansum(subdata)}")
|
|
1102
|
+
except KeyError as e:
|
|
1103
|
+
logger.error(f"KeyError: {e}. The expected keys may not be present in the file.")
|
|
1104
|
+
logger.error(f"Tree of the HDF5 file:\n{file_helpers.print_hdf5_tree(f)}")
|
|
1105
|
+
|
|
1106
|
+
# Check if fitted_metabolite_maps and goodness_of_fit_maps are loaded
|
|
1107
|
+
if not self.fitted_metabolite_maps:
|
|
1108
|
+
logger.warning(
|
|
1109
|
+
"No fitted metabolite maps were loaded. Something went wrong! "
|
|
1110
|
+
"Datasets probably not stored correctly in hdf5 file."
|
|
1111
|
+
)
|
|
1112
|
+
if not self.goodness_of_fit_maps:
|
|
1113
|
+
logger.warning(
|
|
1114
|
+
"No goodness of fit maps were loaded. Something went wrong! "
|
|
1115
|
+
"Datasets probably not stored correctly in hdf5 file."
|
|
1116
|
+
)
|
|
1117
|
+
return True
|
|
1118
|
+
|
|
1119
|
+
def _check_saved_data(self, filename: Path) -> bool:
|
|
1120
|
+
"""Check if the saved data file is valid.
|
|
1121
|
+
|
|
1122
|
+
Validates that the file is an HDF5 file, contains maps in the 'fitted_data'
|
|
1123
|
+
group, and ensures the maps are nonzero in size and have 3D dimensions.
|
|
1124
|
+
|
|
1125
|
+
Parameters
|
|
1126
|
+
----------
|
|
1127
|
+
filename : Path
|
|
1128
|
+
The path to the file to check.
|
|
1129
|
+
|
|
1130
|
+
Returns
|
|
1131
|
+
-------
|
|
1132
|
+
bool
|
|
1133
|
+
True if the file passes all checks, False otherwise.
|
|
1134
|
+
"""
|
|
1135
|
+
if not filename.exists():
|
|
1136
|
+
logger.error(f"File {filename} does not exist.")
|
|
1137
|
+
return False
|
|
1138
|
+
|
|
1139
|
+
if not h5py.is_hdf5(filename):
|
|
1140
|
+
logger.error(f"File {filename} is not a valid HDF5 file.")
|
|
1141
|
+
return False
|
|
1142
|
+
|
|
1143
|
+
try:
|
|
1144
|
+
with h5py.File(filename, "r") as f:
|
|
1145
|
+
if "fitted_data" not in f:
|
|
1146
|
+
logger.error(f"'fitted_data' group not found in {filename}.")
|
|
1147
|
+
return False
|
|
1148
|
+
|
|
1149
|
+
fitted_data_group = f["fitted_data"]
|
|
1150
|
+
if not isinstance(fitted_data_group, h5py.Group):
|
|
1151
|
+
logger.error(f"'fitted_data' is not a group in {filename}.")
|
|
1152
|
+
return False
|
|
1153
|
+
|
|
1154
|
+
# Ensure 'fitted_data' group has at least one key
|
|
1155
|
+
if not any(
|
|
1156
|
+
isinstance(fitted_data_group[key], h5py.Dataset)
|
|
1157
|
+
for key in fitted_data_group.keys()
|
|
1158
|
+
):
|
|
1159
|
+
logger.error(f"'fitted_data' group in {filename} has no datasets.")
|
|
1160
|
+
return False
|
|
1161
|
+
|
|
1162
|
+
# Ensure 'goodness_of_fit' group has at least one key
|
|
1163
|
+
if "goodness_of_fit" in fitted_data_group:
|
|
1164
|
+
gof_group = fitted_data_group["goodness_of_fit"]
|
|
1165
|
+
if isinstance(gof_group, h5py.Group) and len(gof_group.keys()) == 0:
|
|
1166
|
+
logger.error(f"'goodness_of_fit' group in {filename} has no keys.")
|
|
1167
|
+
return False
|
|
1168
|
+
|
|
1169
|
+
for key in fitted_data_group.keys():
|
|
1170
|
+
dataset = fitted_data_group[key]
|
|
1171
|
+
if isinstance(dataset, h5py.Dataset):
|
|
1172
|
+
data = dataset[:]
|
|
1173
|
+
if data.size == 0:
|
|
1174
|
+
logger.error(
|
|
1175
|
+
f"Dataset '{key}' in 'fitted_data' group is empty in {filename}."
|
|
1176
|
+
)
|
|
1177
|
+
return False
|
|
1178
|
+
if data.ndim != 3:
|
|
1179
|
+
logger.error(
|
|
1180
|
+
f"Dataset '{key}' in 'fitted_data' group is not 3D in {filename}."
|
|
1181
|
+
)
|
|
1182
|
+
return False
|
|
1183
|
+
elif isinstance(dataset, h5py.Group):
|
|
1184
|
+
for subkey in dataset.keys():
|
|
1185
|
+
subdataset = dataset[subkey]
|
|
1186
|
+
if isinstance(subdataset, h5py.Dataset):
|
|
1187
|
+
subdata = subdataset[:]
|
|
1188
|
+
if subdata.size == 0:
|
|
1189
|
+
logger.error(
|
|
1190
|
+
f"Sub-dataset '{subkey}' in '{key}' group "
|
|
1191
|
+
f"is empty in {filename}."
|
|
1192
|
+
)
|
|
1193
|
+
return False
|
|
1194
|
+
if subdata.ndim != 3:
|
|
1195
|
+
logger.error(
|
|
1196
|
+
f"Sub-dataset '{subkey}' in '{key}' group "
|
|
1197
|
+
f"is not 3D in {filename}."
|
|
1198
|
+
)
|
|
1199
|
+
return False
|
|
1200
|
+
else:
|
|
1201
|
+
logger.error(
|
|
1202
|
+
f"Unexpected data type in 'fitted_data' group for "
|
|
1203
|
+
f"key '{key}' in {filename}."
|
|
1204
|
+
)
|
|
1205
|
+
return False
|
|
1206
|
+
|
|
1207
|
+
# Additional check: Ensure acquisition time is present and valid
|
|
1208
|
+
if "ACQUISITION_TIME" not in f.attrs:
|
|
1209
|
+
logger.error(f"'ACQUISITION_TIME' attribute not found in {filename}.")
|
|
1210
|
+
return False
|
|
1211
|
+
try:
|
|
1212
|
+
acq_time_str = f.attrs["ACQUISITION_TIME"]
|
|
1213
|
+
datetime.strptime(str(acq_time_str), "%Y-%m-%d_%H:%M:%S")
|
|
1214
|
+
logger.debug(f"'ACQUISITION_TIME' in {filename} is valid: {acq_time_str}")
|
|
1215
|
+
except (TypeError, ValueError) as e:
|
|
1216
|
+
logger.error(f"Invalid 'ACQUISITION_TIME' format in {filename}: {e}")
|
|
1217
|
+
return False
|
|
1218
|
+
|
|
1219
|
+
except (OSError, KeyError, TypeError, ValueError) as e:
|
|
1220
|
+
logger.error(f"Error while checking file {filename}: {e}")
|
|
1221
|
+
return False
|
|
1222
|
+
|
|
1223
|
+
logger.debug(f"File {'/'.join(filename.parts[-4:])} passed all checks.")
|
|
1224
|
+
return True
|
|
1225
|
+
|
|
1226
|
+
def visualize_fitted_metabolite_map(
|
|
1227
|
+
self,
|
|
1228
|
+
metabolite_name: str,
|
|
1229
|
+
**kwargs,
|
|
1230
|
+
):
|
|
1231
|
+
"""Display the fitted metabolite map for metabolite_name."""
|
|
1232
|
+
plotting.images.display_images(
|
|
1233
|
+
self.fitted_metabolite_maps[metabolite_name],
|
|
1234
|
+
title=f"Fitted Metabolite Map: {metabolite_name}",
|
|
1235
|
+
colorbar=True,
|
|
1236
|
+
**kwargs,
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
def visualize_goodness_of_fit_map(
|
|
1240
|
+
self,
|
|
1241
|
+
metric_name: str,
|
|
1242
|
+
**kwargs,
|
|
1243
|
+
):
|
|
1244
|
+
"""Display the goodness-of-fit map for metric_name."""
|
|
1245
|
+
plotting.images.display_images(
|
|
1246
|
+
self.goodness_of_fit_maps[metric_name],
|
|
1247
|
+
title=f"Goodness of Fit Map: {metric_name}",
|
|
1248
|
+
colorbar=True,
|
|
1249
|
+
**kwargs,
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
def visualize_fitted_values(self):
|
|
1253
|
+
"""Not yet implemented."""
|
|
1254
|
+
...
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
class MRSSeries(RawMRISeries):
|
|
1258
|
+
"""A single-voxel MRS series: FIDs, spectra, averaging, and fitting."""
|
|
1259
|
+
|
|
1260
|
+
def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
|
|
1261
|
+
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
1262
|
+
spec_data = self.recon.get("spec", np.ndarray([]))
|
|
1263
|
+
if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
|
|
1264
|
+
logger.error(
|
|
1265
|
+
"Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
|
|
1266
|
+
)
|
|
1267
|
+
else:
|
|
1268
|
+
# This assumes no zero filling was applied yet
|
|
1269
|
+
self.npts: int = spec_data.shape[1]
|
|
1270
|
+
self.spec: xr.DataArray = xr.DataArray(
|
|
1271
|
+
spec_data,
|
|
1272
|
+
dims=["averages", "chemical_shift"],
|
|
1273
|
+
coords={
|
|
1274
|
+
"averages": np.arange(spec_data.shape[0]),
|
|
1275
|
+
"chemical_shift": spectra.calculate_ppm_axis(
|
|
1276
|
+
spectral_width=self.bandwidth,
|
|
1277
|
+
frequency=self.centre_frequency,
|
|
1278
|
+
carrier_ppm=self.carrier_ppm,
|
|
1279
|
+
npts=spec_data.shape[1],
|
|
1280
|
+
),
|
|
1281
|
+
},
|
|
1282
|
+
attrs={
|
|
1283
|
+
"field_strength": self.field_strength,
|
|
1284
|
+
"reference_frequency": self.centre_frequency,
|
|
1285
|
+
"carrier_ppm": self.carrier_ppm,
|
|
1286
|
+
"units": "a.u.",
|
|
1287
|
+
"spectral_width": self.bandwidth,
|
|
1288
|
+
"deadtime": self.deadtime,
|
|
1289
|
+
},
|
|
1290
|
+
)
|
|
1291
|
+
self.averages: int = self.spec.sizes["averages"]
|
|
1292
|
+
self.ppm: xr.DataArray = self.spec.coords["chemical_shift"]
|
|
1293
|
+
|
|
1294
|
+
@cached_property
|
|
1295
|
+
def fids(self) -> xr.DataArray:
|
|
1296
|
+
"""The FIDs from RawMRISeries, with the id coordinate renamed to averages."""
|
|
1297
|
+
# Return the fids from RawMRISeries but rename coordinate id to averages
|
|
1298
|
+
super().fids # Ensure fids is cached in RawMRISeries before renaming
|
|
1299
|
+
fids = super().fids.rename({"id": "averages"})
|
|
1300
|
+
return fids.assign_attrs(**self.spec.attrs)
|
|
1301
|
+
|
|
1302
|
+
@cached_property
|
|
1303
|
+
def avg_fid(self) -> xr.DataArray:
|
|
1304
|
+
"""The FID averaged across all averages."""
|
|
1305
|
+
return self.fids.mean("averages").assign_attrs(**self.spec.attrs)
|
|
1306
|
+
|
|
1307
|
+
@cached_property
|
|
1308
|
+
def avg_spec(self) -> xr.DataArray:
|
|
1309
|
+
"""The spectrum averaged across all averages."""
|
|
1310
|
+
# WARNING: fid to spectrum has different scaling than avg spec!! this is
|
|
1311
|
+
# probably due to scaling in the mat data
|
|
1312
|
+
logger.trace(
|
|
1313
|
+
"Calculating average spectrum from averaged FIDs. Note that this may "
|
|
1314
|
+
"have different scaling than the average of the spectra due to "
|
|
1315
|
+
"scaling in the mat data."
|
|
1316
|
+
)
|
|
1317
|
+
return self.spec.mean("averages").assign_attrs(**self.spec.attrs)
|
|
1318
|
+
|
|
1319
|
+
def group_size_by_duration(self, group_duration: float) -> int:
|
|
1320
|
+
"""Calculate number of consecutive FIDs to average for a target duration.
|
|
1321
|
+
|
|
1322
|
+
Parameters
|
|
1323
|
+
----------
|
|
1324
|
+
group_duration : float
|
|
1325
|
+
Target duration in seconds for each group.
|
|
1326
|
+
|
|
1327
|
+
Returns
|
|
1328
|
+
-------
|
|
1329
|
+
int
|
|
1330
|
+
Number of consecutive FIDs to average together.
|
|
1331
|
+
"""
|
|
1332
|
+
scan_duration_sec = self.scan_duration.total_seconds()
|
|
1333
|
+
time_per_fid = scan_duration_sec / self.averages
|
|
1334
|
+
|
|
1335
|
+
if scan_duration_sec % group_duration != 0:
|
|
1336
|
+
actual_group_duration = (group_duration * self.averages) / scan_duration_sec
|
|
1337
|
+
logger.warning(
|
|
1338
|
+
f"Scan duration {scan_duration_sec:.2f}s is not an exact multiple "
|
|
1339
|
+
f"of group duration {group_duration:.2f}s.\n Actual group "
|
|
1340
|
+
f"duration will be {actual_group_duration:.2f}s."
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
group_size = int(group_duration / time_per_fid)
|
|
1344
|
+
|
|
1345
|
+
return group_size
|
|
1346
|
+
|
|
1347
|
+
def average_fids(self, group_size: int | None = 8) -> xr.DataArray:
|
|
1348
|
+
"""Average FIDs by grouping consecutive acquisitions.
|
|
1349
|
+
|
|
1350
|
+
Parameters
|
|
1351
|
+
----------
|
|
1352
|
+
group_size : int, optional
|
|
1353
|
+
Number of consecutive FIDs to average together. Defaults to 8.
|
|
1354
|
+
|
|
1355
|
+
Returns
|
|
1356
|
+
-------
|
|
1357
|
+
xarray.DataArray
|
|
1358
|
+
Averaged FIDs, shape `(n_groups, n_timepoints)`.
|
|
1359
|
+
"""
|
|
1360
|
+
if group_size is None:
|
|
1361
|
+
group_size = 8
|
|
1362
|
+
# Create bin edges: [0, group_size, 2*group_size, ..., self.averages]
|
|
1363
|
+
bins = np.arange(0, self.averages + group_size, group_size)
|
|
1364
|
+
|
|
1365
|
+
# Group by bins and average across averages dimension
|
|
1366
|
+
return (
|
|
1367
|
+
self.fids.groupby_bins("averages", bins=bins)
|
|
1368
|
+
.mean("averages")
|
|
1369
|
+
.assign_attrs(**self.spec.attrs)
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
def average_spectra(self, group_size: int | None = 8) -> xr.DataArray:
|
|
1373
|
+
"""Average spectra by grouping consecutive acquisitions.
|
|
1374
|
+
|
|
1375
|
+
Parameters
|
|
1376
|
+
----------
|
|
1377
|
+
group_size : int, optional
|
|
1378
|
+
Number of consecutive FIDs to average together. Defaults to 8.
|
|
1379
|
+
|
|
1380
|
+
Returns
|
|
1381
|
+
-------
|
|
1382
|
+
xarray.DataArray
|
|
1383
|
+
Averaged spectra, shape `(n_groups, n_points)`.
|
|
1384
|
+
"""
|
|
1385
|
+
# Average grouped FIDs
|
|
1386
|
+
averaged_fids = self.average_fids(group_size=group_size)
|
|
1387
|
+
|
|
1388
|
+
# Apply FFT along time dimension
|
|
1389
|
+
return averaged_fids.xmr.to_spectrum()
|
|
1390
|
+
|
|
1391
|
+
def average_spectra_by_duration(self, group_duration: int | None = 10) -> xr.DataArray:
|
|
1392
|
+
"""Average spectra by grouping acquisitions within a time duration.
|
|
1393
|
+
|
|
1394
|
+
Parameters
|
|
1395
|
+
----------
|
|
1396
|
+
group_duration : int, optional
|
|
1397
|
+
Duration in seconds for each group. Defaults to 10.
|
|
1398
|
+
|
|
1399
|
+
Returns
|
|
1400
|
+
-------
|
|
1401
|
+
xarray.DataArray
|
|
1402
|
+
Averaged spectra grouped by duration.
|
|
1403
|
+
"""
|
|
1404
|
+
if group_duration is None:
|
|
1405
|
+
group_duration = 10
|
|
1406
|
+
group_size = self.group_size_by_duration(group_duration)
|
|
1407
|
+
return self.average_spectra(group_size=group_size)
|
|
1408
|
+
|
|
1409
|
+
def average_fids_by_duration(self, group_duration: int | None = 10) -> xr.DataArray:
|
|
1410
|
+
"""Average FIDs by grouping acquisitions within a time duration.
|
|
1411
|
+
|
|
1412
|
+
Parameters
|
|
1413
|
+
----------
|
|
1414
|
+
group_duration : int, optional
|
|
1415
|
+
Duration in seconds for each group. Defaults to 10.
|
|
1416
|
+
|
|
1417
|
+
Returns
|
|
1418
|
+
-------
|
|
1419
|
+
xarray.DataArray
|
|
1420
|
+
Averaged FIDs grouped by duration.
|
|
1421
|
+
"""
|
|
1422
|
+
if group_duration is None:
|
|
1423
|
+
group_duration = 10
|
|
1424
|
+
group_size = self.group_size_by_duration(group_duration)
|
|
1425
|
+
return self.average_fids(group_size=group_size)
|
|
1426
|
+
|
|
1427
|
+
def phase_avg_spec(
|
|
1428
|
+
self,
|
|
1429
|
+
zero_order: float = 0.0,
|
|
1430
|
+
first_order: float = 0.0,
|
|
1431
|
+
autophase: bool = False,
|
|
1432
|
+
ppm_min: float = -np.inf,
|
|
1433
|
+
ppm_max: float = np.inf,
|
|
1434
|
+
) -> xr.DataArray:
|
|
1435
|
+
"""Phase (or autophase) the average spectrum, limited to a ppm range."""
|
|
1436
|
+
if autophase:
|
|
1437
|
+
phased_spec = self.spec.xmr.to_hz().xmr.autophase()
|
|
1438
|
+
else:
|
|
1439
|
+
phased_spec = self.spec.xmr.to_hz().xmr.phase(p0=zero_order, p1=first_order)
|
|
1440
|
+
|
|
1441
|
+
return (
|
|
1442
|
+
phased_spec.mean("averages")
|
|
1443
|
+
.assign_attrs(**self.spec.attrs)
|
|
1444
|
+
.xmr.to_ppm()
|
|
1445
|
+
.sel(chemical_shift=slice(ppm_min, ppm_max))
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
def limit_spec_to_ppm_range(self, ppm_min: float = -2, ppm_max: float = 6) -> xr.DataArray:
|
|
1449
|
+
"""Return the spectra limited to the chemical_shift range [ppm_min, ppm_max]."""
|
|
1450
|
+
return self.spec.sel(chemical_shift=slice(ppm_max, ppm_min))
|
|
1451
|
+
|
|
1452
|
+
def fit_average_fid(
|
|
1453
|
+
self,
|
|
1454
|
+
init_params: dict,
|
|
1455
|
+
fit_params: dict | None = None,
|
|
1456
|
+
normalisation_factor: np.complexfloating | None = None,
|
|
1457
|
+
filter_by_ppm: tuple[float, float] | None = None,
|
|
1458
|
+
):
|
|
1459
|
+
"""Fit the averaged FID (avg_fid) with AMARES, normalizing it first."""
|
|
1460
|
+
if fit_params is None:
|
|
1461
|
+
fit_params = {}
|
|
1462
|
+
# Normalize avg_fid
|
|
1463
|
+
if normalisation_factor is None:
|
|
1464
|
+
scaling_factor = np.max(self.avg_fid) * self.number_of_excitations
|
|
1465
|
+
logger.warning(
|
|
1466
|
+
"Using averaged fid as normalisation factor. It is recommended to "
|
|
1467
|
+
"provide a normalisation_factor for consistent scaling across "
|
|
1468
|
+
"series. See debug for more details."
|
|
1469
|
+
)
|
|
1470
|
+
else:
|
|
1471
|
+
scaling_factor = normalisation_factor * self.number_of_excitations
|
|
1472
|
+
logger.debug(
|
|
1473
|
+
f"Scaling the FID by factor {scaling_factor:.4g} for fitting. "
|
|
1474
|
+
f"Number of excitations: {self.number_of_excitations}"
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
avg_fid = self.avg_fid / scaling_factor
|
|
1478
|
+
|
|
1479
|
+
fit_result = fitting.AMARES.fit_single_fid(
|
|
1480
|
+
avg_fid,
|
|
1481
|
+
init_params,
|
|
1482
|
+
fit_params,
|
|
1483
|
+
raw_header=self.header,
|
|
1484
|
+
filter_by_ppm=filter_by_ppm,
|
|
1485
|
+
)
|
|
1486
|
+
return fit_result
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
class MRSWashinSeries(MRSSeries):
|
|
1490
|
+
"""An MRS washin series: repeated spectra fitted and grouped over time."""
|
|
1491
|
+
|
|
1492
|
+
def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
|
|
1493
|
+
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
1494
|
+
self.fit_results: list = []
|
|
1495
|
+
|
|
1496
|
+
@property
|
|
1497
|
+
def extracted_results(self):
|
|
1498
|
+
"""The fit results extracted into a FitResults, or an empty one if unfit."""
|
|
1499
|
+
if not self.fit_results:
|
|
1500
|
+
logger.info(
|
|
1501
|
+
"No fit results present. Please run fit_grouped_by_duration() "
|
|
1502
|
+
"first. Returning empty results."
|
|
1503
|
+
)
|
|
1504
|
+
return fitting.AMARES.FitResults(np.asarray([]), pd.DataFrame(), pd.DataFrame())
|
|
1505
|
+
else:
|
|
1506
|
+
return self.extract_from_fit_results()
|
|
1507
|
+
|
|
1508
|
+
def plot_washin(self, **kwargs):
|
|
1509
|
+
"""Plot the duration-averaged spectra, one line per minute."""
|
|
1510
|
+
averaged_spec = self.average_spectra_by_duration(kwargs.pop("group_duration", None))
|
|
1511
|
+
minute_labels = [f"Min. {i + 1:.0f}" for i in range(averaged_spec.shape[0])]
|
|
1512
|
+
|
|
1513
|
+
# averaged_spectra_per_minute
|
|
1514
|
+
return plotting.spectra.plot_spectra(
|
|
1515
|
+
data=averaged_spec.xmr.to_ppm(), labels=minute_labels, **kwargs
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
def fit_grouped_by_duration(
|
|
1519
|
+
self,
|
|
1520
|
+
init_params: dict,
|
|
1521
|
+
fit_params: dict | None = None,
|
|
1522
|
+
normalisation_factor: np.complexfloating | None = None,
|
|
1523
|
+
group_duration: int = 60,
|
|
1524
|
+
) -> list:
|
|
1525
|
+
"""Average FIDs by duration and fit each group's FID with AMARES."""
|
|
1526
|
+
if fit_params is None:
|
|
1527
|
+
fit_params = {}
|
|
1528
|
+
if normalisation_factor is None:
|
|
1529
|
+
logger.warning(
|
|
1530
|
+
"No normalisation_factor provided. This can lead to different "
|
|
1531
|
+
"scaling between different series."
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
averaged_fids = self.average_fids_by_duration(group_duration=group_duration)
|
|
1535
|
+
if normalisation_factor is None:
|
|
1536
|
+
scaling_factor = np.max(averaged_fids[0, :]) * self.number_of_excitations
|
|
1537
|
+
logger.warning(
|
|
1538
|
+
"Using first averaged fid as normalisation factor. It is "
|
|
1539
|
+
"recommended to provide a normalisation_factor for consistent "
|
|
1540
|
+
"scaling across series. See debug for more details."
|
|
1541
|
+
)
|
|
1542
|
+
else:
|
|
1543
|
+
scaling_factor = normalisation_factor * self.number_of_excitations
|
|
1544
|
+
logger.debug(
|
|
1545
|
+
f"Scaling the FIDs by factor {scaling_factor:.4g} for fitting. "
|
|
1546
|
+
f"Number of excitations: {self.number_of_excitations}"
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
fit_results = []
|
|
1550
|
+
for idx in range(averaged_fids.shape[0]):
|
|
1551
|
+
fid = averaged_fids[idx, :]
|
|
1552
|
+
# Normalize fid
|
|
1553
|
+
fid = fid / scaling_factor
|
|
1554
|
+
|
|
1555
|
+
fit_result = fitting.AMARES.fit_single_fid(
|
|
1556
|
+
fid,
|
|
1557
|
+
init_params,
|
|
1558
|
+
fit_params,
|
|
1559
|
+
raw_header=self.header,
|
|
1560
|
+
)
|
|
1561
|
+
fit_results.append(fit_result)
|
|
1562
|
+
|
|
1563
|
+
self.fit_results = fit_results
|
|
1564
|
+
return fit_results
|
|
1565
|
+
|
|
1566
|
+
def extract_from_fit_results(
|
|
1567
|
+
self, index_key: str = "group_id", index_values: list | None = None
|
|
1568
|
+
) -> fitting.AMARES.FitResults:
|
|
1569
|
+
"""Restructure self.fit_results into a combined FitResults."""
|
|
1570
|
+
restructured_results = []
|
|
1571
|
+
for res in self.fit_results:
|
|
1572
|
+
data = (res.result_multiplets, res.out_obj)
|
|
1573
|
+
restructured_results.append(data)
|
|
1574
|
+
|
|
1575
|
+
extracted_results = fitting.AMARES.extract_from_fit_results(
|
|
1576
|
+
restructured_results, index_key=index_key, index_values=index_values
|
|
1577
|
+
)
|
|
1578
|
+
return extracted_results
|