MNutils 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. mnutils/GEExam.py +495 -0
  2. mnutils/GESeries.py +1578 -0
  3. mnutils/__init__.py +54 -0
  4. mnutils/_scripts.py +177 -0
  5. mnutils/fitting/AMARES.py +831 -0
  6. mnutils/fitting/__init__.py +5 -0
  7. mnutils/matlab/__init__.py +13 -0
  8. mnutils/matlab/setup_matlab.py +116 -0
  9. mnutils/plotting/__init__.py +38 -0
  10. mnutils/plotting/_widgets/__init__.py +5 -0
  11. mnutils/plotting/_widgets/_html.py +34 -0
  12. mnutils/plotting/_widgets/_shared/__init__.py +50 -0
  13. mnutils/plotting/_widgets/_shared/dom.js +124 -0
  14. mnutils/plotting/_widgets/_shared/theme.css +140 -0
  15. mnutils/plotting/_widgets/image_grid.css +202 -0
  16. mnutils/plotting/_widgets/image_grid.js +212 -0
  17. mnutils/plotting/_widgets/image_grid.py +86 -0
  18. mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
  19. mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
  20. mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
  21. mnutils/plotting/_widgets/slice_viewer.css +38 -0
  22. mnutils/plotting/_widgets/slice_viewer.js +41 -0
  23. mnutils/plotting/_widgets/slice_viewer.py +35 -0
  24. mnutils/plotting/images.py +1399 -0
  25. mnutils/plotting/saving.py +138 -0
  26. mnutils/plotting/spectra.py +357 -0
  27. mnutils/py.typed +0 -0
  28. mnutils/testing/__init__.py +3 -0
  29. mnutils/testing/_spectra.py +338 -0
  30. mnutils/testing/synthetic.py +373 -0
  31. mnutils/utils/__init__.py +25 -0
  32. mnutils/utils/data_loaders.py +218 -0
  33. mnutils/utils/file_helpers.py +708 -0
  34. mnutils/utils/images.py +202 -0
  35. mnutils/utils/nifti.py +379 -0
  36. mnutils/utils/spectra.py +149 -0
  37. mnutils-1.2.0.dist-info/METADATA +161 -0
  38. mnutils-1.2.0.dist-info/RECORD +41 -0
  39. mnutils-1.2.0.dist-info/WHEEL +4 -0
  40. mnutils-1.2.0.dist-info/entry_points.txt +7 -0
  41. mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,338 @@
