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,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
|