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,1399 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import zlib
5
+ from collections.abc import Sequence
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import Any, NamedTuple, TypedDict, Unpack
8
+
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ import numpy.typing as npt
12
+ import PIL.Image
13
+ from IPython.display import display as ipy_display
14
+ from loguru import logger
15
+ from matplotlib import colormaps, patches
16
+ from matplotlib.axes import Axes
17
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
18
+ from matplotlib.cm import ScalarMappable
19
+ from matplotlib.colors import Normalize
20
+ from matplotlib.contour import QuadContourSet
21
+ from matplotlib.figure import Figure
22
+ from matplotlib.image import AxesImage
23
+ from nibabel import affines, spatialimages
24
+
25
+ from .. import GESeries, utils
26
+ from ._widgets import ImageGridWidget, MRSIVoxelInspectorWidget, SliceViewerWidget
27
+
28
+ # WebP quality for the MRSI inspector's anatomical frames. These are display
29
+ # rasters -- already normalised, colormapped and alpha-blended to 8-bit -- not
30
+ # data, so lossy encoding costs nothing measurable: q92 holds ~41 dB PSNR
31
+ # against the exact composite while being ~7x smaller than the PNG equivalent.
32
+ MRSI_FRAME_QUALITY = 92
33
+
34
+ # WebP quality for `display_images` panels. Same reasoning as above -- these
35
+ # are colormapped 8-bit display rasters, not data -- but with an alpha channel,
36
+ # since NaN pixels stay transparent so the widget's background shows through.
37
+ GRID_FRAME_QUALITY = 92
38
+
39
+ # Below this many pixels a panel is encoded losslessly instead. Lossy WebP is
40
+ # tuned for photographs: on a 20x20 mask, where every pixel is a hard-edged
41
+ # block a reader looks *at* rather than through, and which the widget then
42
+ # blows up several times over, its ringing is plainly visible -- while the
43
+ # frame is a couple of kB either way. The threshold stays low because lossless
44
+ # is not free above it: at 90x90 it costs 4.4x the payload (0.85 -> 3.8 MB for
45
+ # a 700-panel grid) to fix artifacts nobody can see at that scale.
46
+ GRID_LOSSLESS_MAX_PIXELS = 64 * 64
47
+
48
+ # Panels per row in a `display_images` grid, before it wraps to a new one.
49
+ GRID_MAX_COLS = 5
50
+
51
+ # float16 tops out at 65504, and raw spectra routinely exceed that, so the
52
+ # buffer is divided by a scale factor before the cast and multiplied back in
53
+ # the browser. 32000 leaves an octave of headroom under the limit.
54
+ _F16_TARGET_MAX = 32000.0
55
+
56
+ # Warn past this uncompressed spectra size -- a big grid can otherwise silently
57
+ # produce a notebook hundreds of MB wide.
58
+ _SPECTRA_WARN_BYTES = 32 * 1024**2
59
+
60
+ DEFAULT_IMAGE_AX_PARAMS = {
61
+ "xlabel": "",
62
+ "xticks": [],
63
+ "ylabel": "",
64
+ "yticks": [],
65
+ }
66
+
67
+ DEFAULT_PARAMS = {
68
+ "cmap": "magma",
69
+ "ticker_steps": [1, 2, 4],
70
+ }
71
+
72
+ DEFAULT_MASK_PARAMS = {
73
+ "colors": "green",
74
+ "linewidths": 3,
75
+ }
76
+
77
+
78
+ def fast_bounds(
79
+ data: npt.NDArray,
80
+ max_target_samples: int = 50000,
81
+ p_lower: float = 1.0,
82
+ p_upper: float = 99.0,
83
+ ) -> tuple[float, float]:
84
+ """Quickly estimate lower/upper display bounds by sampling a subset of the data.
85
+
86
+ Samples a subset of the input data and calculates the specified
87
+ percentiles to estimate bounds suitable for visualization.
88
+
89
+ Parameters
90
+ ----------
91
+ data : ndarray
92
+ The input data array from which to estimate the bounds.
93
+ max_target_samples : int, optional
94
+ The maximum number of samples to use for estimating bounds. Defaults
95
+ to 50000.
96
+ p_lower : float, optional
97
+ The lower percentile to calculate. Defaults to 1.0 (the 1st
98
+ percentile).
99
+ p_upper : float, optional
100
+ The upper percentile to calculate. Defaults to 99.0 (the 99th
101
+ percentile).
102
+
103
+ Returns
104
+ -------
105
+ tuple[float, float]
106
+ The estimated lower and upper bounds for visualization.
107
+ """
108
+ # Sample the data by taking every nth element based on the sample_step
109
+ sample_step = max(1, data.size // max_target_samples)
110
+ logger.trace(
111
+ f"Sampling data with step size {sample_step} to estimate bounds "
112
+ f"from a total of {data.size} elements."
113
+ )
114
+ # First filter out nan values and then sample the data to ensure we are
115
+ # sampling valid values for bounds estimation
116
+ sampled_data = data[np.isfinite(data)]
117
+ sampled_data = sampled_data[::sample_step]
118
+
119
+ if sampled_data.size == 0:
120
+ logger.warning("Sampled data contains only NaN values. Returning (0, 0) as bounds.")
121
+ return 0.0, 1.0
122
+
123
+ # Calculate the lower and upper percentiles of the sampled data
124
+ lower_bound, upper_bound = np.percentile(sampled_data, [p_lower, p_upper])
125
+ logger.trace(
126
+ f"Sampled {sampled_data.size} elements for bounds estimation "
127
+ f"after excluding NaN values."
128
+ f"\nEstimated bounds: lower={lower_bound:2.2e}, upper={upper_bound:2.2e} "
129
+ f"using percentiles {p_lower} and {p_upper}."
130
+ )
131
+
132
+ return lower_bound, upper_bound
133
+
134
+
135
+ def _resolve_display_bounds(
136
+ data: npt.NDArray,
137
+ vmin: float | None,
138
+ vmax: float | None,
139
+ v_percentile: float = 1.0,
140
+ ) -> tuple[float, float]:
141
+ """Fill in whichever of `vmin`/`vmax` was not given from the data itself.
142
+
143
+ A caller is allowed to pin one bound and leave the other to the data, so
144
+ the estimate has to run whenever *either* is missing and then replace only
145
+ the missing one.
146
+ """
147
+ if vmin is not None and vmax is not None:
148
+ return vmin, vmax
149
+ new_vmin, new_vmax = fast_bounds(data, p_lower=v_percentile, p_upper=100 - v_percentile)
150
+ return (new_vmin if vmin is None else vmin, new_vmax if vmax is None else vmax)
151
+
152
+
153
+ def _widen_if_flat(low: float, high: float) -> tuple[float, float]:
154
+ """Give a zero-width range one unit of span so `Normalize` has one to map.
155
+
156
+ A constant panel (or an all-zero one under `zeros_as_nan`) still renders as
157
+ the colormap's low end either way, but a colorbar reading "0 to 0" is worse
158
+ than one reading "0 to 1".
159
+ """
160
+ return (low, high) if high > low else (low, low + 1.0)
161
+
162
+
163
+ def _encode_webp(
164
+ array: npt.NDArray,
165
+ mode: str | None = None,
166
+ *,
167
+ quality: int,
168
+ lossless: bool = False,
169
+ ) -> bytes:
170
+ """Encode one display raster to WebP bytes.
171
+
172
+ `method` trades encode time for size; the lossless path can afford the
173
+ slower 4 because it is only ever taken for panels under
174
+ `GRID_LOSSLESS_MAX_PIXELS`.
175
+ """
176
+ buf = io.BytesIO()
177
+ image = PIL.Image.fromarray(array, mode)
178
+ if lossless:
179
+ image.save(buf, format="webp", lossless=True, method=4)
180
+ else:
181
+ image.save(buf, format="webp", quality=quality, method=2)
182
+ return buf.getvalue()
183
+
184
+
185
+ def _mrsi_to_display_affine(
186
+ t1_nii: spatialimages.SpatialImage, MRSI: GESeries.MRSISeries
187
+ ) -> npt.NDArray:
188
+ """Build the affine mapping raw MRSI voxel indices to T1 display pixels.
189
+
190
+ The raw (blocky) grid is the reference in both directions: `RAW_exp`'s
191
+ affine is what the voxel picker and the voxel outlines are drawn in, and
192
+ `get_display_affine` bakes the orientation transpose that `orient_nifti`
193
+ applies to the anatomical volume, so the inverse of the latter composed
194
+ with the former is what carries one into the other.
195
+ """
196
+ mrsi_affine = np.asarray(MRSI.RAW_exp.nii.affine)
197
+ t1_display_affine = utils.nifti.get_display_affine(t1_nii)
198
+ mrsi_to_display = np.linalg.inv(t1_display_affine).dot(mrsi_affine)
199
+ logger.debug(f"T1 display affine:\n{t1_display_affine}")
200
+ logger.debug(f"MRSI affine:\n{mrsi_affine}")
201
+ logger.debug(f"MRSI to display affine:\n{mrsi_to_display}")
202
+ return mrsi_to_display
203
+
204
+
205
+ def display_nifti(
206
+ nii: spatialimages.SpatialImage | GESeries.NiiBase,
207
+ orientation: tuple[str, str, str] | None = None,
208
+ **kwargs,
209
+ ) -> None:
210
+ """Orient a NIfTI image and display it via `display_images`.
211
+
212
+ Parameters
213
+ ----------
214
+ nii : nibabel.spatialimages.SpatialImage or GESeries.NiiBase
215
+ The NIfTI image (or wrapper) to display.
216
+ orientation : tuple[str, str, str], optional
217
+ Target axis orientation codes, by default None.
218
+ **kwargs
219
+ Additional keyword arguments forwarded to `display_images`.
220
+
221
+ Returns
222
+ -------
223
+ None
224
+ See `display_images` -- the image grid is displayed, not returned.
225
+ """
226
+ if isinstance(nii, GESeries.NiiBase):
227
+ nii = nii.nii
228
+ return display_images(utils.nifti.orient_nifti(nii, orientation), **kwargs)
229
+
230
+
231
+ def display_images(
232
+ images: npt.NDArray | list,
233
+ titles: list[str] | None = None,
234
+ fig_title: str = "",
235
+ cmap: str = DEFAULT_PARAMS["cmap"],
236
+ imshow_kws: dict[str, Any] | None = None,
237
+ colorbar: bool = False,
238
+ colorbar_kws: dict[str, Any] | None = None,
239
+ fig_kws: dict[str, Any] | None = None,
240
+ vmin: float | None = None,
241
+ vmax: float | None = None,
242
+ v_percentile: float = 1.0,
243
+ zeros_as_nan: bool = False,
244
+ **kwargs,
245
+ ) -> None:
246
+ """Display a grid of 2D/3D/4D images, with a slice slider for 3D/4D input.
247
+
248
+ The panels are rendered straight to images at the data's own resolution
249
+ and handed to an interactive widget that draws the titles and colorbar
250
+ around them as HTML; no matplotlib `Figure` is involved, which is why
251
+ nothing is returned.
252
+
253
+ Parameters
254
+ ----------
255
+ images : ndarray or list
256
+ Image data (2D, 3D, or 4D) or a list of arrays to stack along a new
257
+ last axis.
258
+ titles : list[str], optional
259
+ Titles for each image, by default None.
260
+ fig_title : str, optional
261
+ Overall figure title, by default "".
262
+ cmap : str, optional
263
+ Colormap to use, by default the module's default colormap.
264
+ imshow_kws : dict, optional
265
+ Accepted and ignored: there is no `imshow` call left to forward them
266
+ to. Kept so existing call sites keep working.
267
+ colorbar : bool, optional
268
+ Whether to display a colorbar, by default False.
269
+ colorbar_kws : dict, optional
270
+ Only the `"mode"` key is read, either `"single"` (one colorbar for
271
+ the grid) or `"each"` (one per panel, autoscaled to that panel and
272
+ slice). Remaining keys are ignored -- the colorbar is HTML now, not a
273
+ matplotlib artist.
274
+ fig_kws : dict, optional
275
+ Accepted and ignored: there is no `Figure` left to configure. Kept so
276
+ existing call sites keep working.
277
+ vmin : float, optional
278
+ Lower display bound. Estimated from the data if omitted.
279
+ vmax : float, optional
280
+ Upper display bound. Estimated from the data if omitted.
281
+ v_percentile : float, optional
282
+ Percentile used to estimate `vmin`/`vmax` when not given, by
283
+ default 1.0.
284
+ zeros_as_nan : bool, optional
285
+ If True, treat zero values as NaN for display, by default False.
286
+ **kwargs
287
+ Accepted and ignored: axis parameters such as `aspect`, `xlabel` or
288
+ `xticks` have no Axes to apply to. Kept so existing call sites keep
289
+ working.
290
+
291
+ Returns
292
+ -------
293
+ None
294
+ The grid is displayed as a widget. For a single-slice input the
295
+ widget simply has no slider.
296
+ """
297
+ if titles is None:
298
+ titles = []
299
+
300
+ # Convert list of images to 4D numpy array
301
+ if isinstance(images, list):
302
+ images = np.stack(images, axis=-1)
303
+
304
+ # Cast images to float32 for display
305
+ images = images.astype(np.float32)
306
+ original_dims = images.ndim
307
+
308
+ if original_dims == 2:
309
+ num_images = 1
310
+ num_cols = 1
311
+ slice_idx = 0
312
+ images = images[:, :, np.newaxis, np.newaxis]
313
+ elif original_dims == 3:
314
+ num_images = 1
315
+ num_cols = 1
316
+ # Put the images into a 4D array for easier handling later
317
+ slice_idx = images.shape[2] // 2
318
+ images = images[:, :, :, np.newaxis]
319
+ elif original_dims == 4:
320
+ num_images = images.shape[3]
321
+ # Nominal columns per row. The widget wraps to fewer when the pane is
322
+ # too narrow for them, so this is the *most* a row will ever hold.
323
+ num_cols = min(GRID_MAX_COLS, num_images)
324
+ slice_idx = images.shape[2] // 2
325
+ num_rows = -(-num_images // GRID_MAX_COLS)
326
+ logger.debug(f"Displaying {num_images} images in a {num_rows}x{num_cols} grid.")
327
+ # Check that 3rd dimension is the same for all images
328
+ for i in range(1, num_images):
329
+ if images.shape[2] != images[:, :, :, i].shape[2]:
330
+ raise ValueError(
331
+ "All images must have the same number of slices in the 3rd dimension."
332
+ )
333
+ else:
334
+ raise ValueError("Input images must be either 3D or 4D.")
335
+
336
+ # Set 0 values in images to nan for better visualization and colorbar scaling
337
+ if vmin is None or vmax is None or zeros_as_nan:
338
+ logger.debug(
339
+ "Setting 0 values in images to NaN for better visualization and colorbar scaling."
340
+ )
341
+ zeros_nan_images = images.copy()
342
+ zeros_nan_images[zeros_nan_images == 0] = np.nan
343
+ # Check that now not all values are nan
344
+ if np.isnan(zeros_nan_images).all():
345
+ logger.warning(
346
+ "All values in images are 0, so setting zeros_as_nan to False "
347
+ "to avoid all values being NaN."
348
+ )
349
+ zeros_as_nan = False
350
+ zeros_nan_images = images
351
+ images = zeros_nan_images
352
+
353
+ vmin, vmax = _resolve_display_bounds(images, vmin, vmax, v_percentile)
354
+
355
+ if vmin == vmax or vmin is None or vmax is None or np.isnan([vmin, vmax]).any():
356
+ logger.debug(
357
+ f"vmin ({vmin}) and vmax ({vmax}) are equal or NaN. Adjusting vmin "
358
+ f"and vmax to the min and max of the images for better visualization."
359
+ )
360
+ vmin, vmax = np.nanmin(images), np.nanmax(images)
361
+ # If vmin and vmax are still equal after adjustment, set vmin to 0 to
362
+ # avoid errors in imshow
363
+ if vmin == vmax:
364
+ logger.debug(
365
+ "vmin and vmax are still equal after adjustment. This is likely "
366
+ "to all non zeros values being equal (e.g. in a mask). Setting "
367
+ "vmin to 0 for better visualization."
368
+ )
369
+ vmin = 0
370
+
371
+ logger.debug(f"Setting vmin: {vmin:4g}, vmax: {vmax:4g}")
372
+
373
+ ignored = {
374
+ name: value
375
+ for name, value in (("imshow_kws", imshow_kws), ("fig_kws", fig_kws), *kwargs.items())
376
+ if value is not None
377
+ }
378
+ if ignored:
379
+ logger.debug(
380
+ f"Ignoring matplotlib-only arguments {sorted(ignored)}: the panels are rendered "
381
+ "directly to images, so there is no Figure or Axes to configure."
382
+ )
383
+
384
+ if colorbar:
385
+ colorbar_mode = colorbar_kws.pop("mode", "single") if colorbar_kws is not None else "single"
386
+ else:
387
+ colorbar_mode = ""
388
+
389
+ frames, bounds = _render_image_grid_frames(
390
+ images,
391
+ num_images=num_images,
392
+ cmap=cmap,
393
+ vmin=vmin,
394
+ vmax=vmax,
395
+ per_panel_bounds=colorbar_mode == "each",
396
+ )
397
+ ipy_display(
398
+ ImageGridWidget(
399
+ frames=frames,
400
+ bounds=bounds,
401
+ colormap_stops=_colormap_stops(cmap),
402
+ num_cols=num_cols,
403
+ titles=titles,
404
+ fig_title=fig_title,
405
+ colorbar_mode=colorbar_mode,
406
+ initial_index=slice_idx,
407
+ )
408
+ )
409
+ return None
410
+
411
+
412
+ def _colormap_stops(cmap: str, num_stops: int = 64) -> list[str]:
413
+ """Sample a matplotlib colormap into CSS hex stops, low to high.
414
+
415
+ The widget's colorbar is a CSS `linear-gradient`, so it needs the colormap
416
+ as colours rather than as a `Colormap`. Sampling it here (rather than
417
+ reimplementing the colormap in JS) is what keeps the bar and the pixels it
418
+ explains provably the same colormap. 64 stops is well past the point where
419
+ the browser's linear interpolation between them is distinguishable from
420
+ the real thing, and costs ~0.5 kB.
421
+ """
422
+ colors = colormaps[cmap](np.linspace(0, 1, num_stops), bytes=True)
423
+ return [f"#{r:02x}{g:02x}{b:02x}" for r, g, b, _ in colors]
424
+
425
+
426
+ class _Layer(NamedTuple):
427
+ """One colormapped volume in a frame, composited over the ones before it.
428
+
429
+ `bounds` is either a single `(vmin, vmax)` used for every frame, or one
430
+ pair per job when each frame autoscales to itself. `alpha` is matplotlib's
431
+ `imshow(alpha=...)`: the base layer is opaque, layers above it blend.
432
+ """
433
+
434
+ volume: npt.NDArray
435
+ cmap: str
436
+ bounds: tuple[float, float] | list[tuple[float, float]]
437
+ alpha: float = 1.0
438
+
439
+
440
+ def _render_frames(
441
+ layers: Sequence[_Layer],
442
+ jobs: Sequence[tuple[int, ...]],
443
+ *,
444
+ quality: int,
445
+ lossless: bool = False,
446
+ keep_alpha: bool = False,
447
+ max_workers: int = 8,
448
+ ) -> list[bytes]:
449
+ """Render a batch of colormapped rasters to WebP bytes, one per job.
450
+
451
+ The single frame renderer behind every widget in this module: the
452
+ `display_images` grid (one opaque layer, one job per (slice, panel)) and
453
+ the MRSI inspector's anatomicals (a gray T1 under a half-transparent magma
454
+ overlay, one job per slice) differ only in their layers and in which axes
455
+ a job indexes. A job is the trailing index into each layer's volume, so
456
+ `volume[:, :, *job]` is the 2-D image that frame colormaps.
457
+
458
+ These frames are pure raster -- no ticks, labels, titles, colorbar or
459
+ contours; the widgets rebuild all of that as DOM around them. Going
460
+ through a matplotlib `Figure`/Agg canvas to produce that costs ~130 ms per
461
+ frame, almost all of it rasterization and PNG encoding, for arithmetic
462
+ that is really only `cmap(norm(arr))` plus an alpha blend. So the Figure
463
+ is gone: matplotlib still *supplies* the colormaps (via `ScalarMappable`,
464
+ so nothing is reimplemented and the panels, the colorbar gradient and the
465
+ inspector can't drift apart), and the blend and encode are done directly.
466
+
467
+ Frames come out at the volume's native `(nrows, ncols)` rather than a
468
+ dpi-derived size, and the browser scales them to whatever width it has.
469
+ The client side is resolution-independent -- the SVG voxel overlay is
470
+ sized by a data-coordinate `viewBox` and clicks are mapped through the
471
+ rendered `rect.width` -- so this is purely sharper.
472
+
473
+ WebP rather than PNG: at native resolution an anatomical frame is ~318 kB
474
+ as PNG but ~42 kB as WebP q92 (41 dB PSNR against the exact composite),
475
+ which is what keeps a widget's payload manageable. Pass `lossless` for
476
+ small hard-edged images, where the ringing shows and the payload argument
477
+ disappears. `keep_alpha` carries the colormap's "bad" pixels (NaN) through
478
+ as transparent instead of flattening them onto a baked-in background.
479
+
480
+ WebP encoding releases the GIL, so frames are encoded on a thread pool.
481
+ """
482
+ # Layers scaled the same way for every frame get one `ScalarMappable`
483
+ # for the whole batch; per-job bounds have to build theirs per frame.
484
+ shared = [
485
+ None
486
+ if isinstance(layer.bounds, list)
487
+ else ScalarMappable(Normalize(*layer.bounds), layer.cmap)
488
+ for layer in layers
489
+ ]
490
+
491
+ def render(index: int) -> bytes:
492
+ selector = (slice(None), slice(None), *jobs[index])
493
+ composite: npt.NDArray | None = None
494
+ for layer, mappable in zip(layers, shared):
495
+ if mappable is None:
496
+ bounds = layer.bounds[index] # pyright: ignore[reportIndexIssue]
497
+ mappable = ScalarMappable(Normalize(*bounds), layer.cmap)
498
+ rgba = mappable.to_rgba(layer.volume[selector], bytes=True)
499
+ if composite is None:
500
+ composite = rgba
501
+ continue
502
+ # Reproduce matplotlib's compositing of a second `imshow(alpha=)`
503
+ # over the first: the layer's alpha is scaled by the overlay's own
504
+ # per-pixel alpha, so pixels the colormap marks "bad" (NaN, alpha
505
+ # 0) let the layer below through untouched instead of being
506
+ # blended toward the bad colour.
507
+ weight = (rgba[..., 3:4].astype(np.uint16) * round(layer.alpha * 255)) // 255
508
+ composite[..., :3] = (
509
+ composite[..., :3] * (255 - weight) + rgba[..., :3] * weight
510
+ ) // 255
511
+
512
+ assert composite is not None, "at least one layer is required"
513
+ if keep_alpha:
514
+ return _encode_webp(composite, "RGBA", quality=quality, lossless=lossless)
515
+ return _encode_webp(
516
+ np.ascontiguousarray(composite[..., :3]), quality=quality, lossless=lossless
517
+ )
518
+
519
+ logger.debug(f"Rendering {len(jobs)} frames on {max_workers} threads")
520
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
521
+ return list(pool.map(render, range(len(jobs))))
522
+
523
+
524
+ def _panel_bounds(panel: npt.NDArray) -> tuple[float, float]:
525
+ """Autoscale one panel the way `AxesImage.autoscale` did: to its own extent.
526
+
527
+ Guards the two cases matplotlib's `Normalize` would otherwise have to
528
+ absorb: an all-NaN panel (nothing to scale to) and a constant one (a zero
529
+ -width range maps every pixel to the colormap's low end).
530
+ """
531
+ if not np.isfinite(panel).any():
532
+ return 0.0, 1.0
533
+ return _widen_if_flat(float(np.nanmin(panel)), float(np.nanmax(panel)))
534
+
535
+
536
+ def _render_image_grid_frames(
537
+ images: npt.NDArray,
538
+ *,
539
+ num_images: int,
540
+ cmap: str,
541
+ vmin: float | None,
542
+ vmax: float | None,
543
+ per_panel_bounds: bool,
544
+ quality: int = GRID_FRAME_QUALITY,
545
+ max_workers: int = 8,
546
+ ) -> tuple[list[list[bytes]], list[list[tuple[float, float]]]]:
547
+ """Shape a `display_images` grid for `_render_frames`: one job per panel.
548
+
549
+ Rasterising is entirely `_render_frames`' job; what is grid-specific is
550
+ the bounds each panel is normalised with -- one shared pair, or, under
551
+ `colorbar_kws={"mode": "each"}`, one per panel and slice -- and the
552
+ slice-major nesting `_widgets.ImageGridWidget` indexes frames by. The
553
+ bounds are returned alongside because the widget prints them on its
554
+ colorbar.
555
+
556
+ Panels are rendered with alpha: values the colormap marks "bad" (NaN, so
557
+ also everything `zeros_as_nan` masked out) stay transparent and take the
558
+ panel's background in both light and dark themes instead of a baked-in
559
+ figure colour. Small panels are encoded losslessly -- lossy WebP's ringing
560
+ is invisible on a 512-row anatomical and obvious on a 20x20 mask, where
561
+ the payload argument for lossy disappears anyway (see
562
+ `GRID_LOSSLESS_MAX_PIXELS`).
563
+ """
564
+ n_slices = images.shape[2]
565
+ if per_panel_bounds:
566
+ bounds = [
567
+ [_panel_bounds(images[:, :, s, p]) for p in range(num_images)] for s in range(n_slices)
568
+ ]
569
+ else:
570
+ shared = _widen_if_flat(
571
+ 0.0 if vmin is None else float(vmin), 1.0 if vmax is None else float(vmax)
572
+ )
573
+ bounds = [[shared] * num_images for _ in range(n_slices)]
574
+
575
+ jobs = [(s, p) for s in range(n_slices) for p in range(num_images)]
576
+ rendered = _render_frames(
577
+ [_Layer(images, cmap, [bounds[s][p] for s, p in jobs])],
578
+ jobs,
579
+ quality=quality,
580
+ lossless=images.shape[0] * images.shape[1] <= GRID_LOSSLESS_MAX_PIXELS,
581
+ keep_alpha=True,
582
+ max_workers=max_workers,
583
+ )
584
+ return [rendered[s * num_images : (s + 1) * num_images] for s in range(n_slices)], bounds
585
+
586
+
587
+ def overlay_nifti_data_on_T1(
588
+ t1_nii: spatialimages.SpatialImage,
589
+ data_nii: spatialimages.SpatialImage,
590
+ orientation: tuple[str, str, str] | None = None,
591
+ display_plane: utils.nifti.DISPLAY_PLANES | None = None,
592
+ resample_kwargs: dict[str, Any] | None = None,
593
+ mask: npt.NDArray | GESeries.NiiBase | None = None,
594
+ **kwargs,
595
+ ) -> tuple[Figure, list[Axes]] | None:
596
+ """Resample a data NIfTI into T1 space and overlay it on the T1 image.
597
+
598
+ Parameters
599
+ ----------
600
+ t1_nii : nibabel.spatialimages.SpatialImage
601
+ Anatomical T1 NIfTI image to overlay onto.
602
+ data_nii : nibabel.spatialimages.SpatialImage
603
+ Data NIfTI image to resample and overlay.
604
+ orientation : tuple[str, str, str], optional
605
+ Target axis orientation codes, by default None.
606
+ display_plane : utils.nifti.DISPLAY_PLANES, optional
607
+ Anatomical plane to display, by default None.
608
+ resample_kwargs : dict, optional
609
+ Extra keyword arguments passed to `resample_and_orient_nifti`.
610
+ mask : ndarray or GESeries.NiiBase, optional
611
+ Boolean mask applied to the data before or after resampling,
612
+ depending on whether its shape matches the data or the T1 image.
613
+ **kwargs
614
+ Additional keyword arguments forwarded to `overlay_image_data_on_T1`.
615
+
616
+ Returns
617
+ -------
618
+ tuple[Figure, list[Axes]] or None
619
+ See `overlay_image_data_on_T1`.
620
+ """
621
+ # If mask in kwargs, apply mask to data_nii before resampling and overlaying
622
+ apply_mask_after_resampling: bool = False
623
+ mask_to_pass_on: npt.NDArray[np.bool_] | None = None
624
+ if mask is not None:
625
+ # assert mask is a numpy bool array
626
+ if isinstance(mask, GESeries.NiiBase):
627
+ mask_to_pass_on = mask.images(
628
+ orientation=orientation, display_plane=display_plane
629
+ ).astype(bool)
630
+ elif isinstance(mask, np.ndarray) and mask.dtype == bool:
631
+ if mask.shape != data_nii.shape:
632
+ # Check if mask shape matches t1_shape than
633
+ if mask.shape == t1_nii.shape:
634
+ logger.debug(
635
+ "Mask shape matches T1 NIfTI but not data NIfTI. "
636
+ "Will apply mask after resampling."
637
+ )
638
+ apply_mask_after_resampling = True
639
+ else:
640
+ raise ValueError(
641
+ f"Mask must have the same shape as data NIfTI.\n"
642
+ f"Shape of mask: {mask.shape}, "
643
+ f"shape of data NIfTI: {data_nii.shape}"
644
+ )
645
+ else:
646
+ masked_data = np.where(mask, data_nii.get_fdata(), np.nan)
647
+ data_nii = spatialimages.SpatialImage(
648
+ dataobj=masked_data, # pyright: ignore[reportArgumentType]
649
+ affine=data_nii.affine,
650
+ header=data_nii.header,
651
+ )
652
+ else:
653
+ raise ValueError("Mask must be a numpy array of boolean type.")
654
+
655
+ # Resample data nifti to T1 nifti space before overlaying
656
+ t1_images, data_images = utils.nifti.resample_and_orient_nifti(
657
+ source_nii=data_nii,
658
+ target_nii=t1_nii,
659
+ orientation=orientation,
660
+ display_plane=display_plane,
661
+ **(resample_kwargs or {}),
662
+ )
663
+
664
+ if apply_mask_after_resampling and mask is not None:
665
+ mask_to_pass_on = mask
666
+
667
+ return overlay_image_data_on_T1(t1_images, data_images, mask=mask_to_pass_on, **kwargs)
668
+
669
+
670
+ def overlay_image_data_on_T1(
671
+ t1_images: npt.NDArray,
672
+ data_images: npt.NDArray,
673
+ mask: npt.NDArray[np.bool_] | None = None,
674
+ mask_contour: npt.NDArray[np.bool_] | bool | None = None,
675
+ only_overlay: bool = False,
676
+ mask_kwargs: dict[str, Any] | None = None,
677
+ **kwargs,
678
+ ) -> tuple[Figure, list[Axes]] | None:
679
+ """Display T1, resampled data, and overlay images side by side with a slice slider.
680
+
681
+ Parameters
682
+ ----------
683
+ t1_images : ndarray
684
+ T1-weighted anatomical image data.
685
+ data_images : ndarray
686
+ Data image, resampled into T1 space, to overlay.
687
+ mask : ndarray, optional
688
+ Boolean mask applied to the overlay data, by default None.
689
+ mask_contour : ndarray or bool, optional
690
+ Mask (or `True` to reuse `mask`) whose contour is drawn on the
691
+ overlay axis, by default None.
692
+ only_overlay : bool, optional
693
+ If True, only show the overlay axis instead of T1, data, and
694
+ overlay side by side, by default False.
695
+ mask_kwargs : dict, optional
696
+ Keyword arguments customizing the mask contour's appearance.
697
+ **kwargs
698
+ Additional keyword arguments forwarded to
699
+ `overlay_image_data_on_T1_on_ax`.
700
+
701
+ Returns
702
+ -------
703
+ tuple[Figure, list[Axes]] or None
704
+ For a single-slice input, the created figure and its axes. For
705
+ multi-slice input, an interactive slice-scrubbing widget is
706
+ displayed instead and `None` is returned.
707
+ """
708
+ if data_images.shape != t1_images.shape:
709
+ raise ValueError(
710
+ f"T1 images and data images must have the same shape for overlay. "
711
+ f"T1 shape: {t1_images.shape}, data shape: {data_images.shape}"
712
+ )
713
+
714
+ if t1_images.ndim < 3:
715
+ slice_idx = 0
716
+ t1_images = t1_images[:, :, np.newaxis]
717
+ data_images = data_images[:, :, np.newaxis]
718
+ if mask is not None:
719
+ mask = mask[:, :, np.newaxis]
720
+ if isinstance(mask_contour, np.ndarray):
721
+ mask_contour = mask_contour[:, :, np.newaxis]
722
+ else:
723
+ slice_idx = t1_images.shape[2] // 2
724
+
725
+ # `mask_contour` only draws a contour when it's `True` (reuse `mask`); an
726
+ # explicit array is not applied here (matches this function's long-standing
727
+ # behavior; `overlay_image_data_on_T1_on_ax` is the one that honours it).
728
+ resolved_mask_contour = mask if mask_contour is True else None
729
+
730
+ overlay_kwargs = dict(
731
+ only_overlay=only_overlay,
732
+ mask=mask,
733
+ resolved_mask_contour=resolved_mask_contour,
734
+ mask_kwargs=mask_kwargs,
735
+ kwargs=kwargs,
736
+ )
737
+
738
+ n_slices = t1_images.shape[2]
739
+ if n_slices > 1:
740
+ frames = _render_overlay_frames(
741
+ t1_images, data_images, list(range(n_slices)), **overlay_kwargs
742
+ )
743
+ ipy_display(SliceViewerWidget(frames=frames, n_slices=n_slices, initial_index=slice_idx))
744
+ return None
745
+
746
+ fig = plt.figure(figsize=(12, 4))
747
+ axes = [fig.subplots(1, 1)] if only_overlay else list(fig.subplots(1, 3))
748
+ _draw_overlay_slice(fig, axes, t1_images, data_images, slice_idx, **overlay_kwargs)
749
+ return fig, axes
750
+
751
+
752
+ def _draw_overlay_slice(
753
+ fig: Figure,
754
+ axes: list[Axes],
755
+ t1_images: npt.NDArray,
756
+ data_images: npt.NDArray,
757
+ slice_idx: int,
758
+ *,
759
+ only_overlay: bool,
760
+ mask: npt.NDArray[np.bool_] | None,
761
+ resolved_mask_contour: npt.NDArray[np.bool_] | None,
762
+ mask_kwargs: dict[str, Any] | None,
763
+ kwargs: dict[str, Any],
764
+ ) -> None:
765
+ """Draw one slice of the `overlay_image_data_on_T1` panels onto given axes."""
766
+ axes_idx = 0
767
+ if not only_overlay:
768
+ overlay_image_data_on_T1_on_ax(
769
+ t1_images[:, :, slice_idx], ax=axes[axes_idx], cmap="gray", **kwargs
770
+ )
771
+ axes[axes_idx].set_title("T1 Image")
772
+ axes_idx += 1
773
+ overlay_image_data_on_T1_on_ax(data_images[:, :, slice_idx], ax=axes[axes_idx], **kwargs)
774
+ axes[axes_idx].set_title("Resampled Data")
775
+ axes_idx += 1
776
+
777
+ overlay_image_data_on_T1_on_ax(
778
+ t1_images[:, :, slice_idx],
779
+ data_images[:, :, slice_idx],
780
+ mask=mask[:, :, slice_idx] if mask is not None else None,
781
+ mask_contour=resolved_mask_contour[:, :, slice_idx]
782
+ if resolved_mask_contour is not None
783
+ else None,
784
+ ax=axes[axes_idx],
785
+ mask_kwargs=mask_kwargs,
786
+ **kwargs,
787
+ )
788
+ axes[axes_idx].set_title("Overlay")
789
+
790
+
791
+ def _render_overlay_frames(
792
+ t1_images: npt.NDArray, data_images: npt.NDArray, slice_indices: list[int], **kwargs
793
+ ) -> list[bytes]:
794
+ """Render a batch of `overlay_image_data_on_T1` slices to PNG bytes.
795
+
796
+ Builds one Figure/axes for the whole batch (off the pyplot registry) and
797
+ reuses it across slices, clearing each axis before redrawing — the
798
+ per-slice content (imshow bounds, mask contour) isn't reusable in place
799
+ since it's recomputed per slice, but the figure/gridspec construction is.
800
+ """
801
+ only_overlay = kwargs["only_overlay"]
802
+ fig = Figure(figsize=(12, 4))
803
+ FigureCanvasAgg(fig)
804
+ axes = [fig.subplots(1, 1)] if only_overlay else list(fig.subplots(1, 3))
805
+
806
+ frames = []
807
+ for i, slice_idx in enumerate(slice_indices):
808
+ if i:
809
+ for ax in axes:
810
+ ax.cla()
811
+ _draw_overlay_slice(fig, axes, t1_images, data_images, slice_idx, **kwargs)
812
+ buf = io.BytesIO()
813
+ fig.savefig(buf, format="png")
814
+ frames.append(buf.getvalue())
815
+ return frames
816
+
817
+
818
+ def overlay_image_data_on_T1_on_ax(
819
+ base_image: npt.NDArray,
820
+ overlay_image: npt.NDArray | None = None,
821
+ mask: npt.NDArray[np.bool_] | None = None,
822
+ mask_contour: npt.NDArray[np.bool_] | bool | None = None,
823
+ ax: Axes | None = None,
824
+ cmap: str = DEFAULT_PARAMS["cmap"],
825
+ alpha: float = 0.5,
826
+ mask_kwargs: dict[str, Any] | None = None,
827
+ figsize: tuple[int, int] = (4, 4),
828
+ **kwargs,
829
+ ) -> (
830
+ tuple[list[AxesImage], QuadContourSet | None]
831
+ | tuple[Figure, Axes, list[AxesImage], QuadContourSet | None]
832
+ ):
833
+ """Draw a single 2D anatomical image, optionally with a data overlay/mask.
834
+
835
+ If `ax` is omitted, creates its own figure and axes and returns them
836
+ alongside the plotted images/contours.
837
+ """
838
+ created_fig = None
839
+ if ax is None:
840
+ created_fig, ax = plt.subplots(figsize=figsize)
841
+
842
+ # Copy default image params and update with any provided kwargs
843
+ remove_keys = ["vmin", "vmax", "v_percentile"]
844
+ # Adjust aspect ratio based on image shape
845
+ aspect_ratio = base_image.shape[1] / base_image.shape[0]
846
+ kwargs.setdefault("aspect", aspect_ratio)
847
+ logger.trace(f"Setting image aspect ratio to {kwargs['aspect']:.2f}.")
848
+ image_params = DEFAULT_IMAGE_AX_PARAMS.copy()
849
+ image_params.update(kwargs)
850
+ image_params = {k: v for k, v in image_params.items() if k not in remove_keys}
851
+
852
+ im: list[AxesImage] = []
853
+ if overlay_image is None and mask is not None:
854
+ base_image = np.where(mask, base_image, np.nan)
855
+ # If there is a base image and an overlay image, change the cmap for the
856
+ # base image to gray and the overlay image to the provided cmap
857
+ if overlay_image is not None:
858
+ cmap_base = "gray"
859
+ else:
860
+ cmap_base = cmap
861
+
862
+ # Get vmin and vmax bounds for anatomical image by default
863
+ anat_vmin, anat_vmax = fast_bounds(base_image, p_lower=1.0, p_upper=99.0)
864
+
865
+ im.append(ax.imshow(base_image, origin="upper", cmap=cmap_base, vmin=anat_vmin, vmax=anat_vmax))
866
+
867
+ # See if vmin and vmax are provided in kwargs and apply to data image
868
+ if overlay_image is None:
869
+ ax.set(**image_params)
870
+ if created_fig is not None:
871
+ return created_fig, ax, im, None
872
+ return im, None
873
+
874
+ vmin, vmax = _resolve_display_bounds(
875
+ overlay_image,
876
+ kwargs.pop("vmin", None),
877
+ kwargs.pop("vmax", None),
878
+ kwargs.pop("v_percentile", 1.0),
879
+ )
880
+
881
+ logger.debug(f"Setting overlay vmin: {vmin:4g}, vmax: {vmax:4g}")
882
+ # Assert that t1_images and data_images have the same shape
883
+ if base_image.shape != overlay_image.shape:
884
+ raise ValueError("T1 images and data images must have the same shape for overlay.")
885
+ if mask is not None and mask.shape != overlay_image.shape:
886
+ raise ValueError(
887
+ f"Mask must have the same shape as data images. "
888
+ f"Mask shape: {mask.shape}, data images shape: {overlay_image.shape}"
889
+ )
890
+ if mask is not None:
891
+ overlay_image = np.where(mask, overlay_image, np.nan)
892
+ logger.trace(
893
+ f"Applied mask to overlay image. Mask shape: {mask.shape}, mask dtype: {mask.dtype}"
894
+ )
895
+
896
+ im.append(
897
+ ax.imshow(
898
+ overlay_image,
899
+ origin="upper",
900
+ cmap=cmap,
901
+ alpha=alpha,
902
+ vmin=vmin,
903
+ vmax=vmax,
904
+ )
905
+ )
906
+
907
+ if mask_contour is False:
908
+ mask_contour = None
909
+ elif mask_contour is True:
910
+ mask_contour = mask
911
+
912
+ if mask_contour is not None:
913
+ mask_contour = mask_contour.astype(np.bool_)
914
+ contour_kwargs = DEFAULT_MASK_PARAMS.copy()
915
+ if mask_kwargs is not None:
916
+ contour_kwargs.update(mask_kwargs)
917
+ logger.debug(f"Applying mask contour with parameters: {contour_kwargs}")
918
+ contours = ax.contour(mask_contour, **contour_kwargs)
919
+ else:
920
+ contours = None
921
+
922
+ ax.set(**image_params)
923
+ if created_fig is not None:
924
+ return created_fig, ax, im, contours
925
+ return im, contours
926
+
927
+
928
+ def inspect_MRSI_spectra(
929
+ T1: GESeries.MRISeries,
930
+ MRSI: GESeries.MRSISeries,
931
+ blocky: bool = True,
932
+ magnitude: bool = False,
933
+ autophase: bool = True,
934
+ ) -> None:
935
+ """Display an interactive widget to inspect MRSI spectra over a T1 image.
936
+
937
+ Shows the T1 image with an MRSI overlay and a spectrum plot; clicking,
938
+ the keyboard arrows, or the slice slider update the selected voxel and
939
+ its spectrum. All interaction runs client-side (no Python kernel needed
940
+ after the widget is displayed): every anatomical slice is pre-rendered,
941
+ and every voxel's spectrum is precomputed into a single buffer embedded
942
+ in the widget.
943
+
944
+ Parameters
945
+ ----------
946
+ T1 : GESeries.MRISeries
947
+ The T1-weighted anatomical series.
948
+ MRSI : GESeries.MRSISeries
949
+ The MRSI series to inspect.
950
+ blocky : bool, optional
951
+ If True, resample the raw (blocky) MRSI grid rather than the
952
+ interpolated NIfTI, by default True.
953
+ magnitude : bool, optional
954
+ If True, display the magnitude spectrum, by default False.
955
+ autophase : bool, optional
956
+ If True (and `magnitude` is False), phase each voxel independently
957
+ before display, by default True. See `mrsi_spectra_for_display` for
958
+ why this is done per voxel rather than through a single global fit.
959
+ """
960
+ t1_images = T1.images()
961
+ if blocky:
962
+ t1_images, mrsi_images = utils.nifti.resample_and_orient_nifti(
963
+ source_nii=MRSI.RAW_exp.nii,
964
+ target_nii=T1.nii,
965
+ )
966
+ else:
967
+ t1_images, mrsi_images = utils.nifti.resample_and_orient_nifti(
968
+ source_nii=MRSI.nii, target_nii=T1.nii
969
+ )
970
+
971
+ n_anat_slices = t1_images.shape[2]
972
+ initial_slice = n_anat_slices // 2
973
+
974
+ mrsi_to_display = _mrsi_to_display_affine(T1.nii, MRSI)
975
+ display_to_mrsi = np.linalg.inv(mrsi_to_display)
976
+
977
+ nx, ny, n_mrsi_slices = MRSI.spec.shape[1], MRSI.spec.shape[2], MRSI.spec.shape[3]
978
+ initial_voxel = (nx // 2, ny // 2, n_mrsi_slices // 2)
979
+
980
+ anat_vmin, anat_vmax = fast_bounds(t1_images)
981
+ mrsi_vmin, mrsi_vmax = fast_bounds(mrsi_images)
982
+ logger.debug(f"Anatomical image bounds for display: vmin={anat_vmin:4g}, vmax={anat_vmax:4g}")
983
+ logger.debug(f"MRSI image bounds for display: vmin={mrsi_vmin:4g}, vmax={mrsi_vmax:4g}")
984
+
985
+ def mrsi_slice_for(anat_slice_idx: int) -> int:
986
+ raw = affines.apply_affine(display_to_mrsi, [0, 0, anat_slice_idx])[2]
987
+ return int(np.clip(np.round(raw), 0, n_mrsi_slices - 1))
988
+
989
+ logger.debug("Starting rendering of Left frames")
990
+
991
+ left_frames = _render_frames(
992
+ [
993
+ _Layer(t1_images, "gray", (anat_vmin, anat_vmax)),
994
+ _Layer(mrsi_images, "magma", (mrsi_vmin, mrsi_vmax), alpha=0.5),
995
+ ],
996
+ [(s,) for s in range(n_anat_slices)],
997
+ quality=MRSI_FRAME_QUALITY,
998
+ )
999
+ slice_titles = [
1000
+ f"Anat. Slice {s}, MRSI Slice {mrsi_slice_for(s)}" for s in range(n_anat_slices)
1001
+ ]
1002
+
1003
+ if magnitude and autophase:
1004
+ logger.warning(
1005
+ "Both magnitude and autophase options selected. Autophase will "
1006
+ "be ignored when displaying magnitude spectrum."
1007
+ )
1008
+
1009
+ logger.debug("Starting rendering of spectra array")
1010
+
1011
+ ppm_axis, spectra_array = mrsi_spectra_for_display(
1012
+ MRSI, magnitude=magnitude, autophase=autophase
1013
+ )
1014
+ npts = ppm_axis.size
1015
+ spectra_bytes, spectra_scale = _encode_spectra(spectra_array)
1016
+ # Bounds over the whole grid, computed here rather than in the browser:
1017
+ # they set the travel of the widget's manual y-limit slider, which has to
1018
+ # be drawable before anything has scanned the 2.9M-value buffer.
1019
+ spectra_min = float(np.nanmin(spectra_array)) if spectra_array.size else 0.0
1020
+ spectra_max = float(np.nanmax(spectra_array)) if spectra_array.size else 1.0
1021
+
1022
+ if magnitude:
1023
+ spectrum_label = "Magnitude Spectrum"
1024
+ elif autophase:
1025
+ spectrum_label = "Autophased Spectrum"
1026
+ else:
1027
+ spectrum_label = "Real Spectrum"
1028
+
1029
+ logger.debug("Ready to display")
1030
+
1031
+ ipy_display(
1032
+ MRSIVoxelInspectorWidget(
1033
+ left_frames=left_frames,
1034
+ slice_titles=slice_titles,
1035
+ n_anat_slices=n_anat_slices,
1036
+ initial_slice=initial_slice,
1037
+ image_width=t1_images.shape[1],
1038
+ image_height=t1_images.shape[0],
1039
+ mrsi_to_display_affine=mrsi_to_display.flatten().tolist(),
1040
+ display_to_mrsi_affine=display_to_mrsi.flatten().tolist(),
1041
+ grid_shape=(nx, ny, n_mrsi_slices),
1042
+ mrsi_dims=(int(MRSI.dims[0]), int(MRSI.dims[1])),
1043
+ initial_voxel=initial_voxel,
1044
+ ppm=ppm_axis.tolist(),
1045
+ spectra_bytes=spectra_bytes,
1046
+ spectra_scale=spectra_scale,
1047
+ spectra_min=spectra_min,
1048
+ spectra_max=spectra_max,
1049
+ npts=npts,
1050
+ spectrum_label=spectrum_label,
1051
+ )
1052
+ )
1053
+
1054
+
1055
+ def mrsi_spectra_for_display(
1056
+ MRSI: GESeries.MRSISeries,
1057
+ *,
1058
+ magnitude: bool = False,
1059
+ autophase: bool = True,
1060
+ ) -> tuple[npt.NDArray, npt.NDArray]:
1061
+ """Build the exact ppm axis and spectra grid `inspect_MRSI_spectra` displays.
1062
+
1063
+ Split out of the widget so the displayed numbers can be inspected directly
1064
+ — the inspector ships precisely this array, so plotting `spectra[x, y, z]`
1065
+ against `ppm` reproduces a voxel's panel exactly:
1066
+
1067
+ >>> ppm, spectra = mrsi_spectra_for_display(mrsi) # doctest: +SKIP
1068
+ >>> plt.plot(ppm, spectra[8, 8, 8]) # doctest: +SKIP
1069
+
1070
+ Parameters
1071
+ ----------
1072
+ MRSI : GESeries.MRSISeries
1073
+ The MRSI series to read spectra from.
1074
+ magnitude : bool, optional
1075
+ If True, return the magnitude spectrum, by default False.
1076
+ autophase : bool, optional
1077
+ If True (and `magnitude` is False), phase each voxel independently,
1078
+ by default True.
1079
+
1080
+ Returns
1081
+ -------
1082
+ ppm : ndarray
1083
+ 1-D ppm axis, spanning the full acquired sweep.
1084
+ spectra : ndarray
1085
+ Real-valued grid indexed `[x, y, mrsi_slice, point]`, matching the
1086
+ voxel coordinates the widget's picker reports.
1087
+ """
1088
+ ppm = np.asarray(MRSI.ppm.values, dtype=float)
1089
+ spectral_dim = MRSI.spec.dims[0]
1090
+ # Top left of the image is (0, 0); the MRSI grid's coordinate system
1091
+ # starts bottom-right, so the array is reversed along i/j and the (x, y)
1092
+ # voxel axes read from (j, i) rather than (i, j) -- matches the mapping
1093
+ # `overlay_voxel_on_T1`/the widget's client-side voxel picker expect.
1094
+ grid = (
1095
+ MRSI.spec.isel(i=slice(None, None, -1), j=slice(None, None, -1))
1096
+ .transpose("j", "i", "k", spectral_dim)
1097
+ .values
1098
+ )
1099
+
1100
+ if magnitude:
1101
+ return ppm, np.abs(grid)
1102
+ if not autophase:
1103
+ return ppm, grid.real
1104
+ # Phase every voxel by its own peak instead of applying one global
1105
+ # correction to all of them. xmris' `autophase(mode="single")` derives a
1106
+ # single zero-order phase from the grid's strongest voxel; on real data
1107
+ # that leaves the median voxel only ~0.52 absorptive and outright inverts
1108
+ # ~29% of them, which reads as corrupted spectra. Rotating each voxel by
1109
+ # the argument of its own largest point makes every peak absorptive and
1110
+ # positive, is one complex multiply (~0.02 s for a 16x16x16 grid, against
1111
+ # ~0.85 s for the global fit), and is display-only -- no fitting decision
1112
+ # is taken here. `mode="all"` upstream would be the real home for this;
1113
+ # it currently raises NotImplementedError.
1114
+ peak = np.abs(grid).argmax(axis=-1)
1115
+ xs, ys, zs = np.indices(peak.shape)
1116
+ phi = np.angle(grid[xs, ys, zs, peak])
1117
+ return ppm, (grid * np.exp(-1j * phi)[..., None]).real
1118
+
1119
+
1120
+ def _encode_spectra(spectra_array: npt.NDArray) -> tuple[bytes, float]:
1121
+ """Pack the per-voxel spectra grid into a compact wire buffer.
1122
+
1123
+ Returns the zlib-compressed float16 buffer and the scale factor needed to
1124
+ recover the original values (`value = stored * scale`).
1125
+
1126
+ Two reductions over the obvious `astype(np.float32).tobytes()`, which for a
1127
+ 16x16x16 grid at 700 points is 11.5 MB before base64 inflates it to 15.3:
1128
+
1129
+ - **float16 halves it.** This buffer only ever feeds a line plot, so the
1130
+ ~3 significant digits it keeps are far inside display precision. Raw
1131
+ spectra do overflow float16's 65504 limit, though, so the grid is scaled
1132
+ into range first and the factor travels with it.
1133
+ - **zlib then takes another ~8x**, because neighbouring points in a
1134
+ spectrum are highly correlated. The browser inflates it with the built-in
1135
+ `DecompressionStream`, so this costs no JS dependency.
1136
+ """
1137
+ nbytes = spectra_array.size * 4
1138
+ if nbytes > _SPECTRA_WARN_BYTES:
1139
+ logger.warning(
1140
+ f"MRSI spectra buffer is large ({nbytes / 1024**2:.0f} MB as float32 for a "
1141
+ f"{'x'.join(str(d) for d in spectra_array.shape)} grid). The widget embeds it in "
1142
+ "its output, so the notebook and any built docs page will grow accordingly."
1143
+ )
1144
+ peak = float(np.nanmax(np.abs(spectra_array))) if spectra_array.size else 0.0
1145
+ # A power of two, not `peak / target`: scaling by one shifts every value's
1146
+ # exponent and leaves its mantissa bits untouched, so it is both exact (no
1147
+ # rounding on top of the float16 cast) and keeps the buffer as compressible
1148
+ # as it was. An arbitrary divisor perturbs every mantissa and cost ~4x on
1149
+ # the compressed size when measured.
1150
+ scale = 2.0 ** np.ceil(np.log2(peak / _F16_TARGET_MAX)) if peak > _F16_TARGET_MAX else 1.0
1151
+ scaled = (spectra_array / scale) if scale != 1.0 else spectra_array
1152
+ return zlib.compress(scaled.astype(np.float16).tobytes(), 6), float(scale)
1153
+
1154
+
1155
+ class VoxelOverlayParams(TypedDict, total=False):
1156
+ """Keyword arguments accepted by `overlay_voxel_on_T1`."""
1157
+
1158
+ show_overlay: bool
1159
+ voxel_kwargs: dict | list[dict] | None
1160
+ image_kwargs: dict | None
1161
+ overlay_kwargs: dict | None
1162
+ title: str | None
1163
+ scale_overlay: bool
1164
+ blocky: bool
1165
+
1166
+
1167
+ VoxelCoord = tuple[int, int, int]
1168
+ VoxelCoordsInput = VoxelCoord | npt.NDArray | list[VoxelCoord]
1169
+ VoxelStyle = dict[str, Any]
1170
+ VoxelStylesInput = VoxelStyle | list[VoxelStyle] | None
1171
+
1172
+
1173
+ def get_voxel_visible_slice_range(
1174
+ voxel_coord: VoxelCoord,
1175
+ mrsi_to_display_affine: npt.NDArray,
1176
+ n_slices: int | None = None,
1177
+ ) -> tuple[int, int]:
1178
+ """Return inclusive third-dimension slice range where a voxel is visible."""
1179
+ # Voxel occupies [voxel-0.5, voxel+0.5] in each MRSI index dim (center convention).
1180
+ origin = np.asarray(voxel_coord, dtype=float) - 0.5
1181
+ corner_offsets = np.indices((2, 2, 2)).reshape(3, -1).T.astype(float)
1182
+ corners = origin + corner_offsets
1183
+ display_corners = affines.apply_affine(mrsi_to_display_affine, corners)
1184
+ min_slice = int(np.ceil(np.min(display_corners[:, 2])))
1185
+ max_slice = int(np.floor(np.max(display_corners[:, 2])))
1186
+
1187
+ if n_slices is not None:
1188
+ min_slice = max(0, min_slice)
1189
+ max_slice = min(n_slices - 1, max_slice)
1190
+
1191
+ return min_slice, max_slice
1192
+
1193
+
1194
+ def draw_voxel_overlays_on_ax(
1195
+ ax: Axes,
1196
+ voxel_coords: VoxelCoord | list[VoxelCoord],
1197
+ mrsi_to_display_affine: npt.NDArray,
1198
+ voxel_kwargs: VoxelStylesInput = None,
1199
+ ) -> list[patches.Polygon]:
1200
+ """Draw voxel outlines on an axis.
1201
+
1202
+ mrsi_to_display_affine must map MRSI voxel indices to display pixel coordinates
1203
+ (row, col) as produced by orient_nifti. Use utils.nifti.get_display_affine to
1204
+ obtain the display affine for the T1, then compute
1205
+ mrsi_to_display = inv(display_affine) @ MRSI.RAW_exp.nii.affine.
1206
+
1207
+ The voxel's four in-plane corners (voxel index +/- 0.5 along axes 0 and 1) are
1208
+ projected through the full affine, so this is correct for display affines that
1209
+ transpose or rotate the axes -- reading the affine diagonal is not, because
1210
+ get_display_affine bakes an axis transpose into an anti-diagonal affine.
1211
+ """
1212
+ default_voxel_kwargs: VoxelStyle = {
1213
+ "linewidth": 2,
1214
+ "edgecolor": "cyan",
1215
+ "facecolor": "none",
1216
+ }
1217
+
1218
+ if not isinstance(voxel_coords, list):
1219
+ voxel_coords = [voxel_coords]
1220
+
1221
+ if isinstance(voxel_kwargs, list):
1222
+ if len(voxel_kwargs) != len(voxel_coords):
1223
+ raise ValueError("Length of voxel_kwargs list must match the number of voxels.")
1224
+ resolved_voxel_kwargs = [default_voxel_kwargs | single for single in voxel_kwargs]
1225
+ else:
1226
+ resolved_voxel_kwargs = [default_voxel_kwargs | (voxel_kwargs or {})] * len(voxel_coords)
1227
+
1228
+ # In-plane corner offsets around the voxel centre (index +/- 0.5 on axes 0, 1).
1229
+ corner_offsets = np.array(
1230
+ [[-0.5, -0.5, 0.0], [0.5, -0.5, 0.0], [0.5, 0.5, 0.0], [-0.5, 0.5, 0.0]]
1231
+ )
1232
+
1233
+ polys: list[patches.Polygon] = []
1234
+ for voxel, single_voxel_kwargs in zip(voxel_coords, resolved_voxel_kwargs):
1235
+ corners = np.asarray(voxel, dtype=float) + corner_offsets
1236
+ corners_in_display = affines.apply_affine(mrsi_to_display_affine, corners)
1237
+ # apply_affine yields (row, col, slice); Polygon wants (x, y) = (col, row).
1238
+ xy = corners_in_display[:, [1, 0]]
1239
+ logger.debug(f"Voxel {voxel} display corners (col, row):\n{xy}")
1240
+
1241
+ range_start, range_end = get_voxel_visible_slice_range(
1242
+ voxel_coord=voxel,
1243
+ mrsi_to_display_affine=mrsi_to_display_affine,
1244
+ )
1245
+ logger.debug(
1246
+ f"Voxel {voxel} visible on display slices {range_start} to {range_end} (inclusive)."
1247
+ )
1248
+
1249
+ poly = patches.Polygon(xy, closed=True, **single_voxel_kwargs)
1250
+ ax.add_patch(poly)
1251
+ polys.append(poly)
1252
+
1253
+ return polys
1254
+
1255
+
1256
+ def overlay_voxel_on_T1(
1257
+ t1: GESeries.MRISeries,
1258
+ MRSI: GESeries.MRSISeries,
1259
+ voxel_coords: VoxelCoordsInput,
1260
+ ax: Axes | None = None,
1261
+ figsize: tuple[int, int] = (6, 6),
1262
+ **kwargs: Unpack[VoxelOverlayParams],
1263
+ ) -> tuple[Figure, Axes] | Axes:
1264
+ """Overlay one or more voxels (and optionally MRSI data) on a T1 slice.
1265
+
1266
+ Displays a slice of a T1-weighted MRI image, overlays voxel(s) at the
1267
+ specified coordinates, and optionally overlays MRSI data resampled into
1268
+ T1 space. If `ax` is omitted, creates its own figure and axes.
1269
+
1270
+ Parameters
1271
+ ----------
1272
+ t1 : GESeries.MRISeries
1273
+ The T1-weighted MRI series containing the anatomical image data.
1274
+ MRSI : GESeries.MRSISeries
1275
+ The MRSI series containing the spectroscopic imaging data.
1276
+ voxel_coords : tuple[int, int, int] or ndarray or list[tuple[int, int, int]]
1277
+ The coordinates of the voxel(s) in MRSI space. If a list is given,
1278
+ all voxels must share the same z-coordinate.
1279
+ ax : matplotlib.axes.Axes, optional
1280
+ The axis on which to plot the image and overlays. If `None`, a new
1281
+ figure and axis are created.
1282
+ figsize : tuple[int, int], optional
1283
+ The size of the figure in inches, used only when `ax` is `None`.
1284
+ Defaults to (6, 6).
1285
+ **kwargs : Unpack[VoxelOverlayParams]
1286
+ show_overlay : bool, optional
1287
+ Whether to overlay the MRSI data on the T1 image. Defaults to True.
1288
+ scale_overlay : bool, optional
1289
+ Whether to scale the MRSI data to `[0, 1]` for better
1290
+ visualization when overlaid. Defaults to True.
1291
+ title : str, optional
1292
+ Title for the plot. Defaults to one naming the anatomical slice
1293
+ index.
1294
+ voxel_kwargs : dict or list[dict], optional
1295
+ Keyword arguments customizing the voxel rectangle(s)'
1296
+ appearance. If a list, it must match the number of voxels.
1297
+ Defaults to None.
1298
+ image_kwargs : dict, optional
1299
+ Keyword arguments customizing the T1 image's appearance.
1300
+ overlay_kwargs : dict, optional
1301
+ Keyword arguments customizing the MRSI overlay's appearance.
1302
+
1303
+ Returns
1304
+ -------
1305
+ tuple[matplotlib.figure.Figure, matplotlib.axes.Axes] or matplotlib.axes.Axes
1306
+ The created `(fig, ax)` if `ax` was `None`, otherwise the axis with
1307
+ the plotted image and overlays.
1308
+
1309
+ Notes
1310
+ -----
1311
+ The MRSI data is resampled to T1 space using the provided affine
1312
+ transformations, then scaled to `[0, 1]` if `scale_overlay` is True. The
1313
+ voxel rectangle(s) are drawn at the specified coordinates, adjusted to
1314
+ align with the anatomical space.
1315
+ """
1316
+ created_fig = None
1317
+ if ax is None:
1318
+ created_fig, ax = plt.subplots(figsize=figsize)
1319
+
1320
+ # Get elements from kwargs with defaults
1321
+ show_overlay: bool = kwargs.get("show_overlay", True)
1322
+ voxel_kwargs: VoxelStylesInput = kwargs.get("voxel_kwargs", None)
1323
+ image_kwargs: dict | None = kwargs.get("image_kwargs", None)
1324
+ overlay_kwargs: dict | None = kwargs.get("overlay_kwargs", None)
1325
+ blocky: bool = kwargs.get("blocky", True)
1326
+
1327
+ if show_overlay:
1328
+ # Resample MRSI to T1 space
1329
+ t1_images, mrsi_images = utils.nifti.resample_and_orient_nifti(
1330
+ source_nii=MRSI.RAW_exp.nii if blocky else MRSI.nii,
1331
+ target_nii=t1.nii,
1332
+ )
1333
+ # Scale the mrsi images from 0 to 1 for better overlay
1334
+ if kwargs.get("scale_overlay", True):
1335
+ mrsi_images = (mrsi_images - np.nanmin(mrsi_images)) / (
1336
+ np.nanmax(mrsi_images) - np.nanmin(mrsi_images)
1337
+ )
1338
+ else:
1339
+ # Just get the T1 images
1340
+ t1_images = t1.images()
1341
+ mrsi_images = np.zeros_like(t1_images)
1342
+
1343
+ logger.trace(f"T1 images shape: {t1_images.shape}")
1344
+ logger.trace(f"MRSI images shape: {mrsi_images.shape}")
1345
+
1346
+ mrsi_to_display = _mrsi_to_display_affine(t1.nii, MRSI)
1347
+
1348
+ # Ensure voxel_coords is a list of tuples
1349
+ if isinstance(voxel_coords, (tuple, np.ndarray)):
1350
+ voxel_coords = [tuple(voxel_coords)]
1351
+ elif not isinstance(voxel_coords, list):
1352
+ raise ValueError("voxel_coords must be a tuple, ndarray, or list of tuples.")
1353
+
1354
+ # Check that all voxels have the same z-coordinate
1355
+ z_coords = {coord[2] for coord in voxel_coords}
1356
+ if len(z_coords) > 1:
1357
+ raise ValueError("All voxels must have the same z-coordinate.")
1358
+
1359
+ slice_idx = int(np.round(affines.apply_affine(mrsi_to_display, voxel_coords[0])[2]))
1360
+
1361
+ logger.debug(f"Displaying slice index: {slice_idx}")
1362
+
1363
+ default_image_kwargs: dict[str, Any] = {"cmap": "gray"}
1364
+ default_overlay_kwargs: dict[str, Any] = {"cmap": "magma", "alpha": 0.5}
1365
+
1366
+ # Update default kwargs with provided kwargs
1367
+ img_vmin, img_vmax = fast_bounds(t1_images[:, :, slice_idx])
1368
+ default_image_kwargs.update({"vmin": img_vmin, "vmax": img_vmax})
1369
+ image_kwargs = default_image_kwargs | (image_kwargs or {})
1370
+
1371
+ ax.imshow(t1_images[:, :, slice_idx], origin="upper", **image_kwargs)
1372
+ logger.trace(f"Sum of t1 image slice: {np.nansum(t1_images[:, :, slice_idx])}")
1373
+ if show_overlay:
1374
+ mrsi_vmin, mrsi_vmax = fast_bounds(mrsi_images[:, :, slice_idx])
1375
+ overlay_kwargs = default_overlay_kwargs | (overlay_kwargs or {})
1376
+ overlay_kwargs.update({"vmin": mrsi_vmin, "vmax": mrsi_vmax})
1377
+ ax.imshow(
1378
+ mrsi_images[:, :, slice_idx],
1379
+ origin="upper",
1380
+ **overlay_kwargs,
1381
+ )
1382
+ logger.trace(f"Sum of MRSI image slice: {np.nansum(mrsi_images[:, :, slice_idx])}")
1383
+
1384
+ draw_voxel_overlays_on_ax(
1385
+ ax=ax,
1386
+ voxel_coords=voxel_coords,
1387
+ mrsi_to_display_affine=mrsi_to_display,
1388
+ voxel_kwargs=voxel_kwargs,
1389
+ )
1390
+
1391
+ title: str | None = kwargs.get("title", None)
1392
+ if title != "":
1393
+ ax.set_title(title or f"Anatomical Slice {slice_idx}")
1394
+
1395
+ ax.axis("off")
1396
+
1397
+ if created_fig is not None:
1398
+ return created_fig, ax
1399
+ return ax