1
+ """Realistic-ish spectra and spatial phantoms for the synthetic exam fixtures.
2
+
3
+ Spectra come from `xmris.simulate_fid` (real AMARES-formulation peak simulation), not noise, with
4
+ three peaks always present -- HDO, Glucose, Glx -- at roughly their real chemical shifts. Spatial
5
+ realism comes from two sources: a downloaded real T1 template for brain-shaped anatomy (see
6
+ `_TEMPLATE_ATTRIBUTION`), and a synthetic ring-of-spheres phantom for the NIST-style dataset, where
7
+ each sphere gets a different, increasing signal intensity and that same intensity drives the
8
+ spectra simulated at its location.
9
+ """
10
+
11
+ import functools
12
+ import tempfile
13
+ import urllib.request
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ import nibabel as nib
18
+ import numpy as np
19
+ import xarray as xr
20
+ import xmris
21
+ from loguru import logger
22
+ from numpy.typing import ArrayLike
23
+
24
+ _TEMPLATE_URL = "https://raw.githubusercontent.com/neurolabusc/niivue-images/main/chris_t1.nii.gz"
25
+ _TEMPLATE_ATTRIBUTION = (
26
+ "T1 anatomical fixtures are resampled from Chris Rorden's 'chris_t1', part of the "
27
+ "niivue-images sample set (https://github.com/neurolabusc/niivue-images), licensed "
28
+ "CC BY-NC 4.0 (https://creativecommons.org/licenses/by-nc/4.0/) -- non-commercial use only."
29
+ )
30
+
31
+ # Deuterium (2H) chemical shifts track their 1H counterparts (chemical shift is a property of the
32
+ # electronic environment, not the nucleus). HDO dominates real 2H-MRSI spectra by roughly an order
33
+ # of magnitude over the metabolite peaks -- these ratios, and the per-peak linewidths, are
34
+ # illustrative, not measured. `Baseline` is a broad, low-amplitude component standing in for the
35
+ # macromolecular/lipid background every real spectrum sits on top of.
36
+ # name: (relative amplitude, chemical shift [ppm], linewidth [Hz])
37
+ DMI_TRUTH: dict[str, tuple[float, float, float]] = {
38
+ "HDO": (1.0, 4.70, 12.0),
39
+ "Glucose": (0.25, 3.80, 12.0),
40
+ "Glx": (0.18, 2.40, 12.0),
41
+ "Baseline": (1.0, 1.9, 70.0),
42
+ }
43
+ PEAKS_PPM: dict[str, float] = {name: ppm for name, (_, ppm, _) in DMI_TRUTH.items()}
44
+ PEAK_REL_AMPLITUDE: dict[str, float] = {name: amp for name, (amp, _, _) in DMI_TRUTH.items()}
45
+
46
+ FREQ_2H_MHz_3T = 19.61
47
+ CARRIER_PPM = 4.68
48
+ _BASE_AMPLITUDE = 40.0
49
+ _BASE_SNR_UNLOC = 50 # target SNR per simulated spectrum, unlocalized MRS
50
+ _BASE_SNR_MRSI = 30 # MRSI voxels are effectively single-transient -- no sqrt(N) boost
51
+
52
+
53
+ def simulate_fid(
54
+ *,
55
+ npts: int,
56
+ bandwidth: float,
57
+ centre_freq_mhz: float = FREQ_2H_MHz_3T,
58
+ carrier_ppm: float = CARRIER_PPM,
59
+ b0_ppm: float = 0.0,
60
+ phase_deg: float = 0.0,
61
+ base_snr: float = _BASE_SNR_UNLOC,
62
+ intensities: ArrayLike | None = None,
63
+ seed: int,
64
+ n_specs: int = 1,
65
+ ) -> xr.DataArray:
66
+ """`n_specs` simulated FIDs (HDO/Glucose/Glx/Baseline), time-domain, via `xmris.simulate_fid`.
67
+
68
+ Returns the raw `xr.DataArray` -- callers convert it to whatever they need: `xmris.to_spectrum`
69
+ for the frequency domain, `.values`/`np.asarray` for a plain numpy array. This lets one
70
+ simulated dataset back both a `.mat` spectrum and a raw-FID fixture (same peaks, same noise)
71
+ instead of two independently-random ones. `intensities` scales each peak's amplitude, per
72
+ spectrum -- either one `(n_peaks,)` vector broadcast across all `n_specs`, or a `(n_specs,
73
+ n_peaks)` array for per-spectrum control (e.g. a washin series ramping Glucose/Glx over time).
74
+ `b0_ppm`/`phase_deg` add a uniform chemical-shift offset / zero-order phase across all peaks,
75
+ matching real per-repetition/per-voxel B0 and phase drift.
76
+ """
77
+ names = list(DMI_TRUTH)
78
+ amps = np.asarray([_BASE_AMPLITUDE * DMI_TRUTH[name][0] for name in names], dtype=np.float32)
79
+ chemical_shifts = np.asarray([DMI_TRUTH[name][1] + b0_ppm for name in names], dtype=np.float32)
80
+ dampings = np.asarray([np.pi * DMI_TRUTH[name][2] for name in names], dtype=np.float32)
81
+ phases = np.deg2rad(phase_deg)
82
+
83
+ if intensities is None:
84
+ intensities = np.ones(len(names))
85
+ intensities = np.atleast_1d(intensities).astype(np.float32)
86
+
87
+ # Check if the number of intensities matches the number of spectra to simulate
88
+ if intensities.ndim == 1:
89
+ intensities = np.broadcast_to(intensities, (n_specs, len(names)))
90
+ elif intensities.ndim == 2:
91
+ if intensities.shape[0] != n_specs or intensities.shape[1] != len(names):
92
+ raise ValueError(
93
+ f"intensities shape {intensities.shape} does not match n_specs={n_specs} and "
94
+ f"number of peaks={len(names)}"
95
+ )
96
+
97
+ fid = xr.concat(
98
+ [
99
+ xmris.simulate_fid(
100
+ amplitudes=amps * intensities[i],
101
+ chemical_shifts=chemical_shifts,
102
+ reference_frequency=centre_freq_mhz,
103
+ carrier_ppm=carrier_ppm,
104
+ spectral_width=bandwidth,
105
+ n_points=npts,
106
+ dampings=dampings,
107
+ phases=phases,
108
+ target_snr=base_snr,
109
+ seed=seed + i, # a shared seed across i would give every voxel identical noise
110
+ )
111
+ for i in range(n_specs)
112
+ ],
113
+ dim="voxel",
114
+ ).assign_coords(voxel=np.arange(n_specs))
115
+ fid.attrs = {"reference_frequency": centre_freq_mhz, "carrier_ppm": carrier_ppm}
116
+ return fid
117
+
118
+
119
+ def simulate_grid(
120
+ *,
121
+ npts: int,
122
+ grid: tuple[int, int, int],
123
+ bandwidth: float,
124
+ centre_freq_mhz: float = FREQ_2H_MHz_3T,
125
+ carrier_ppm: float = CARRIER_PPM,
126
+ intensity_map: np.ndarray,
127
+ seed: int,
128
+ ) -> np.ndarray:
129
+ """`(npts, i, j, k)` grid of spectra, one per cell, scaled by `intensity_map` (same shape).
130
+
131
+ Every cell gets its own unscaled spectrum from `simulate_fid`, then `intensity_map` scales
132
+ each cell's signal post-FFT. MRSI voxels are effectively single-transient, so `_BASE_SNR_MRSI`
133
+ (no `sqrt(N)` boost) sets the noise floor.
134
+ """
135
+ n_cells = int(np.prod(grid))
136
+
137
+ # Simulate all FIDs, then convert to the frequency domain
138
+ fid = simulate_fid(
139
+ npts=npts,
140
+ bandwidth=bandwidth,
141
+ centre_freq_mhz=centre_freq_mhz,
142
+ carrier_ppm=carrier_ppm,
143
+ base_snr=_BASE_SNR_MRSI,
144
+ seed=seed,
145
+ n_specs=n_cells,
146
+ )
147
+ # xmris.to_spectrum returns (voxel, frequency) -- transpose to (frequency, voxel) before
148
+ # reshaping, or the flat buffer scrambles frequency bins across voxels.
149
+ specs_flat = np.asarray(xmris.to_spectrum(fid).values, dtype=complex).T
150
+
151
+ # Reshape to (npts, i, j, k) and apply intensity map
152
+ specs = specs_flat.reshape((npts, *grid))
153
+ specs *= intensity_map.reshape((1, *grid))
154
+ return specs
155
+
156
+
157
+ def grid_world_centers(
158
+ grid: tuple[int, int, int], voxel_size: tuple[float, float, float]
159
+ ) -> np.ndarray:
160
+ """World-mm center of every grid cell, `(i, j, k, 3)`, under a `-shape*voxel/2` origin.
161
+
162
+ This is the same convention `grid_own_affine`/`sphere_phantom_image` use for anatomical
163
+ volumes, and matches `MRSISeries.create_MRSI_affine()` in x/y to within half a voxel when the
164
+ MRSI series' own NIfTI is built with matching shape/voxel_size (see `grid_own_affine`) --
165
+ comfortably inside a sphere's radius, so sphere placement lines up between the anatomical image
166
+ and the MRSI grid without needing to replicate that method's exact half-voxel shift.
167
+ """
168
+ idx = np.indices(grid).astype(np.float64) # (3, i, j, k)
169
+ dims = np.array(grid, dtype=np.float64).reshape(3, 1, 1, 1)
170
+ vox = np.array(voxel_size, dtype=np.float64).reshape(3, 1, 1, 1)
171
+ world = vox * (idx - dims / 2)
172
+ return np.moveaxis(world, 0, -1)
173
+
174
+
175
+ def grid_own_affine(
176
+ grid: tuple[int, int, int], voxel_size: tuple[float, float, float]
177
+ ) -> np.ndarray:
178
+ """The `-shape*voxel/2`-centered affine for an image shaped exactly like `grid`."""
179
+ affine = np.diag([*voxel_size, 1.0])
180
+ affine[:3, 3] = -np.array(grid) * np.array(voxel_size) / 2
181
+ return affine
182
+
183
+
184
+ # --- Sphere-ring phantom (NIST-style) -----------------------------------------------------------
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class SphereRing:
189
+ """One ring of spheres in the x/y plane, offset along z, each with its own signal intensity.
190
+
191
+ Add another metabolite range by appending a second `SphereRing` (different `peaks_ppm`/
192
+ `peak_rel_amplitude`) to the list passed to `sphere_intensity`/`sphere_phantom_image` -- rings
193
+ are independent and just take the max where they'd overlap.
194
+ """
195
+
196
+ name: str
197
+ n_spheres: int = 8
198
+ ring_radius_mm: float = 60.0
199
+ z_offset_mm: float = 20.0
200
+ sphere_radius_mm: float = 10.0
201
+ intensity_range: tuple[float, float] = (0.3, 1.0)
202
+ peaks_ppm: dict[str, float] = field(default_factory=lambda: dict(PEAKS_PPM))
203
+ peak_rel_amplitude: dict[str, float] = field(default_factory=lambda: dict(PEAK_REL_AMPLITUDE))
204
+
205
+ def spheres(self) -> list[tuple[tuple[float, float, float], float]]:
206
+ """`[(center_xyz_mm, intensity), ...]`, intensity strictly increasing around the ring."""
207
+ intensities = np.linspace(*self.intensity_range, self.n_spheres)
208
+ out = []
209
+ for k in range(self.n_spheres):
210
+ theta = 2 * np.pi * k / self.n_spheres
211
+ center = (
212
+ self.ring_radius_mm * np.cos(theta),
213
+ self.ring_radius_mm * np.sin(theta),
214
+ self.z_offset_mm,
215
+ )
216
+ out.append((center, float(intensities[k])))
217
+ return out
218
+
219
+
220
+ DEFAULT_SPHERE_RINGS: list[SphereRing] = [SphereRing(name="metabolite_ring")]
221
+
222
+
223
+ def sphere_intensity(world_xyz: np.ndarray, rings: list[SphereRing]) -> np.ndarray:
224
+ """Per-position intensity in `[0, 1]`, `world_xyz` shaped `(..., 3)` -- 0 outside spheres."""
225
+ intensity = np.zeros(world_xyz.shape[:-1], dtype=np.float32)
226
+ for ring in rings:
227
+ for center, amp in ring.spheres():
228
+ dist = np.linalg.norm(world_xyz - np.array(center), axis=-1)
229
+ intensity = np.maximum(intensity, np.where(dist <= ring.sphere_radius_mm, amp, 0.0))
230
+ return intensity
231
+
232
+
233
+ def sphere_phantom_image(
234
+ path: Path, *, shape: tuple[int, int, int], voxel_mm: float, rings: list[SphereRing], seed: int
235
+ ) -> None:
236
+ """Write a fine-resolution anatomical NIfTI showing the sphere ring as bright regions."""
237
+ world = grid_world_centers(shape, (voxel_mm, voxel_mm, voxel_mm))
238
+ intensity = sphere_intensity(world, rings)
239
+ background = np.random.default_rng(seed).random(shape).astype(np.float32) * 5.0
240
+ data = (background + intensity * 200.0).astype(np.float32)
241
+ affine = grid_own_affine(shape, (voxel_mm, voxel_mm, voxel_mm))
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ nib.save(nib.Nifti1Image(data, affine), path)
244
+
245
+
246
+ def sphere_grid_intensity(
247
+ grid: tuple[int, int, int], *, fov_mm: float, z_extent_mm: float, rings: list[SphereRing]
248
+ ) -> tuple[np.ndarray, float]:
249
+ """Intensity map over an MRSI grid plus the x/y voxel size (`fov_mm / grid[0]`) it implies."""
250
+ voxel_xy = fov_mm / grid[0]
251
+ voxel_z = z_extent_mm / grid[2]
252
+ world = grid_world_centers(grid, (voxel_xy, voxel_xy, voxel_z))
253
+ return sphere_intensity(world, rings), voxel_xy
254
+
255
+
256
+ # --- Real T1 template (brain-shaped anatomy) ----------------------------------------------------
257
+
258
+
259
+ def _template_cache_path() -> Path:
260
+ return Path(tempfile.gettempdir()) / "mnutils_template_cache" / "chris_t1.nii.gz"
261
+
262
+
263
+ def _fetch_t1_template_path() -> Path:
264
+ """Download the CC BY-NC 4.0 template once per machine, cached under the system temp dir."""
265
+ cache = _template_cache_path()
266
+ if not cache.is_file():
267
+ cache.parent.mkdir(parents=True, exist_ok=True)
268
+ logger.info(f"Downloading T1 template fixture from {_TEMPLATE_URL} to {cache}")
269
+ with urllib.request.urlopen(_TEMPLATE_URL, timeout=30) as response: # noqa: S310
270
+ cache.write_bytes(response.read())
271
+ return cache
272
+
273
+
274
+ @functools.cache
275
+ def _cropped_template() -> tuple[np.ndarray, np.ndarray]:
276
+ """The template's data cropped to its non-background bounding box, and the matching affine."""
277
+ img = nib.load(_fetch_t1_template_path())
278
+ data = np.asarray(img.get_fdata(), dtype=np.float32)
279
+ mask = data > (data.max() * 0.05)
280
+ idx = np.array(np.where(mask))
281
+ lo, hi = idx.min(axis=1), idx.max(axis=1) + 1
282
+ cropped = data[lo[0] : hi[0], lo[1] : hi[1], lo[2] : hi[2]]
283
+ affine = img.affine.copy()
284
+ affine[:3, 3] = affine[:3, :3] @ lo + affine[:3, 3]
285
+ return cropped, affine
286
+
287
+
288
+ def write_template_t1(path: Path) -> None:
289
+ """Write the cropped, full-resolution template as an anatomical NIfTI."""
290
+ data, affine = _cropped_template()
291
+ path.parent.mkdir(parents=True, exist_ok=True)
292
+ nib.save(nib.Nifti1Image(data, affine), path)
293
+
294
+
295
+ def template_grid_intensity(grid: tuple[int, int, int]) -> tuple[np.ndarray, np.ndarray]:
296
+ """Downsample the template onto `grid`; returns `(own_affine, intensity_map in [0, 1])`.
297
+
298
+ `own_affine` is what an MRSI series' own NIfTI (the one `create_MRSI_nii` derives its affine
299
+ from) should carry so the low-res grid sits inside the real brain's footprint; `intensity_map`
300
+ is that same downsampled data, normalized, and is meant to drive `simulate_grid`'s
301
+ `intensity_map` directly -- real brain signal in, brain-shaped spectra out.
302
+ """
303
+ from scipy.ndimage import zoom
304
+
305
+ data, affine = _cropped_template()
306
+ voxel_native = np.diag(affine)[:3]
307
+ physical_extent = np.array(data.shape) * voxel_native
308
+ voxel_size = physical_extent / np.array(grid)
309
+
310
+ # grid_mode=True samples voxel *centers* under the same uniform physical_extent/grid stride
311
+ # own_affine assumes below -- the default corner-aligned mapping (native index 0 -> low-res
312
+ # index 0, native's *last* index -> low-res's last index) stretches across a shorter span than
313
+ # physical_extent implies, growing to a full blocky-voxel misalignment at the far edge.
314
+ zoom_factors = np.array(grid) / np.array(data.shape)
315
+ low_res = zoom(data, zoom_factors, order=1, grid_mode=True, mode="nearest")
316
+ if low_res.shape != tuple(grid):
317
+ padded = np.zeros(grid, dtype=np.float32)
318
+ sl = tuple(slice(0, min(a, b)) for a, b in zip(low_res.shape, grid, strict=True))
319
+ padded[sl] = low_res[sl]
320
+ low_res = padded
321
+
322
+ # `simulate_grid` scales each cell's whole complex spectrum (signal *and* noise) by this map,
323
+ # so a hard 0.0 outside the head would silence the noise floor there too -- a background voxel
324
+ # would come back a perfectly flat, noiseless zero instead of a real acquisition's noise-only
325
+ # spectrum. Floor it so background stays low-signal but still noisy.
326
+ intensity_map = (low_res / (low_res.max() + 1e-6)).astype(np.float32)
327
+ intensity_map = np.maximum(intensity_map, 0.0)
328
+ own_affine = np.diag([*voxel_size, 1.0])
329
+ own_affine[:3, 3] = affine[:3, 3]
330
+ # MRSISeries.create_MRSI_affine() always adds an extra half-*blocky*-voxel shift in x/y on top
331
+ # of its resize shift (tuned for real GE headers, where the "fine" reference affine needs it).
332
+ # For grid_mode=True zoom, blocky voxel i's true center is:
333
+ # native_translation + blocky_voxel*(i + 0.5) - native_voxel/2
334
+ # i.e. own_affine's translation should be native_translation - native_voxel/2 to land there once
335
+ # create_MRSI_affine's own +blocky_voxel/2 shift is added back on top -- the blocky_voxel terms
336
+ # cancel exactly, so the correction here is half a *native* voxel, not half the blocky one.
337
+ own_affine[:2, 3] -= voxel_native[:2] / 2
338
+ return own_affine, intensity_map