nltools 0.6.0.dev0__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.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
"""niivue interactive viewer for BrainData, as a self-owned `anywidget`.
|
|
2
|
+
|
|
3
|
+
`_build_viewer` returns a `_NiivueViewer` — a WebGL brain viewer with live
|
|
4
|
+
windowing, slice scrolling, native 4D frame scrubbing, true 3D rendering, and
|
|
5
|
+
optional nltools-atlas overlays (colored regions / outlines / hover labels).
|
|
6
|
+
|
|
7
|
+
The widget drives the `@niivue/niivue` JavaScript library directly through
|
|
8
|
+
anywidget's standard model API (see ``viewer.js``), so it renders identically
|
|
9
|
+
in Jupyter and ``marimo edit`` without depending on any host-specific protocol.
|
|
10
|
+
|
|
11
|
+
The module is split functional-core / imperative-shell:
|
|
12
|
+
|
|
13
|
+
- Pure helpers (`_resolve_cmap`, `_divergent_partner`, `_slice_type_for`,
|
|
14
|
+
`_qualitative_colors`, `_atlas_to_label_lut`, `_resolve_background`,
|
|
15
|
+
`_bd_to_nifti_bytes`, `_compute_display_window`) translate BrainData /
|
|
16
|
+
`_Atlas` state into the vocabulary niivue understands.
|
|
17
|
+
- `_NiivueViewer` is the thin traitlets widget; `_build_viewer` is the assembler
|
|
18
|
+
that fills its traits from a BrainData.
|
|
19
|
+
|
|
20
|
+
niivue formatting deliberately lives here, not in ``nltools/data/atlases/`` —
|
|
21
|
+
the atlas package stays niivue-agnostic and only exposes the generic `_Atlas`
|
|
22
|
+
dataclass.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import colorsys
|
|
28
|
+
import functools
|
|
29
|
+
import gzip
|
|
30
|
+
import pathlib
|
|
31
|
+
import warnings
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Literal
|
|
34
|
+
|
|
35
|
+
import anywidget
|
|
36
|
+
import traitlets
|
|
37
|
+
|
|
38
|
+
from nltools.data.atlases import _Atlas, load_atlas
|
|
39
|
+
from nltools.templates.matching import _get_bg_image, _is_standard_space
|
|
40
|
+
from nltools.utils import _find_stack_level
|
|
41
|
+
|
|
42
|
+
_VIEWER_JS = pathlib.Path(__file__).parent / "viewer.js"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
# Colormaps
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
|
|
49
|
+
# niivue's builtin colormap names (@niivue/niivue 0.69). Hardcoded rather than
|
|
50
|
+
# read from a Python package's statics because the JS engine now comes straight
|
|
51
|
+
# from a CDN — there is no local niivue install to introspect. Stable across
|
|
52
|
+
# niivue releases; extend if niivue adds builtins we want to expose by name.
|
|
53
|
+
_NIIVUE_COLORMAPS: frozenset[str] = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"actc", "afni_blues_inv", "afni_reds_inv", "batlow", "bcgwhw",
|
|
56
|
+
"bcgwhw_dark", "blue", "blue2cyan", "blue2magenta", "blue2red",
|
|
57
|
+
"bluegrn", "bone", "bronze", "cet_l17", "cividis", "cool", "copper",
|
|
58
|
+
"copper2", "ct_airways", "ct_artery", "ct_bones", "ct_brain",
|
|
59
|
+
"ct_brain_gray", "ct_cardiac", "ct_head", "ct_kidneys", "ct_liver",
|
|
60
|
+
"ct_muscles", "ct_scalp", "ct_skull", "ct_soft", "ct_soft_tissue",
|
|
61
|
+
"ct_surface", "ct_vessels", "ct_w_contrast", "cubehelix",
|
|
62
|
+
"electric_blue", "freesurfer", "ge_color", "gold", "gray", "green",
|
|
63
|
+
"green2cyan", "green2orange", "hot", "hotiron", "hsv", "inferno",
|
|
64
|
+
"jet", "kry", "linspecer", "lipari", "magma", "mako", "navia", "nih",
|
|
65
|
+
"plasma", "random", "red", "redyell", "rocket", "roi_i256", "surface",
|
|
66
|
+
"thermal", "turbo", "violet", "viridis", "warm", "winter", "x_rain",
|
|
67
|
+
}
|
|
68
|
+
) # fmt: skip
|
|
69
|
+
|
|
70
|
+
# Common matplotlib colormap names with no exact niivue equivalent. niivue
|
|
71
|
+
# silently renders gray for unknown names, so we map the popular ones and
|
|
72
|
+
# warn (see _resolve_cmap) rather than letting them fall through.
|
|
73
|
+
_MPL_TO_NIIVUE: dict[str, str] = {
|
|
74
|
+
"rdbu_r": "warm",
|
|
75
|
+
"rdbu": "winter",
|
|
76
|
+
"coolwarm": "warm",
|
|
77
|
+
"bwr": "warm",
|
|
78
|
+
"seismic": "warm",
|
|
79
|
+
"spectral": "warm",
|
|
80
|
+
"spectral_r": "warm",
|
|
81
|
+
"reds": "warm",
|
|
82
|
+
"reds_r": "warm",
|
|
83
|
+
"oranges": "warm",
|
|
84
|
+
"ylorrd": "redyell",
|
|
85
|
+
"blues": "winter",
|
|
86
|
+
"blues_r": "winter",
|
|
87
|
+
"greens": "green",
|
|
88
|
+
"purples": "violet",
|
|
89
|
+
"greys": "gray",
|
|
90
|
+
"grays": "gray",
|
|
91
|
+
"grey": "gray",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Cool-side partner for the positive colormap, used as ``colormap_negative``
|
|
95
|
+
# so divergent stat maps render with a mirrored negative limb. Falls back to
|
|
96
|
+
# ``winter`` for sequential maps with no obvious counterpart.
|
|
97
|
+
_DIVERGENT_PARTNERS: dict[str, str] = {
|
|
98
|
+
"warm": "winter",
|
|
99
|
+
"hot": "cool",
|
|
100
|
+
"hotiron": "cool",
|
|
101
|
+
"red": "blue",
|
|
102
|
+
"redyell": "blue",
|
|
103
|
+
"gold": "blue",
|
|
104
|
+
"green": "violet",
|
|
105
|
+
"viridis": "winter",
|
|
106
|
+
"inferno": "winter",
|
|
107
|
+
"magma": "winter",
|
|
108
|
+
"plasma": "winter",
|
|
109
|
+
"jet": "winter",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@functools.cache
|
|
114
|
+
def _niivue_colormaps() -> frozenset[str]:
|
|
115
|
+
"""Names of niivue's builtin colormaps (see `_NIIVUE_COLORMAPS`)."""
|
|
116
|
+
return _NIIVUE_COLORMAPS
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _resolve_cmap(name: str) -> str:
|
|
120
|
+
"""Resolve a colormap name to a valid niivue colormap.
|
|
121
|
+
|
|
122
|
+
Valid niivue names pass through. Common matplotlib names are mapped to
|
|
123
|
+
the closest niivue equivalent (with a warning, since the mapping is
|
|
124
|
+
lossy). Anything else falls back to ``"warm"`` with a warning, because
|
|
125
|
+
niivue renders unknown colormaps as flat gray with no error.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
name: A niivue or matplotlib colormap name (case-insensitive).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A valid niivue colormap name.
|
|
132
|
+
"""
|
|
133
|
+
key = name.lower()
|
|
134
|
+
if key in _niivue_colormaps():
|
|
135
|
+
return key
|
|
136
|
+
if key in _MPL_TO_NIIVUE:
|
|
137
|
+
mapped = _MPL_TO_NIIVUE[key]
|
|
138
|
+
warnings.warn(
|
|
139
|
+
f"colormap {name!r} is a matplotlib name with no exact niivue "
|
|
140
|
+
f"equivalent; using {mapped!r}. Pass a niivue colormap name to "
|
|
141
|
+
"silence this.",
|
|
142
|
+
UserWarning,
|
|
143
|
+
stacklevel=_find_stack_level(),
|
|
144
|
+
)
|
|
145
|
+
return mapped
|
|
146
|
+
warnings.warn(
|
|
147
|
+
f"colormap {name!r} is not a known niivue colormap; falling back to 'warm'.",
|
|
148
|
+
UserWarning,
|
|
149
|
+
stacklevel=_find_stack_level(),
|
|
150
|
+
)
|
|
151
|
+
return "warm"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _divergent_partner(cmap: str) -> str:
|
|
155
|
+
"""Return the ``colormap_negative`` partner for a positive colormap.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
cmap: A (resolved) niivue positive colormap name.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The niivue colormap to use for negative values.
|
|
162
|
+
"""
|
|
163
|
+
return _DIVERGENT_PARTNERS.get(cmap, "winter")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------------- #
|
|
167
|
+
# View / slice type
|
|
168
|
+
# --------------------------------------------------------------------------- #
|
|
169
|
+
|
|
170
|
+
# Map a ``view`` string to the name of niivue's ``SLICE_TYPE`` enum member,
|
|
171
|
+
# which ``viewer.js`` indexes into (``SLICE_TYPE[name]``).
|
|
172
|
+
_VIEW_TO_SLICE: dict[str, str] = {
|
|
173
|
+
"ortho": "MULTIPLANAR",
|
|
174
|
+
"axial": "AXIAL",
|
|
175
|
+
"coronal": "CORONAL",
|
|
176
|
+
"sagittal": "SAGITTAL",
|
|
177
|
+
"render": "RENDER",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _slice_type_for(view: str) -> str:
|
|
182
|
+
"""Map a ``view`` string to a niivue ``SLICE_TYPE`` enum name.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
view: One of ``"ortho"``, ``"axial"``, ``"coronal"``,
|
|
186
|
+
``"sagittal"``, ``"render"``.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
str: The matching ``SLICE_TYPE`` member name (e.g. ``"MULTIPLANAR"``),
|
|
190
|
+
which ``viewer.js`` resolves against niivue's enum.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: For ``view="surface"`` (dropped — niivue's 3D render is
|
|
194
|
+
volumetric, not a cortical mesh) or any other unknown view.
|
|
195
|
+
"""
|
|
196
|
+
if view == "surface":
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"view='surface' is no longer supported: niivue's 3D mode renders "
|
|
199
|
+
"the volume, not a cortical mesh. Use view='render' for a 3D "
|
|
200
|
+
"volume render, or BrainData.plot_flatmap()/plot_surf() for a "
|
|
201
|
+
"surface projection."
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
return _VIEW_TO_SLICE[view]
|
|
205
|
+
except KeyError:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"view={view!r} not recognized; choose from {sorted(_VIEW_TO_SLICE)}."
|
|
208
|
+
) from None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --------------------------------------------------------------------------- #
|
|
212
|
+
# Atlas overlay
|
|
213
|
+
# --------------------------------------------------------------------------- #
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _qualitative_colors(n: int, *, seed: int = 0) -> list[tuple[int, int, int]]:
|
|
217
|
+
"""Deterministic qualitative RGB palette of length ``n``.
|
|
218
|
+
|
|
219
|
+
Hues are spaced by the golden angle for maximal separation; saturation
|
|
220
|
+
and value cycle through three bands so adjacent indices stay visually
|
|
221
|
+
distinct. Atlases carry no color data, so this assigns region colors.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
n: Number of colors to generate.
|
|
225
|
+
seed: Rotates the starting hue; deterministic for a given seed.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
``n`` ``(r, g, b)`` tuples with components in ``0..255``.
|
|
229
|
+
"""
|
|
230
|
+
if n < 0:
|
|
231
|
+
raise ValueError("n must be non-negative")
|
|
232
|
+
golden = 0.6180339887498949
|
|
233
|
+
h0 = (seed * golden) % 1.0
|
|
234
|
+
sat_bands = (0.65, 0.85, 0.75)
|
|
235
|
+
val_bands = (0.95, 0.80, 0.90)
|
|
236
|
+
out: list[tuple[int, int, int]] = []
|
|
237
|
+
for i in range(n):
|
|
238
|
+
h = (h0 + i * golden) % 1.0
|
|
239
|
+
s = sat_bands[i % 3]
|
|
240
|
+
v = val_bands[i % 3]
|
|
241
|
+
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
|
242
|
+
out.append((round(r * 255), round(g * 255), round(b * 255)))
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _atlas_to_label_lut(atlas: _Atlas) -> dict:
|
|
247
|
+
"""Build a niivue integer-indexed label LUT from a deterministic atlas.
|
|
248
|
+
|
|
249
|
+
The LUT arrays are dense (length ``max_index + 1``) because niivue
|
|
250
|
+
indexes them by integer voxel value. Index 0 and any gap indices are
|
|
251
|
+
transparent (``A=0``, empty label); each present region gets a color
|
|
252
|
+
from `_qualitative_colors` (assigned in table-enumeration order, so
|
|
253
|
+
colors stay stable under sparse / non-contiguous indices) and its name.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
atlas: A loaded deterministic `_Atlas`.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
dict: Keys ``"R"``, ``"G"``, ``"B"``, ``"A"``, ``"labels"``, suitable for
|
|
260
|
+
niivue's ``setColormapLabel``.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
ValueError: If ``atlas`` is probabilistic (4D) — threshold it to a
|
|
264
|
+
label image first.
|
|
265
|
+
"""
|
|
266
|
+
if atlas.kind == "probabilistic":
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"atlas overlay supports deterministic atlases only; "
|
|
269
|
+
f"{atlas.name!r} is probabilistic (4D). Threshold to a label "
|
|
270
|
+
"image first."
|
|
271
|
+
)
|
|
272
|
+
rows = zip(atlas.labels["index"].to_list(), atlas.labels["name"].to_list())
|
|
273
|
+
present = [(int(idx), str(name)) for idx, name in rows if int(idx) > 0]
|
|
274
|
+
max_index = max((idx for idx, _ in present), default=0)
|
|
275
|
+
size = max_index + 1
|
|
276
|
+
|
|
277
|
+
r = [0] * size
|
|
278
|
+
g = [0] * size
|
|
279
|
+
b = [0] * size
|
|
280
|
+
a = [0] * size
|
|
281
|
+
labels = [""] * size
|
|
282
|
+
for (idx, name), (cr, cg, cb) in zip(present, _qualitative_colors(len(present))):
|
|
283
|
+
r[idx], g[idx], b[idx], a[idx] = cr, cg, cb, 255
|
|
284
|
+
labels[idx] = name
|
|
285
|
+
return {"R": r, "G": g, "B": b, "A": a, "labels": labels}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _coerce_atlas(atlas: str | _Atlas | None) -> _Atlas | None:
|
|
289
|
+
"""Resolve the ``atlas`` argument to an `_Atlas` or ``None``."""
|
|
290
|
+
if atlas is None:
|
|
291
|
+
return None
|
|
292
|
+
if isinstance(atlas, _Atlas):
|
|
293
|
+
return atlas
|
|
294
|
+
if isinstance(atlas, str):
|
|
295
|
+
return load_atlas(atlas)
|
|
296
|
+
raise TypeError(
|
|
297
|
+
f"atlas must be a str name, an Atlas, or None; got {type(atlas).__name__}."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# --------------------------------------------------------------------------- #
|
|
302
|
+
# Background
|
|
303
|
+
# --------------------------------------------------------------------------- #
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _resolve_background(affine, bg_img: str | bool | None) -> str | None:
|
|
307
|
+
"""Resolve the ``bg_img`` argument to a background-image path or ``None``.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
affine (np.ndarray): 4x4 affine of the BrainData (``bd.mask.affine``),
|
|
311
|
+
used to decide whether auto-MNI applies.
|
|
312
|
+
bg_img: ``False`` → no background; a string/path → used as-is;
|
|
313
|
+
``None``/``True`` (auto) → the matching MNI template when the
|
|
314
|
+
affine is standard space, else ``None``.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
A path to a background image, or ``None`` for no background.
|
|
318
|
+
|
|
319
|
+
Note:
|
|
320
|
+
``_is_standard_space(np.eye(4)) == (True, None)`` (1mm is a valid
|
|
321
|
+
template resolution), so identity-affine fixtures count as standard
|
|
322
|
+
space and auto would fetch a template from HuggingFace. Offline
|
|
323
|
+
callers should pass ``bg_img=False``.
|
|
324
|
+
"""
|
|
325
|
+
if bg_img is False:
|
|
326
|
+
return None
|
|
327
|
+
if bg_img is None or bg_img is True:
|
|
328
|
+
ok, _ = _is_standard_space(affine)
|
|
329
|
+
return _get_bg_image(affine) if ok else None
|
|
330
|
+
return str(bg_img)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --------------------------------------------------------------------------- #
|
|
334
|
+
# Volume bytes
|
|
335
|
+
# --------------------------------------------------------------------------- #
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _gzip_nifti(raw: bytes) -> bytes:
|
|
339
|
+
"""Gzip NIfTI bytes unless they are already gzip-compressed.
|
|
340
|
+
|
|
341
|
+
The frontend hands every volume to niivue under a ``.nii.gz`` name, so the
|
|
342
|
+
payload must actually be gzip. Compressing also shrinks the single ``Bytes``
|
|
343
|
+
trait that crosses the anywidget comm (a full 1mm-FOV volume is tens of MB
|
|
344
|
+
raw). Bytes already carrying the gzip magic (``1f 8b`` — e.g. a template
|
|
345
|
+
read straight off disk) pass through untouched.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
raw: NIfTI-1 bytes, compressed or not.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Gzip-compressed NIfTI bytes.
|
|
352
|
+
"""
|
|
353
|
+
# mtime=0: gzip otherwise stamps the wall clock into the header, so the
|
|
354
|
+
# same volume serialized twice yields different bytes. Static-site
|
|
355
|
+
# builders (marimo-book) content-address these buffers and re-export a
|
|
356
|
+
# notebook once per slider value; a deterministic stream lets identical
|
|
357
|
+
# volumes de-duplicate to one file.
|
|
358
|
+
return raw if raw[:2] == b"\x1f\x8b" else gzip.compress(raw, mtime=0)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _bd_to_nifti_bytes(bd) -> bytes:
|
|
362
|
+
"""Serialize a BrainData (3D or 4D) to gzip-compressed NIfTI bytes.
|
|
363
|
+
|
|
364
|
+
The image is sent to niivue **once** as a single volume — niivue scrubs
|
|
365
|
+
4D frames natively, so there is no per-frame re-render. niivue infers the
|
|
366
|
+
format from the ``.nii.gz`` name the frontend assigns, so the bytes are
|
|
367
|
+
gzip-compressed to match (see `_gzip_nifti`).
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
bd (BrainData): A single map (3D) or a stack (4D).
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The image encoded as gzip-compressed NIfTI-1 bytes.
|
|
374
|
+
"""
|
|
375
|
+
return _gzip_nifti(bd.to_nifti().to_bytes())
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# --------------------------------------------------------------------------- #
|
|
379
|
+
# Display window (autoscaling)
|
|
380
|
+
# --------------------------------------------------------------------------- #
|
|
381
|
+
|
|
382
|
+
# Autoscale ceiling percentile over |finite nonzero| — a couple of outlier
|
|
383
|
+
# voxels must not set the whole color scale. 98 is the upper edge of the
|
|
384
|
+
# "robust range" convention: FSL's `fslstats -r` (2%/98% of a 1000-bin
|
|
385
|
+
# histogram) and niivue's own calMinMax ("robust range (2%..98%)",
|
|
386
|
+
# percentileFrac = 0.02). Both take the signed 2%/98%; we take the ceiling
|
|
387
|
+
# only, over magnitudes, because this window is symmetric about zero.
|
|
388
|
+
_AUTOSCALE_CEILING_PCT = 98.0
|
|
389
|
+
# Epsilon floor as a fraction of the ceiling: visually zero (everything above
|
|
390
|
+
# true zero stays visible) while zeros still render transparent. A floor, not
|
|
391
|
+
# a threshold — the robust range's 2% low edge would hide real voxels.
|
|
392
|
+
# (nilearn's plot_stat_map/view_img default to threshold=1e-6 for the same
|
|
393
|
+
# reason.)
|
|
394
|
+
_AUTOSCALE_FLOOR_FRAC = 1e-6
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass(frozen=True)
|
|
398
|
+
class _DisplayWindow:
|
|
399
|
+
"""The resolved niivue display window and its threshold-slider bounds.
|
|
400
|
+
|
|
401
|
+
Both halves come out of one pass over the data so the slider handles and
|
|
402
|
+
the rendered window can never disagree.
|
|
403
|
+
|
|
404
|
+
Attributes:
|
|
405
|
+
cal_min (float): Positive-limb threshold (window floor). Always
|
|
406
|
+
strictly positive, so exact zeros stay transparent.
|
|
407
|
+
cal_max (float): Positive-limb saturation endpoint (window ceiling).
|
|
408
|
+
cal_min_neg (float): Negative-limb saturation endpoint.
|
|
409
|
+
cal_max_neg (float): Negative-limb threshold endpoint.
|
|
410
|
+
mirror_negative (bool): Keep the negative endpoints mirrored when the
|
|
411
|
+
controls move.
|
|
412
|
+
slider_min (float): Slider lower bound; strictly positive and at or
|
|
413
|
+
below ``cal_min``.
|
|
414
|
+
slider_max (float): Slider upper bound.
|
|
415
|
+
slider_value_low (float): Initial position of the low handle.
|
|
416
|
+
slider_value_high (float): Initial position of the high handle.
|
|
417
|
+
slider_step (float): Slider step size.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
cal_min: float
|
|
421
|
+
cal_max: float
|
|
422
|
+
cal_min_neg: float
|
|
423
|
+
cal_max_neg: float
|
|
424
|
+
mirror_negative: bool
|
|
425
|
+
slider_min: float
|
|
426
|
+
slider_max: float
|
|
427
|
+
slider_value_low: float
|
|
428
|
+
slider_value_high: float
|
|
429
|
+
slider_step: float
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _compute_display_window(
|
|
433
|
+
data,
|
|
434
|
+
*,
|
|
435
|
+
autoscale: bool = True,
|
|
436
|
+
threshold=None,
|
|
437
|
+
lower=None,
|
|
438
|
+
upper=None,
|
|
439
|
+
symmetric: bool | Literal["auto"] = "auto",
|
|
440
|
+
) -> _DisplayWindow:
|
|
441
|
+
"""Resolve the viewer's display window and threshold-slider bounds.
|
|
442
|
+
|
|
443
|
+
The window is always computed here and passed to niivue explicitly, so
|
|
444
|
+
the slider handles can never show one window while niivue renders
|
|
445
|
+
another. Precedence: ``lower``/``upper`` win; otherwise ``threshold``
|
|
446
|
+
sets the floor; any **unset** edge comes from ``autoscale``:
|
|
447
|
+
|
|
448
|
+
- ``True`` (default): ceiling = 98th percentile of the finite nonzero
|
|
449
|
+
magnitudes (robust to outliers), floor = an epsilon just above zero,
|
|
450
|
+
never above the smallest nonzero magnitude (zeros render transparent,
|
|
451
|
+
every real voxel shows — threshold up from there).
|
|
452
|
+
- ``False``: the raw magnitude range, from one slider step above zero to
|
|
453
|
+
the largest absolute value.
|
|
454
|
+
|
|
455
|
+
For a custom percentile window, pass ``lower``/``upper`` as percentile
|
|
456
|
+
strings (``lower="60%", upper="98%"``) rather than a second spelling of
|
|
457
|
+
the same thing on ``autoscale``.
|
|
458
|
+
|
|
459
|
+
``threshold`` / ``lower`` / ``upper`` accept percentile strings
|
|
460
|
+
(``"98%"``), resolved over the finite nonzero **magnitudes** via
|
|
461
|
+
`_resolve_threshold`: the viewer's window is a divergent magnitude window,
|
|
462
|
+
so its percentiles are magnitude percentiles.
|
|
463
|
+
|
|
464
|
+
``symmetric='auto'`` mirrors the positive and negative limbs only for
|
|
465
|
+
mixed-signed data. ``False`` scales each present sign independently;
|
|
466
|
+
``True`` always mirrors.
|
|
467
|
+
|
|
468
|
+
Every window edge is a **magnitude**, so an explicit ``threshold`` /
|
|
469
|
+
``lower`` / ``upper`` is taken as ``abs()`` of what was passed, and a
|
|
470
|
+
resolved floor at or below zero is raised to the slider's lower bound. A
|
|
471
|
+
floor of zero would admit every zero-valued voxel — the whole volume box
|
|
472
|
+
outside the mask — and paint it in the negative colormap.
|
|
473
|
+
|
|
474
|
+
The slider spans ``(0, max|v|]``, widened as needed to include the
|
|
475
|
+
resolved window so it is always representable (never silently clamped),
|
|
476
|
+
with its handles at the window edges. Its lower bound is strictly
|
|
477
|
+
positive: one step above zero, or the resolved floor when autoscale put
|
|
478
|
+
one below that.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
data (np.ndarray): The BrainData's data array.
|
|
482
|
+
autoscale (bool): See above.
|
|
483
|
+
threshold (float | str | None): Symmetric magnitude floor (ignored when
|
|
484
|
+
``lower``/``upper`` are given).
|
|
485
|
+
lower (float | str | None): Explicit window floor, as a magnitude.
|
|
486
|
+
upper (float | str | None): Explicit window ceiling, as a magnitude.
|
|
487
|
+
symmetric (bool | str): ``True``, ``False``, or ``'auto'``. See above.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
_DisplayWindow: The window endpoints and the slider bounds.
|
|
491
|
+
|
|
492
|
+
Raises:
|
|
493
|
+
TypeError: If ``autoscale`` is not a bool, or ``symmetric`` is not
|
|
494
|
+
``True``, ``False``, or ``'auto'``.
|
|
495
|
+
"""
|
|
496
|
+
import numpy as np
|
|
497
|
+
|
|
498
|
+
from .utils import _resolve_threshold
|
|
499
|
+
|
|
500
|
+
if not isinstance(autoscale, bool):
|
|
501
|
+
raise TypeError("autoscale must be a bool")
|
|
502
|
+
if not (isinstance(symmetric, bool) or symmetric == "auto"):
|
|
503
|
+
raise TypeError("symmetric must be True, False, or 'auto'")
|
|
504
|
+
|
|
505
|
+
# One pass over the array feeds every population below: the magnitudes the
|
|
506
|
+
# percentile specs resolve against, the per-sign limbs, and the slider's
|
|
507
|
+
# finite range.
|
|
508
|
+
arr = np.asarray(data, dtype=float)
|
|
509
|
+
finite = arr[np.isfinite(arr)]
|
|
510
|
+
nonzero = finite[finite != 0]
|
|
511
|
+
magnitudes = np.abs(nonzero)
|
|
512
|
+
positive = nonzero[nonzero > 0]
|
|
513
|
+
negative_magnitudes = np.abs(nonzero[nonzero < 0])
|
|
514
|
+
|
|
515
|
+
def _mag_pct(pct: float) -> float:
|
|
516
|
+
if magnitudes.size == 0:
|
|
517
|
+
return 0.0
|
|
518
|
+
return float(np.percentile(magnitudes, pct))
|
|
519
|
+
|
|
520
|
+
# Resolve percentile strings against the magnitude distribution.
|
|
521
|
+
# `magnitudes` (finite nonzero |values|) is exactly the population
|
|
522
|
+
# _resolve_threshold would keep after its own finite/nonzero filtering, so
|
|
523
|
+
# reuse it instead of materializing np.abs(arr) three times per call
|
|
524
|
+
# (~540 MB of transients on a 100×228k float64 map).
|
|
525
|
+
threshold = _resolve_threshold(threshold, magnitudes)
|
|
526
|
+
lower = _resolve_threshold(lower, magnitudes)
|
|
527
|
+
upper = _resolve_threshold(upper, magnitudes)
|
|
528
|
+
|
|
529
|
+
# Precedence: lower/upper win; else threshold sets the floor.
|
|
530
|
+
if lower is not None or upper is not None:
|
|
531
|
+
floor, ceiling = lower, upper
|
|
532
|
+
elif threshold is not None:
|
|
533
|
+
floor, ceiling = threshold, None
|
|
534
|
+
else:
|
|
535
|
+
floor, ceiling = None, None
|
|
536
|
+
|
|
537
|
+
# Every edge is a magnitude: niivue mirrors [cal_min, cal_max] onto the
|
|
538
|
+
# negative limb, so an explicit edge's sign carries no information.
|
|
539
|
+
# `lower=-1.0` means |v| >= 1, never "everything above -1".
|
|
540
|
+
floor = None if floor is None else abs(float(floor))
|
|
541
|
+
ceiling = None if ceiling is None else abs(float(ceiling))
|
|
542
|
+
|
|
543
|
+
if autoscale is False:
|
|
544
|
+
ceiling = (
|
|
545
|
+
float(magnitudes.max()) if ceiling is None and magnitudes.size else ceiling
|
|
546
|
+
)
|
|
547
|
+
ceiling = 1.0 if ceiling is None else float(ceiling)
|
|
548
|
+
floor = 0.0 if floor is None else float(floor) # raised above zero below
|
|
549
|
+
else:
|
|
550
|
+
if ceiling is None:
|
|
551
|
+
ceiling = _mag_pct(_AUTOSCALE_CEILING_PCT)
|
|
552
|
+
if ceiling == 0.0:
|
|
553
|
+
ceiling = 1.0 # empty / all-zero map: keep a sane window
|
|
554
|
+
if floor is None:
|
|
555
|
+
# The default floor exists to make stored zeros transparent, not to
|
|
556
|
+
# threshold. A bare fraction of the ceiling would start hiding real
|
|
557
|
+
# voxels once the map's dynamic range exceeds 1 / the fraction, so
|
|
558
|
+
# clamp it to the smallest nonzero magnitude.
|
|
559
|
+
epsilon = ceiling * _AUTOSCALE_FLOOR_FRAC
|
|
560
|
+
floor = (
|
|
561
|
+
min(epsilon, float(magnitudes.min())) if magnitudes.size else epsilon
|
|
562
|
+
)
|
|
563
|
+
floor, ceiling = float(floor), float(ceiling)
|
|
564
|
+
|
|
565
|
+
# The slider spans (0, max|v|], widened to include the resolved window so
|
|
566
|
+
# its handles land exactly where asked rather than being clamped. Its
|
|
567
|
+
# lower bound is one slider step above zero — or the resolved floor when
|
|
568
|
+
# autoscale put one below that — but never zero: at a floor of zero every
|
|
569
|
+
# zero-valued voxel, meaning the whole volume box outside the mask,
|
|
570
|
+
# satisfies the magnitude window and paints in the negative colormap.
|
|
571
|
+
span = max(float(magnitudes.max()) if magnitudes.size else 0.0, ceiling, floor)
|
|
572
|
+
if span <= 0.0:
|
|
573
|
+
span = 1.0
|
|
574
|
+
smallest_floor = span / 200.0 # one slider step above zero
|
|
575
|
+
if floor <= 0.0:
|
|
576
|
+
floor = smallest_floor
|
|
577
|
+
ceiling = max(ceiling, floor)
|
|
578
|
+
|
|
579
|
+
cal_min = floor
|
|
580
|
+
use_symmetric = symmetric is True or (
|
|
581
|
+
symmetric == "auto" and positive.size > 0 and negative_magnitudes.size > 0
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
if use_symmetric:
|
|
585
|
+
cal_max, cal_min_neg, cal_max_neg = ceiling, -ceiling, -cal_min
|
|
586
|
+
else:
|
|
587
|
+
explicit_ceiling = upper is not None
|
|
588
|
+
|
|
589
|
+
def _ceiling(sign_values, fallback):
|
|
590
|
+
if explicit_ceiling or sign_values.size == 0:
|
|
591
|
+
return float(fallback)
|
|
592
|
+
if autoscale:
|
|
593
|
+
return float(np.percentile(sign_values, _AUTOSCALE_CEILING_PCT))
|
|
594
|
+
return float(sign_values.max())
|
|
595
|
+
|
|
596
|
+
cal_max = _ceiling(positive, ceiling)
|
|
597
|
+
cal_min_neg = -_ceiling(negative_magnitudes, ceiling)
|
|
598
|
+
cal_max_neg = -cal_min
|
|
599
|
+
# A per-limb percentile ceiling can land below an explicit floor, which
|
|
600
|
+
# would invert the window and the slider handles.
|
|
601
|
+
cal_max = max(cal_max, cal_min)
|
|
602
|
+
cal_min_neg = min(cal_min_neg, cal_max_neg)
|
|
603
|
+
|
|
604
|
+
slider_min = min(smallest_floor, cal_min)
|
|
605
|
+
slider_max = max(span, cal_min, cal_max)
|
|
606
|
+
slider_step = slider_max / 200.0
|
|
607
|
+
|
|
608
|
+
return _DisplayWindow(
|
|
609
|
+
cal_min=cal_min,
|
|
610
|
+
cal_max=cal_max,
|
|
611
|
+
cal_min_neg=cal_min_neg,
|
|
612
|
+
cal_max_neg=cal_max_neg,
|
|
613
|
+
mirror_negative=use_symmetric,
|
|
614
|
+
slider_min=slider_min,
|
|
615
|
+
slider_max=slider_max,
|
|
616
|
+
slider_value_low=cal_min,
|
|
617
|
+
slider_value_high=cal_max,
|
|
618
|
+
slider_step=slider_step,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# --------------------------------------------------------------------------- #
|
|
623
|
+
# Widget (imperative shell)
|
|
624
|
+
# --------------------------------------------------------------------------- #
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class _NiivueViewer(anywidget.AnyWidget):
|
|
628
|
+
"""anywidget wrapper around ``@niivue/niivue``, driven via the standard API.
|
|
629
|
+
|
|
630
|
+
Holds the volume stack as byte traits (``bg_bytes`` / ``statmap_bytes`` /
|
|
631
|
+
``atlas_bytes``, any empty and skipped) plus display-parameter traits that
|
|
632
|
+
``viewer.js`` reads to configure niivue. Scalar traits (``cal_min`` /
|
|
633
|
+
``cal_max`` / negative endpoints / ``slice_type`` / ``colorbar`` /
|
|
634
|
+
``atlas_outline``) are
|
|
635
|
+
reactive: set them from Python and the frontend updates in place; the
|
|
636
|
+
in-widget threshold slider writes ``cal_min`` / ``cal_max`` back.
|
|
637
|
+
|
|
638
|
+
Not constructed directly — `_build_viewer` fills it from a BrainData.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
_esm = _VIEWER_JS
|
|
642
|
+
|
|
643
|
+
# Volume bytes (empty == absent). NIfTI-1, sent as one buffer each.
|
|
644
|
+
bg_bytes = traitlets.Bytes(b"").tag(sync=True)
|
|
645
|
+
statmap_bytes = traitlets.Bytes(b"").tag(sync=True)
|
|
646
|
+
atlas_bytes = traitlets.Bytes(b"").tag(sync=True)
|
|
647
|
+
|
|
648
|
+
# Display params for the stat map (name/colormap/colormap_negative/opacity)
|
|
649
|
+
# and the atlas overlay (name + integer-indexed label LUT).
|
|
650
|
+
statmap = traitlets.Dict().tag(sync=True)
|
|
651
|
+
atlas_name = traitlets.Unicode("").tag(sync=True)
|
|
652
|
+
atlas_lut = traitlets.Dict().tag(sync=True)
|
|
653
|
+
|
|
654
|
+
# Reactive stat-map window; None == niivue auto (percentile-derived).
|
|
655
|
+
cal_min = traitlets.Float(None, allow_none=True).tag(sync=True)
|
|
656
|
+
cal_max = traitlets.Float(None, allow_none=True).tag(sync=True)
|
|
657
|
+
cal_min_neg = traitlets.Float(None, allow_none=True).tag(sync=True)
|
|
658
|
+
cal_max_neg = traitlets.Float(None, allow_none=True).tag(sync=True)
|
|
659
|
+
mirror_negative = traitlets.Bool(True).tag(sync=True)
|
|
660
|
+
|
|
661
|
+
slice_type = traitlets.Unicode("MULTIPLANAR").tag(sync=True)
|
|
662
|
+
colorbar = traitlets.Bool(True).tag(sync=True)
|
|
663
|
+
atlas_outline = traitlets.Float(0.0).tag(sync=True)
|
|
664
|
+
|
|
665
|
+
# Controls + layout.
|
|
666
|
+
controls = traitlets.Bool(True).tag(sync=True)
|
|
667
|
+
slider_bounds = traitlets.Dict().tag(sync=True)
|
|
668
|
+
height = traitlets.Int(600).tag(sync=True)
|
|
669
|
+
|
|
670
|
+
# Extra niivue ConfigOptions forwarded verbatim to ``new Niivue(opts)``.
|
|
671
|
+
niivue_opts = traitlets.Dict().tag(sync=True)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _build_viewer(
|
|
675
|
+
bd,
|
|
676
|
+
*,
|
|
677
|
+
window: _DisplayWindow,
|
|
678
|
+
view: str = "ortho",
|
|
679
|
+
cmap: str | None = None,
|
|
680
|
+
atlas: str | _Atlas | None = None,
|
|
681
|
+
bg_img: str | bool | None = None,
|
|
682
|
+
opacity: float = 1.0,
|
|
683
|
+
outline: float = 0.0,
|
|
684
|
+
colorbar: bool = True,
|
|
685
|
+
controls: bool = True,
|
|
686
|
+
niivue_opts: dict | None = None,
|
|
687
|
+
) -> _NiivueViewer:
|
|
688
|
+
"""Assemble a configured `_NiivueViewer` for a BrainData.
|
|
689
|
+
|
|
690
|
+
Builds the volume stack ``[background?, statmap, atlas?]`` (atlas on top
|
|
691
|
+
so its outlines/opacity keep the stat map readable) as byte + parameter
|
|
692
|
+
traits, and sets the slice type.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
bd (BrainData): BrainData to view.
|
|
696
|
+
window: The resolved display window and slider bounds. Required, and
|
|
697
|
+
must be built from ``bd.data`` with `_compute_display_window` — the
|
|
698
|
+
slider handles and the rendered window come from it together, so
|
|
699
|
+
they can never disagree.
|
|
700
|
+
view: See `_slice_type_for`.
|
|
701
|
+
cmap: Positive colormap (niivue or matplotlib name). ``None`` uses the
|
|
702
|
+
sign-aware red-positive/blue-negative default.
|
|
703
|
+
atlas: Atlas name, `_Atlas`, or ``None``.
|
|
704
|
+
bg_img: See `_resolve_background`.
|
|
705
|
+
opacity: Stat-map (and filled-atlas) opacity.
|
|
706
|
+
outline: ``> 0`` draws atlas region boundaries of that width;
|
|
707
|
+
``0`` draws filled regions.
|
|
708
|
+
colorbar: Show the stat-map colorbar (the ``cmap`` scale). Only the
|
|
709
|
+
stat map carries a colorbar; the background and atlas overlays are
|
|
710
|
+
suppressed by the frontend.
|
|
711
|
+
controls: Render the in-widget threshold slider (default ``True``).
|
|
712
|
+
niivue_opts: Extra kwargs forwarded verbatim to ``new Niivue(opts)``.
|
|
713
|
+
A ``height`` key overrides the 600px ortho and 400px single-view
|
|
714
|
+
defaults; an ``is_colorbar`` key overrides ``colorbar``.
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
_NiivueViewer: A configured widget ready to display.
|
|
718
|
+
"""
|
|
719
|
+
# niivue selects the positive/negative limb from the voxel sign, so this
|
|
720
|
+
# pair is already sign-aware without swapping colormap names per map.
|
|
721
|
+
cmap_resolved = "warm" if cmap is None else _resolve_cmap(cmap)
|
|
722
|
+
cmap_negative = _divergent_partner(cmap_resolved)
|
|
723
|
+
slice_name = _slice_type_for(view)
|
|
724
|
+
atlas_obj = _coerce_atlas(atlas)
|
|
725
|
+
bg_path = _resolve_background(bd.mask.affine, bg_img)
|
|
726
|
+
|
|
727
|
+
# Validate / compute the atlas LUT up front so a probabilistic atlas
|
|
728
|
+
# raises before we serialize any image bytes.
|
|
729
|
+
atlas_lut = _atlas_to_label_lut(atlas_obj) if atlas_obj is not None else {}
|
|
730
|
+
|
|
731
|
+
# Pull height / is_colorbar out of the forwarded niivue opts: height is a
|
|
732
|
+
# canvas-layout trait, and an explicit is_colorbar wins over colorbar=.
|
|
733
|
+
opts = dict(niivue_opts or {})
|
|
734
|
+
height = int(opts.pop("height", 600 if view == "ortho" else 400))
|
|
735
|
+
if "is_colorbar" in opts:
|
|
736
|
+
colorbar = bool(opts.pop("is_colorbar"))
|
|
737
|
+
|
|
738
|
+
return _NiivueViewer(
|
|
739
|
+
bg_bytes=_gzip_nifti(pathlib.Path(bg_path).read_bytes()) if bg_path else b"",
|
|
740
|
+
statmap_bytes=_bd_to_nifti_bytes(bd),
|
|
741
|
+
atlas_bytes=(
|
|
742
|
+
_gzip_nifti(atlas_obj.image.to_bytes()) if atlas_obj is not None else b""
|
|
743
|
+
),
|
|
744
|
+
statmap={
|
|
745
|
+
"name": "statmap",
|
|
746
|
+
"colormap": cmap_resolved,
|
|
747
|
+
"colormap_negative": cmap_negative,
|
|
748
|
+
"opacity": float(opacity),
|
|
749
|
+
},
|
|
750
|
+
atlas_name=atlas_obj.name if atlas_obj is not None else "",
|
|
751
|
+
atlas_lut=atlas_lut,
|
|
752
|
+
cal_min=window.cal_min,
|
|
753
|
+
cal_max=window.cal_max,
|
|
754
|
+
cal_min_neg=window.cal_min_neg,
|
|
755
|
+
cal_max_neg=window.cal_max_neg,
|
|
756
|
+
mirror_negative=window.mirror_negative,
|
|
757
|
+
slice_type=slice_name,
|
|
758
|
+
colorbar=bool(colorbar),
|
|
759
|
+
atlas_outline=float(outline) if atlas_obj is not None else 0.0,
|
|
760
|
+
controls=bool(controls),
|
|
761
|
+
slider_bounds={
|
|
762
|
+
"min": window.slider_min,
|
|
763
|
+
"max": window.slider_max,
|
|
764
|
+
"value_low": window.slider_value_low,
|
|
765
|
+
"value_high": window.slider_value_high,
|
|
766
|
+
"step": window.slider_step,
|
|
767
|
+
},
|
|
768
|
+
height=height,
|
|
769
|
+
niivue_opts=opts,
|
|
770
|
+
)
|