MNutils 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mnutils/GEExam.py +495 -0
- mnutils/GESeries.py +1578 -0
- mnutils/__init__.py +54 -0
- mnutils/_scripts.py +177 -0
- mnutils/fitting/AMARES.py +831 -0
- mnutils/fitting/__init__.py +5 -0
- mnutils/matlab/__init__.py +13 -0
- mnutils/matlab/setup_matlab.py +116 -0
- mnutils/plotting/__init__.py +38 -0
- mnutils/plotting/_widgets/__init__.py +5 -0
- mnutils/plotting/_widgets/_html.py +34 -0
- mnutils/plotting/_widgets/_shared/__init__.py +50 -0
- mnutils/plotting/_widgets/_shared/dom.js +124 -0
- mnutils/plotting/_widgets/_shared/theme.css +140 -0
- mnutils/plotting/_widgets/image_grid.css +202 -0
- mnutils/plotting/_widgets/image_grid.js +212 -0
- mnutils/plotting/_widgets/image_grid.py +86 -0
- mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
- mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
- mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
- mnutils/plotting/_widgets/slice_viewer.css +38 -0
- mnutils/plotting/_widgets/slice_viewer.js +41 -0
- mnutils/plotting/_widgets/slice_viewer.py +35 -0
- mnutils/plotting/images.py +1399 -0
- mnutils/plotting/saving.py +138 -0
- mnutils/plotting/spectra.py +357 -0
- mnutils/py.typed +0 -0
- mnutils/testing/__init__.py +3 -0
- mnutils/testing/_spectra.py +338 -0
- mnutils/testing/synthetic.py +373 -0
- mnutils/utils/__init__.py +25 -0
- mnutils/utils/data_loaders.py +218 -0
- mnutils/utils/file_helpers.py +708 -0
- mnutils/utils/images.py +202 -0
- mnutils/utils/nifti.py +379 -0
- mnutils/utils/spectra.py +149 -0
- mnutils-1.2.0.dist-info/METADATA +161 -0
- mnutils-1.2.0.dist-info/RECORD +41 -0
- mnutils-1.2.0.dist-info/WHEEL +4 -0
- mnutils-1.2.0.dist-info/entry_points.txt +7 -0
- mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Synthetic GE exam fixtures for docs pages and tests -- no real scanner data required.
|
|
2
|
+
|
|
3
|
+
See docs/diary/2026-08-19-synthetic-exam-fixtures.md for why this exists, what it does and doesn't
|
|
4
|
+
fake, and where the spectra/anatomy come from. `build_fake_exam(dataset)` writes the requested
|
|
5
|
+
dataset under a process-local temp directory (via `tempfile.mkdtemp`) the first time it's called,
|
|
6
|
+
caches each dataset's path for the rest of the process, and registers an `atexit` cleanup so
|
|
7
|
+
nothing survives past the process that created it.
|
|
8
|
+
|
|
9
|
+
`_TEMPLATE_ATTRIBUTION` in `_spectra.py` covers the one piece of real (downloaded, cached) data
|
|
10
|
+
used here -- a CC BY-NC 4.0 T1 template.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import atexit
|
|
14
|
+
import functools
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import h5py
|
|
21
|
+
import nibabel as nib
|
|
22
|
+
import numpy as np
|
|
23
|
+
import scipy.io
|
|
24
|
+
import xmris
|
|
25
|
+
from numpy.typing import ArrayLike
|
|
26
|
+
|
|
27
|
+
from . import _spectra
|
|
28
|
+
|
|
29
|
+
_RNG_SEED = 20260819
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _mat_header(
|
|
33
|
+
*,
|
|
34
|
+
nucleus: float,
|
|
35
|
+
protocol: str,
|
|
36
|
+
description: str,
|
|
37
|
+
exam_number: int,
|
|
38
|
+
bandwidth: float,
|
|
39
|
+
centre_freq_x1e7: float,
|
|
40
|
+
sctime: float = 60_000_000.0,
|
|
41
|
+
fov_mm: float,
|
|
42
|
+
) -> dict:
|
|
43
|
+
"""Build the nested dict `RawMRISeries._get_header_value` walks (`h.rdb_hdr.*` etc.)."""
|
|
44
|
+
return {
|
|
45
|
+
"rdb_hdr": {
|
|
46
|
+
"data_collect_type": 2.0,
|
|
47
|
+
"run_int": 1.0,
|
|
48
|
+
"nframes": 8.0,
|
|
49
|
+
"user0": bandwidth,
|
|
50
|
+
"ps_mps_freq": centre_freq_x1e7,
|
|
51
|
+
"te": 30000.0,
|
|
52
|
+
"fov": fov_mm,
|
|
53
|
+
"user25": 0.0,
|
|
54
|
+
"user14": 0.0,
|
|
55
|
+
"user3": 0.0,
|
|
56
|
+
"ps_mps_r1": 100.0,
|
|
57
|
+
"ps_mps_r2": 100.0,
|
|
58
|
+
"ps_mps_tg": 100.0,
|
|
59
|
+
"recon": 1.0,
|
|
60
|
+
"scan_date": "01/01/25",
|
|
61
|
+
"scan_time": "12:00",
|
|
62
|
+
},
|
|
63
|
+
"series": {"prtcl": protocol, "se_desc": description},
|
|
64
|
+
"exam": {"ex_no": exam_number},
|
|
65
|
+
"image": {
|
|
66
|
+
"specnuc": nucleus,
|
|
67
|
+
"mr_flip": 90.0,
|
|
68
|
+
"nex": 1.0,
|
|
69
|
+
"tr": 2_000_000.0,
|
|
70
|
+
"slthick": 5.0,
|
|
71
|
+
"sctime": sctime,
|
|
72
|
+
},
|
|
73
|
+
"mrconfig": {"fieldStrength": 30_000.0},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Deuterium at ~3T. Kept in one place so the .mat header, the ppm axis GESeries derives from it,
|
|
78
|
+
# and the peaks xmris simulates all agree on what "4.7 ppm" means.
|
|
79
|
+
_NUCLEUS = 2.0
|
|
80
|
+
_BANDWIDTH_HZ = 5000.0
|
|
81
|
+
_CENTRE_FREQ_X1E7 = 1.9612e8 # -> 80.0 MHz via the `centre_frequency` property
|
|
82
|
+
_CENTRE_FREQ_MHZ = _CENTRE_FREQ_X1E7 / 1e7
|
|
83
|
+
_CARRIER_PPM = 4.68 # matches GESeries.RawMRISeries.carrier_ppm for nucleus in {1, 2}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _rng(*salt: object) -> np.random.Generator:
|
|
87
|
+
return np.random.default_rng(abs(hash((_RNG_SEED, *salt))) % (2**32))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_own_nii(path: Path, data: np.ndarray, affine: np.ndarray) -> None:
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
nib.save(nib.Nifti1Image(data.astype(np.float32), affine), path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_mat(path: Path, *, spec: np.ndarray, fov_mm: float, seed: int, **header_kwargs) -> None:
|
|
96
|
+
recon = {
|
|
97
|
+
"h": _mat_header(fov_mm=fov_mm, **header_kwargs),
|
|
98
|
+
"bb": _rng(seed, "bb").standard_normal((4, 4)),
|
|
99
|
+
"spec": spec,
|
|
100
|
+
}
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
scipy.io.savemat(path, recon)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_mrs_mat(
|
|
106
|
+
path: Path,
|
|
107
|
+
*,
|
|
108
|
+
series_id: int,
|
|
109
|
+
averages: int,
|
|
110
|
+
npts: int,
|
|
111
|
+
seed: int,
|
|
112
|
+
intensities: ArrayLike | None = None,
|
|
113
|
+
fov_mm: float = 240.0,
|
|
114
|
+
**header_kwargs,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Write the `.mat` spectrum and, from the same simulated peaks, the raw FID `.h5` cache.
|
|
117
|
+
|
|
118
|
+
Every real MRS acquisition has both, so every synthetic one does too -- `simulate_fid`
|
|
119
|
+
returns the raw time-domain FID `xr.DataArray`, which we convert both ways from a single
|
|
120
|
+
simulation instead of writing an independently-random raw-FID array.
|
|
121
|
+
"""
|
|
122
|
+
fid_da = _spectra.simulate_fid(
|
|
123
|
+
npts=npts,
|
|
124
|
+
n_specs=averages,
|
|
125
|
+
bandwidth=_BANDWIDTH_HZ,
|
|
126
|
+
seed=seed,
|
|
127
|
+
intensities=intensities,
|
|
128
|
+
)
|
|
129
|
+
fid = np.asarray(fid_da.values, dtype=complex)
|
|
130
|
+
spec = np.asarray(xmris.to_spectrum(fid_da).values, dtype=complex)
|
|
131
|
+
_write_mat(
|
|
132
|
+
path,
|
|
133
|
+
spec=spec,
|
|
134
|
+
fov_mm=fov_mm,
|
|
135
|
+
seed=seed,
|
|
136
|
+
nucleus=_NUCLEUS,
|
|
137
|
+
bandwidth=_BANDWIDTH_HZ,
|
|
138
|
+
centre_freq_x1e7=_CENTRE_FREQ_X1E7,
|
|
139
|
+
**header_kwargs,
|
|
140
|
+
)
|
|
141
|
+
# In mrs data the fids are chopped. IE every other fid is multiplied by -1
|
|
142
|
+
# Fake this too
|
|
143
|
+
fid[1::2, :] *= -1
|
|
144
|
+
# And the data is complex conjugated
|
|
145
|
+
fid = np.conjugate(fid)
|
|
146
|
+
|
|
147
|
+
_write_raw_fids_h5(path.parent, series_id, fid)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _write_mrsi_mat(
|
|
151
|
+
path: Path,
|
|
152
|
+
*,
|
|
153
|
+
npts: int,
|
|
154
|
+
grid: tuple[int, int, int],
|
|
155
|
+
fov_mm: float,
|
|
156
|
+
seed: int,
|
|
157
|
+
intensity_map: np.ndarray | None = None,
|
|
158
|
+
**header_kwargs,
|
|
159
|
+
) -> None:
|
|
160
|
+
if intensity_map is None:
|
|
161
|
+
intensity_map = np.full(grid, 0.5, dtype=np.float32)
|
|
162
|
+
spec = _spectra.simulate_grid(
|
|
163
|
+
npts=npts,
|
|
164
|
+
grid=grid,
|
|
165
|
+
bandwidth=_BANDWIDTH_HZ,
|
|
166
|
+
centre_freq_mhz=_CENTRE_FREQ_MHZ,
|
|
167
|
+
carrier_ppm=_CARRIER_PPM,
|
|
168
|
+
intensity_map=intensity_map,
|
|
169
|
+
seed=seed,
|
|
170
|
+
)
|
|
171
|
+
_write_mat(
|
|
172
|
+
path,
|
|
173
|
+
spec=spec,
|
|
174
|
+
fov_mm=fov_mm,
|
|
175
|
+
seed=seed,
|
|
176
|
+
nucleus=_NUCLEUS,
|
|
177
|
+
bandwidth=_BANDWIDTH_HZ,
|
|
178
|
+
centre_freq_x1e7=_CENTRE_FREQ_X1E7,
|
|
179
|
+
**header_kwargs,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _write_raw_fids_h5(series_folder: Path, series_id: int, fids: np.ndarray) -> None:
|
|
184
|
+
"""A dummy `ScanArchive*.h5` (only its existence matters) plus a pre-cached `fids` dataset.
|
|
185
|
+
|
|
186
|
+
`load_raw_fids` reads the cache directly when present and never opens the archive -- see the
|
|
187
|
+
diary entry.
|
|
188
|
+
"""
|
|
189
|
+
series_folder.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
(series_folder / f"ScanArchive_{series_id}.h5").touch()
|
|
191
|
+
with h5py.File(series_folder / f"Series{series_id}_raw_fids.h5", "w") as hf:
|
|
192
|
+
hf.create_dataset("fids", data=fids)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _write_raw_fid_files(series_folder: Path, series_id: int, npts: int, ntime: int = 150) -> None:
|
|
196
|
+
"""Random-noise raw FIDs for series with no `.mat`-backed spectrum to derive real ones from."""
|
|
197
|
+
fids = _rng(series_folder, "fids").standard_normal((npts, ntime)) + 1j * _rng(
|
|
198
|
+
series_folder, "fids-imag"
|
|
199
|
+
).standard_normal((npts, ntime))
|
|
200
|
+
_write_raw_fids_h5(series_folder, series_id, fids)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build_brain_mrs_mrsi_exam(root: Path) -> None:
|
|
204
|
+
data = root / "data"
|
|
205
|
+
exam = data / "ExamHeVo18anon"
|
|
206
|
+
grid = (16, 16, 16)
|
|
207
|
+
|
|
208
|
+
# The real T1 template, cropped to the head -- see _spectra._TEMPLATE_ATTRIBUTION.
|
|
209
|
+
_spectra.write_template_t1(data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO.nii.gz")
|
|
210
|
+
|
|
211
|
+
# Series 8's own NIfTI seeds create_MRSI_nii/create_MRSI_affine *and* doubles as the
|
|
212
|
+
# per-grid-cell intensity map below -- real brain signal in, brain-shaped spectra out.
|
|
213
|
+
own_affine, intensity_map = _spectra.template_grid_intensity(grid)
|
|
214
|
+
_write_own_nii(
|
|
215
|
+
data / "008_MRSI_pseudo_S64_X10_Y10_Z10_T1_C1" / "8_MRSI_pseudo.nii.gz",
|
|
216
|
+
intensity_map * 200,
|
|
217
|
+
own_affine,
|
|
218
|
+
)
|
|
219
|
+
# create_MRSI_affine() rescales x/y from the header's fov_mm/mtx[0] against own_affine's x
|
|
220
|
+
# voxel size -- derive fov_mm from that same affine so the two agree (a mismatched hardcoded
|
|
221
|
+
# value here inflates the MRSI grid relative to the real brain extent it's drawn over).
|
|
222
|
+
fov_mm = own_affine[0, 0] * grid[0]
|
|
223
|
+
(data / "014_localizer").mkdir(parents=True, exist_ok=True) # DICOM-only gap filler
|
|
224
|
+
|
|
225
|
+
_write_mrs_mat(
|
|
226
|
+
exam / "Series6" / "ScanArchive_Series6.mat",
|
|
227
|
+
series_id=6,
|
|
228
|
+
averages=64,
|
|
229
|
+
npts=2048,
|
|
230
|
+
seed=6,
|
|
231
|
+
protocol="MRS_unloc",
|
|
232
|
+
description="006_MRS_unloc",
|
|
233
|
+
exam_number=1801,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Create increasing intensities for the washin series
|
|
237
|
+
# Defines start values [HDO, Glucose, Glx, Baseline] and their stop values
|
|
238
|
+
intensities = np.linspace(start=[1.0, 0.4, 0.0, 1.0], stop=[1.2, 3.0, 0.6, 1.0], num=250)
|
|
239
|
+
|
|
240
|
+
_write_mrs_mat(
|
|
241
|
+
exam / "Series7" / "ScanArchive_Series7.mat",
|
|
242
|
+
series_id=7,
|
|
243
|
+
averages=250,
|
|
244
|
+
intensities=intensities,
|
|
245
|
+
npts=2048,
|
|
246
|
+
seed=7,
|
|
247
|
+
protocol="MRS_washin",
|
|
248
|
+
description="007_MRS_washin",
|
|
249
|
+
exam_number=1801,
|
|
250
|
+
sctime=300_000_000.0,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
_write_mrsi_mat(
|
|
254
|
+
exam / "Series8" / "ScanArchive_Series8.mat",
|
|
255
|
+
npts=700,
|
|
256
|
+
grid=grid,
|
|
257
|
+
fov_mm=fov_mm,
|
|
258
|
+
seed=8000,
|
|
259
|
+
intensity_map=intensity_map,
|
|
260
|
+
protocol="MRSI_pseudo",
|
|
261
|
+
description="008_MRSI_pseudo_S64_X10_Y10_Z10_T1_C1",
|
|
262
|
+
exam_number=1801,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
for series_id, npts in ((9, 700), (11, 700), (12, 1678)):
|
|
266
|
+
_write_raw_fid_files(exam / f"Series{series_id}", series_id, npts=npts)
|
|
267
|
+
|
|
268
|
+
(exam / "Series13").mkdir(parents=True, exist_ok=True) # exam data, no DICOM
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _build_brain_extraction_exam(root: Path) -> None:
|
|
272
|
+
data = root / "data"
|
|
273
|
+
(data / "Series0010_BS_prescan").mkdir(parents=True, exist_ok=True) # new-style naming
|
|
274
|
+
_spectra.write_template_t1(data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO_BODY.nii.gz")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _build_mrsi_missing_series_exam(root: Path) -> None:
|
|
278
|
+
exam = root / "Exam4873anon"
|
|
279
|
+
_write_mrsi_mat(
|
|
280
|
+
exam / "Series7" / "ScanArchive_Series7.mat",
|
|
281
|
+
npts=700,
|
|
282
|
+
grid=(16, 16, 16),
|
|
283
|
+
fov_mm=300.0,
|
|
284
|
+
seed=7700,
|
|
285
|
+
protocol="MRSI_pseudo",
|
|
286
|
+
description="007_MRSI_pseudo",
|
|
287
|
+
exam_number=4873,
|
|
288
|
+
)
|
|
289
|
+
(exam / "Series5").mkdir(parents=True, exist_ok=True) # no .mat -- FileNotFoundError case
|
|
290
|
+
(root / "010_axial_localizer").mkdir(parents=True, exist_ok=True) # old-style naming
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _build_nist_phantom_exam(root: Path) -> None:
|
|
294
|
+
data = root / "data"
|
|
295
|
+
exam = data / "ExamNISTanon"
|
|
296
|
+
grid = (16, 16, 16)
|
|
297
|
+
fov_mm = 300.0 # -> 14 mm in-plane voxels, comfortably resolves the 8-sphere ring
|
|
298
|
+
z_extent_mm = 300.0
|
|
299
|
+
rings = _spectra.DEFAULT_SPHERE_RINGS
|
|
300
|
+
|
|
301
|
+
_spectra.sphere_phantom_image(
|
|
302
|
+
data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO.nii.gz",
|
|
303
|
+
shape=(128, 128, 100),
|
|
304
|
+
voxel_mm=1,
|
|
305
|
+
rings=rings,
|
|
306
|
+
seed=2,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
intensity_map, voxel_xy = _spectra.sphere_grid_intensity(
|
|
310
|
+
grid, fov_mm=fov_mm, z_extent_mm=z_extent_mm, rings=rings
|
|
311
|
+
)
|
|
312
|
+
own_affine = _spectra.grid_own_affine(grid, (voxel_xy, voxel_xy, z_extent_mm / grid[2]))
|
|
313
|
+
_write_own_nii(
|
|
314
|
+
data / "005_MRSI_pseudo_S64_X12_Y12_Z3_T1_C1" / "5_MRSI_pseudo.nii.gz",
|
|
315
|
+
intensity_map * 200,
|
|
316
|
+
own_affine,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
_write_mrsi_mat(
|
|
320
|
+
exam / "Series5" / "ScanArchive_Series5.mat",
|
|
321
|
+
npts=700,
|
|
322
|
+
grid=grid,
|
|
323
|
+
fov_mm=fov_mm,
|
|
324
|
+
seed=5500,
|
|
325
|
+
intensity_map=intensity_map * 500,
|
|
326
|
+
protocol="MRSI_pseudo",
|
|
327
|
+
description="005_MRSI_pseudo_S64_X12_Y12_Z3_T1_C1",
|
|
328
|
+
exam_number=408,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _write_legacy_mat(path: Path) -> None:
|
|
333
|
+
"""A genuine MATLAB v7.3 (HDF5) file -- `mat73`'s fallback path, not `scipy.io.loadmat`."""
|
|
334
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
with h5py.File(path, "w") as hf:
|
|
336
|
+
dataset = hf.create_dataset("pyr", data=_rng(path, "pyr").standard_normal(64))
|
|
337
|
+
dataset.attrs["MATLAB_class"] = np.bytes_(b"double")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
_DATASETS: dict[str, tuple[Callable[[Path], None], str]] = {
|
|
341
|
+
"brain_mrs_mrsi_exam": (_build_brain_mrs_mrsi_exam, "brain_mrs_mrsi_exam"),
|
|
342
|
+
"brain_extraction_exam": (_build_brain_extraction_exam, "brain_extraction_exam"),
|
|
343
|
+
"mrsi_missing_series_exam": (_build_mrsi_missing_series_exam, "mrsi_missing_series_exam"),
|
|
344
|
+
"nist_phantom_exam": (_build_nist_phantom_exam, "nist_phantom_exam"),
|
|
345
|
+
"legacy_matlab73_example": (_write_legacy_mat, "legacy_matlab73_example.mat"),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@functools.cache
|
|
350
|
+
def _fake_exam_root() -> Path:
|
|
351
|
+
root = Path(tempfile.mkdtemp(prefix="mnutils_fake_exam_"))
|
|
352
|
+
atexit.register(shutil.rmtree, root, ignore_errors=True)
|
|
353
|
+
return root
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@functools.cache
|
|
357
|
+
def build_fake_exam(dataset: str) -> Path:
|
|
358
|
+
"""Build (on first call) and return the path to one synthetic dataset.
|
|
359
|
+
|
|
360
|
+
`dataset` is one of `_DATASETS`' keys -- the same names and shapes docs pages and tests
|
|
361
|
+
previously read from `tests/datasets/`, entirely fabricated (see the diary entry for what's
|
|
362
|
+
simulated vs. downloaded). Every dataset lives under one shared `tempfile.mkdtemp` directory
|
|
363
|
+
(created on first call to any dataset) removed by an `atexit` hook when the process exits, so
|
|
364
|
+
nothing accumulates across runs. Each dataset is built at most once per process.
|
|
365
|
+
"""
|
|
366
|
+
if dataset not in _DATASETS:
|
|
367
|
+
raise ValueError(
|
|
368
|
+
f"Unknown fake-exam dataset {dataset!r}; choose one of {sorted(_DATASETS)}"
|
|
369
|
+
)
|
|
370
|
+
builder, filename = _DATASETS[dataset]
|
|
371
|
+
path = _fake_exam_root() / filename
|
|
372
|
+
builder(path)
|
|
373
|
+
return path
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Loading, file discovery, NIfTI and spectral helpers."""
|
|
2
|
+
|
|
3
|
+
from . import data_loaders, file_helpers, images, nifti, spectra
|
|
4
|
+
from .data_loaders import load_mat_file
|
|
5
|
+
from .file_helpers import (
|
|
6
|
+
get_exam_folder,
|
|
7
|
+
get_h5_data_from_series,
|
|
8
|
+
get_mat_data_from_series,
|
|
9
|
+
)
|
|
10
|
+
from .spectra import calculate_ppm_axis
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
# modules
|
|
14
|
+
"data_loaders",
|
|
15
|
+
"file_helpers",
|
|
16
|
+
"images",
|
|
17
|
+
"nifti",
|
|
18
|
+
"spectra",
|
|
19
|
+
# frequently used helpers
|
|
20
|
+
"calculate_ppm_axis",
|
|
21
|
+
"get_exam_folder",
|
|
22
|
+
"get_h5_data_from_series",
|
|
23
|
+
"get_mat_data_from_series",
|
|
24
|
+
"load_mat_file",
|
|
25
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import h5py
|
|
6
|
+
import numpy as np
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from . import file_helpers
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_mat_file(file_path: Path | str) -> dict:
|
|
13
|
+
"""Load a .mat file and return its contents as a dictionary.
|
|
14
|
+
|
|
15
|
+
Transparently falls back from `scipy.io.loadmat` (plain v5/v7 files) to
|
|
16
|
+
`mat73` (v7.3, HDF5-based files) when the former can't read the file.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
file_path : Path or str
|
|
21
|
+
Path to the .mat file.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
dict
|
|
26
|
+
Contents of the .mat file.
|
|
27
|
+
|
|
28
|
+
Raises
|
|
29
|
+
------
|
|
30
|
+
FileNotFoundError
|
|
31
|
+
If `file_path` does not exist or is not a file.
|
|
32
|
+
ValueError
|
|
33
|
+
If `file_path` does not have a `.mat` extension.
|
|
34
|
+
RuntimeError
|
|
35
|
+
If the file can't be loaded by either scipy or mat73.
|
|
36
|
+
"""
|
|
37
|
+
file_path = Path(file_path)
|
|
38
|
+
if not file_path.is_file():
|
|
39
|
+
raise FileNotFoundError(
|
|
40
|
+
f"The specified path does not exist or is not a file: '{file_path}'"
|
|
41
|
+
)
|
|
42
|
+
if file_path.suffix.lower() != ".mat":
|
|
43
|
+
raise ValueError(f"The file does not have a .mat extension: '{file_path}'")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
# Try loading with scipy.io.loadmat first
|
|
47
|
+
return _loadmat(file_path)
|
|
48
|
+
except (NotImplementedError, ValueError):
|
|
49
|
+
# If it fails, try loading with mat73 (for v7.3 .mat files)
|
|
50
|
+
try:
|
|
51
|
+
import mat73
|
|
52
|
+
|
|
53
|
+
return mat73.loadmat(file_path)
|
|
54
|
+
except ImportError as e:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"mat73 is required to read MATLAB v7.3 files but could not be imported."
|
|
57
|
+
) from e
|
|
58
|
+
except OSError as e:
|
|
59
|
+
logger.error(f"Failed to load .mat file with both scipy and mat73: {e}")
|
|
60
|
+
raise OSError(f"Failed to open .mat file with mat73: {e}")
|
|
61
|
+
except (TypeError, ValueError) as e:
|
|
62
|
+
raise RuntimeError(f"Unexpected error loading .mat file with mat73: {e}")
|
|
63
|
+
except (OSError, TypeError) as e:
|
|
64
|
+
logger.error(f"Failed to load .mat file with scipy: {e}.")
|
|
65
|
+
raise RuntimeError(f"Unexpected error loading .mat file with scipy: {e}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_raw_fids(
|
|
69
|
+
DATA_FOLDER: Path | str,
|
|
70
|
+
series_id: int,
|
|
71
|
+
load_most_recent: bool = True,
|
|
72
|
+
force_override: bool = False,
|
|
73
|
+
) -> tuple[np.ndarray, Path]:
|
|
74
|
+
"""Load raw FIDs for a series, reading from a cached HDF5 file when available.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
DATA_FOLDER : Path or str
|
|
79
|
+
Path to the exam's data folder.
|
|
80
|
+
series_id : int
|
|
81
|
+
Series number to load raw FIDs for.
|
|
82
|
+
load_most_recent : bool
|
|
83
|
+
If True, return the most recently created cached `*_raw_fids.h5` file
|
|
84
|
+
instead of re-reading the scan archive.
|
|
85
|
+
force_override : bool
|
|
86
|
+
If True, delete and regenerate an existing output HDF5 file rather than
|
|
87
|
+
returning its cached contents.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
tuple[np.ndarray, Path]
|
|
92
|
+
The complex raw FIDs array and the path to the HDF5 file it was
|
|
93
|
+
loaded from (or written to).
|
|
94
|
+
"""
|
|
95
|
+
scan_archive_file = file_helpers.get_h5_data_from_series(DATA_FOLDER, series_id)
|
|
96
|
+
series_folder = file_helpers.get_exam_folder(DATA_FOLDER) / f"Series{series_id}"
|
|
97
|
+
|
|
98
|
+
# Find if there is already an h5 file with raw fids
|
|
99
|
+
if load_most_recent:
|
|
100
|
+
existing_files = list(series_folder.glob(f"Series{series_id}_raw_fids.h5"))
|
|
101
|
+
if existing_files:
|
|
102
|
+
# Get the most recent file based on creation time
|
|
103
|
+
output_h5_file = max(existing_files, key=lambda f: f.stat().st_ctime)
|
|
104
|
+
logger.debug(f"Found existing raw FIDs file: {output_h5_file}. Loading data from it.")
|
|
105
|
+
with h5py.File(output_h5_file, "r") as hf:
|
|
106
|
+
data = np.array(hf["fids"])
|
|
107
|
+
return data, output_h5_file
|
|
108
|
+
else:
|
|
109
|
+
logger.debug(
|
|
110
|
+
f"No existing raw FIDs file found in {series_folder}. Will create a new one."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
output_h5_file = series_folder / f"Series{series_id}_raw_fids.h5"
|
|
114
|
+
|
|
115
|
+
logger.debug(f"Loading raw FIDs from {scan_archive_file} into {output_h5_file}")
|
|
116
|
+
|
|
117
|
+
if output_h5_file.exists():
|
|
118
|
+
if not force_override:
|
|
119
|
+
logger.warning(f"Output file {output_h5_file} already exists. Returning data from it.")
|
|
120
|
+
with h5py.File(output_h5_file, "r") as hf:
|
|
121
|
+
data = np.array(hf["fids"])
|
|
122
|
+
return data, output_h5_file
|
|
123
|
+
else:
|
|
124
|
+
logger.warning(
|
|
125
|
+
f"Output file {output_h5_file} already exists. "
|
|
126
|
+
"Overriding as force_override is True."
|
|
127
|
+
)
|
|
128
|
+
output_h5_file.unlink()
|
|
129
|
+
|
|
130
|
+
from .. import matlab
|
|
131
|
+
|
|
132
|
+
matengine = matlab.connect_to_matlab()
|
|
133
|
+
matlab.setup_util_path(matengine)
|
|
134
|
+
matlab.add_matlablatest_path(matengine)
|
|
135
|
+
matengine.manage_paths("ORCHESTRASDK", True, nargout=0)
|
|
136
|
+
data = matengine.read_archive(str(scan_archive_file), 0, nargout=1)
|
|
137
|
+
matengine.quit()
|
|
138
|
+
logger.debug(
|
|
139
|
+
f"Raw data loaded from MATLAB engine, type: {type(data)}. Matlabengine session closed."
|
|
140
|
+
)
|
|
141
|
+
data = np.asarray(data)
|
|
142
|
+
# Validate data shape and type to be complex numpy array
|
|
143
|
+
if not isinstance(data, np.ndarray) or not np.iscomplexobj(data):
|
|
144
|
+
raise ValueError(f"Loaded data is not a complex numpy array. Got type: {type(data)}")
|
|
145
|
+
|
|
146
|
+
with h5py.File(output_h5_file, "w") as hf:
|
|
147
|
+
hf.create_dataset("fids", data=data)
|
|
148
|
+
hf.attrs["file_created_date"] = datetime.now().isoformat().replace("+00:00", "Z")
|
|
149
|
+
try:
|
|
150
|
+
hf.attrs["file_created_by"] = getpass.getuser()
|
|
151
|
+
except (OSError, KeyError):
|
|
152
|
+
hf.attrs["file_created_by"] = "unknown_user"
|
|
153
|
+
|
|
154
|
+
# Check if file exists
|
|
155
|
+
if not output_h5_file.exists():
|
|
156
|
+
raise FileNotFoundError(f"Output file {output_h5_file} was not created successfully.")
|
|
157
|
+
|
|
158
|
+
return data, output_h5_file
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _loadmat(filename: str | Path) -> dict:
|
|
162
|
+
"""
|
|
163
|
+
Load a MATLAB .mat file and convert its contents into Python data structures.
|
|
164
|
+
|
|
165
|
+
This function wraps around `scipy.io.loadmat` to address issues with
|
|
166
|
+
recovering Python dictionaries from MATLAB .mat files. It ensures that
|
|
167
|
+
all entries, including those stored as MATLAB objects, are properly
|
|
168
|
+
converted to Python-native types by invoking the `_check_keys` function.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
filename (str): The path to the .mat file to be loaded.
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
dict: A dictionary containing the contents of the .mat file, with
|
|
176
|
+
MATLAB objects converted to Python-native types.
|
|
177
|
+
"""
|
|
178
|
+
import scipy.io
|
|
179
|
+
|
|
180
|
+
data = scipy.io.loadmat(filename, struct_as_record=False, squeeze_me=True)
|
|
181
|
+
return _check_keys(data)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_keys(data: dict) -> dict:
|
|
185
|
+
"""Recursively convert MATLAB structs within a dictionary to Python dictionaries.
|
|
186
|
+
|
|
187
|
+
Iterates through the keys of the given dictionary and checks if any of the values
|
|
188
|
+
are MATLAB structs (from `scipy.io.matlab.mat_struct`). If a MATLAB struct is
|
|
189
|
+
found, it is converted to a Python dictionary using the `_todict` function.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
data : dict
|
|
194
|
+
The input dictionary to process.
|
|
195
|
+
|
|
196
|
+
Returns
|
|
197
|
+
-------
|
|
198
|
+
dict
|
|
199
|
+
The processed dictionary with MATLAB structs converted to Python dictionaries.
|
|
200
|
+
"""
|
|
201
|
+
from scipy.io.matlab import mat_struct
|
|
202
|
+
|
|
203
|
+
for key in data:
|
|
204
|
+
if isinstance(data[key], mat_struct):
|
|
205
|
+
data[key] = _todict(data[key], mat_struct)
|
|
206
|
+
return data
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _todict(matobj, mat_struct_type: type) -> dict:
|
|
210
|
+
"""Recursively construct nested dictionaries from a MATLAB struct object."""
|
|
211
|
+
out: dict = {}
|
|
212
|
+
for field_name in matobj._fieldnames:
|
|
213
|
+
elem = matobj.__dict__[field_name]
|
|
214
|
+
if isinstance(elem, mat_struct_type):
|
|
215
|
+
out[field_name] = _todict(elem, mat_struct_type)
|
|
216
|
+
else:
|
|
217
|
+
out[field_name] = elem
|
|
218
|
+
return out
|