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.
Files changed (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. 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
+ )