PySourceviz 0.1.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.
@@ -0,0 +1,1718 @@
1
+ """Cortical source-estimate plotting for PySourceviz."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import inspect
7
+ import math
8
+ import os
9
+ import re
10
+ import sys
11
+ import warnings
12
+ from dataclasses import dataclass
13
+ from functools import lru_cache
14
+ from numbers import Integral, Real
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+
18
+ import mne
19
+ import numpy as np
20
+ from nibabel.freesurfer.io import read_morph_data
21
+
22
+
23
+ _import_module = importlib.import_module
24
+ _NILEARN_SULC_RANGE = (0.03, 0.22)
25
+
26
+
27
+ def _validate_choice(name: str, value: str, allowed: tuple[str, ...]) -> None:
28
+ if value not in allowed:
29
+ choices = ", ".join(repr(choice) for choice in allowed)
30
+ raise ValueError(f"{name} must be one of {choices}; got {value!r}.")
31
+
32
+
33
+ def _finite_real(name: str, value: object) -> float:
34
+ if isinstance(value, bool) or not isinstance(value, Real):
35
+ raise TypeError(f"{name} must be a finite real number; got {value!r}.")
36
+ result = float(value)
37
+ if not math.isfinite(result):
38
+ raise ValueError(f"{name} must be finite; got {value!r}.")
39
+ return result
40
+
41
+
42
+ def _validate_time_selection(
43
+ time: float | Literal["peak"] | None,
44
+ time_window: tuple[float, float] | None,
45
+ ) -> None:
46
+ if time is not None and time_window is not None:
47
+ raise ValueError("time and time_window are mutually exclusive.")
48
+ if time is not None:
49
+ if isinstance(time, str):
50
+ if time != "peak":
51
+ raise ValueError("time must be a finite number, 'peak', or None.")
52
+ else:
53
+ _finite_real("time", time)
54
+ if time_window is not None:
55
+ if not isinstance(time_window, tuple) or len(time_window) != 2:
56
+ raise TypeError("time_window must be a two-item tuple.")
57
+ start = _finite_real("time_window start", time_window[0])
58
+ stop = _finite_real("time_window stop", time_window[1])
59
+ if start > stop:
60
+ raise ValueError("time_window start must not exceed its stop.")
61
+
62
+
63
+ def _validate_positive_int_pair(
64
+ name: str,
65
+ value: tuple[int, int],
66
+ ) -> tuple[int, int]:
67
+ if not isinstance(value, tuple) or len(value) != 2:
68
+ raise TypeError(f"{name} must be a two-item tuple of positive integers.")
69
+ if any(
70
+ isinstance(item, bool) or not isinstance(item, Integral) for item in value
71
+ ):
72
+ raise TypeError(f"{name} must contain positive integers.")
73
+ result = (int(value[0]), int(value[1]))
74
+ if any(item <= 0 for item in result):
75
+ raise ValueError(f"{name} values must be positive.")
76
+ return result
77
+
78
+
79
+ def _validate_smoothing_steps(
80
+ value: int | Literal["nearest"] | None,
81
+ ) -> int | Literal["nearest"] | None:
82
+ if value is None:
83
+ return None
84
+ if isinstance(value, str):
85
+ if value == "nearest":
86
+ return value
87
+ raise ValueError(
88
+ "smoothing_steps must be None, 'nearest', or a non-negative "
89
+ f"integer; got {value!r}."
90
+ )
91
+ if isinstance(value, bool) or not isinstance(value, Integral):
92
+ raise TypeError(
93
+ "smoothing_steps must be None, 'nearest', or a non-negative "
94
+ f"integer; got {value!r}."
95
+ )
96
+ result = int(value)
97
+ if result < 0:
98
+ raise ValueError("smoothing_steps must be non-negative.")
99
+ return result
100
+
101
+
102
+ def _resolve_output_and_show(
103
+ output: str | Path | None,
104
+ show: bool | None,
105
+ ) -> tuple[Path | None, bool]:
106
+ if output is not None and not isinstance(output, (str, os.PathLike)):
107
+ raise TypeError("output must be a string, path-like object, or None.")
108
+ if show is not None and not isinstance(show, bool):
109
+ raise TypeError("show must be a bool or None.")
110
+ output_path = Path(output) if output is not None else None
111
+ return output_path, (output_path is None if show is None else show)
112
+
113
+
114
+ def _validate_phase_one_options(
115
+ *,
116
+ subjects_dir: str | Path,
117
+ backend: str,
118
+ surface: str,
119
+ time: float | str | None,
120
+ time_window: tuple[float, float] | None,
121
+ reduce: str,
122
+ mode: str,
123
+ robust_percentile: float,
124
+ layout: str,
125
+ output: str | Path | None,
126
+ size: tuple[int, int] | None,
127
+ scale: tuple[int, int],
128
+ show: bool | None,
129
+ ) -> None:
130
+ if not isinstance(subjects_dir, (str, os.PathLike)):
131
+ raise TypeError("subjects_dir must be a non-empty path-like value.")
132
+ if isinstance(subjects_dir, str) and not subjects_dir.strip():
133
+ raise ValueError("subjects_dir must not be empty.")
134
+ _validate_choice("backend", backend, ("brainspace", "nilearn"))
135
+ _validate_choice("surface", surface, ("inflated", "pial", "white"))
136
+ _validate_choice("layout", layout, ("row", "grid"))
137
+ _validate_choice("mode", mode, ("magnitude", "signed"))
138
+ _validate_choice("reduce", reduce, ("mean", "rms"))
139
+ _validate_time_selection(time, time_window)
140
+ percentile = _finite_real("robust_percentile", robust_percentile)
141
+ if not 0.0 < percentile <= 100.0:
142
+ raise ValueError("robust_percentile must be in the interval (0, 100].")
143
+ if size is not None:
144
+ _validate_positive_int_pair("size", size)
145
+ _validate_positive_int_pair("scale", scale)
146
+ _resolve_output_and_show(output, show)
147
+
148
+
149
+ def _classify_stc(stc: Any) -> str:
150
+ kinds = (
151
+ (mne.SourceEstimate, "scalar"),
152
+ (mne.VectorSourceEstimate, "vector"),
153
+ (mne.MixedSourceEstimate, "mixed"),
154
+ (mne.MixedVectorSourceEstimate, "mixed-vector"),
155
+ )
156
+ if isinstance(stc, (mne.VolSourceEstimate, mne.VolVectorSourceEstimate)):
157
+ raise TypeError(
158
+ "PySourceviz v0.1 requires a real cortical surface source "
159
+ "estimate; volume-only STCs are not supported."
160
+ )
161
+ for cls, kind in kinds:
162
+ if isinstance(stc, cls):
163
+ if np.iscomplexobj(stc.data):
164
+ raise TypeError(
165
+ "PySourceviz v0.1 requires real data; complex-valued "
166
+ "STCs are not supported."
167
+ )
168
+ return kind
169
+ raise TypeError(
170
+ "PySourceviz v0.1 requires a real cortical surface SourceEstimate, "
171
+ "VectorSourceEstimate, or mixed equivalent."
172
+ )
173
+
174
+
175
+ def _copy_surface_stc(
176
+ stc: mne.SourceEstimate,
177
+ data: np.ndarray,
178
+ *,
179
+ tmin: float | None = None,
180
+ ) -> mne.SourceEstimate:
181
+ return mne.SourceEstimate(
182
+ np.array(data, dtype=float, copy=True),
183
+ [
184
+ np.array(vertices, dtype=int, copy=True)
185
+ for vertices in stc.vertices
186
+ ],
187
+ tmin=float(stc.tmin if tmin is None else tmin),
188
+ tstep=float(stc.tstep),
189
+ subject=stc.subject,
190
+ )
191
+
192
+
193
+ def _normalize_surface_stc(stc: Any, mode: str) -> mne.SourceEstimate:
194
+ kind = _classify_stc(stc)
195
+ working = stc.copy()
196
+ if kind in ("mixed", "mixed-vector"):
197
+ working = working.surface()
198
+ if kind in ("vector", "mixed-vector"):
199
+ working = working.magnitude()
200
+ data = np.asarray(working.data)
201
+ if mode == "magnitude":
202
+ data = np.abs(data)
203
+ return _copy_surface_stc(working, data)
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class _TimeSummary:
208
+ stc: mne.SourceEstimate
209
+ selection: str
210
+ selected_time: float | None
211
+ requested_window: tuple[float, float] | None
212
+ sampled_window: tuple[float, float] | None
213
+ reduce: str | None
214
+
215
+
216
+ def _summarize_time(
217
+ stc: mne.SourceEstimate,
218
+ time: float | str | None,
219
+ time_window: tuple[float, float] | None,
220
+ reduce: str,
221
+ ) -> _TimeSummary:
222
+ times = np.asarray(stc.times, dtype=float)
223
+ if time is None and time_window is None:
224
+ if times.size != 1:
225
+ raise ValueError(
226
+ "STC has multiple time samples; specify time or time_window."
227
+ )
228
+ selected_time = float(times[0])
229
+ one_time = _copy_surface_stc(
230
+ stc,
231
+ stc.data[:, [0]],
232
+ tmin=selected_time,
233
+ )
234
+ return _TimeSummary(
235
+ one_time,
236
+ "single",
237
+ selected_time,
238
+ None,
239
+ None,
240
+ None,
241
+ )
242
+
243
+ if time == "peak":
244
+ score = np.abs(np.asarray(stc.data, dtype=float))
245
+ finite = np.isfinite(score)
246
+ if not finite.any():
247
+ raise ValueError(
248
+ "time='peak' requires at least one finite source value."
249
+ )
250
+ score = np.where(finite, score, -np.inf)
251
+ index = int(np.unravel_index(np.argmax(score), score.shape)[1])
252
+ selected_time = float(times[index])
253
+ one_time = _copy_surface_stc(
254
+ stc,
255
+ stc.data[:, [index]],
256
+ tmin=selected_time,
257
+ )
258
+ return _TimeSummary(
259
+ one_time,
260
+ "peak",
261
+ selected_time,
262
+ None,
263
+ None,
264
+ None,
265
+ )
266
+
267
+ if time is not None:
268
+ requested_time = float(time)
269
+ distances = np.abs(times - requested_time)
270
+ tolerance = (
271
+ np.finfo(float).eps
272
+ * max(1.0, abs(requested_time), float(np.max(np.abs(times))))
273
+ * 8
274
+ )
275
+ candidates = np.flatnonzero(
276
+ distances <= float(np.min(distances)) + tolerance
277
+ )
278
+ index = int(candidates[0])
279
+ selected_time = float(times[index])
280
+ one_time = _copy_surface_stc(
281
+ stc,
282
+ stc.data[:, [index]],
283
+ tmin=selected_time,
284
+ )
285
+ return _TimeSummary(
286
+ one_time,
287
+ "time",
288
+ selected_time,
289
+ None,
290
+ None,
291
+ None,
292
+ )
293
+
294
+ start, stop = (float(value) for value in time_window)
295
+ if start > stop:
296
+ raise ValueError("time_window start must not exceed its stop.")
297
+ tolerance = (
298
+ np.finfo(float).eps
299
+ * max(1.0, float(np.max(np.abs(times))))
300
+ * 8
301
+ )
302
+ if start < times[0] - tolerance or stop > times[-1] + tolerance:
303
+ raise ValueError(
304
+ f"time_window {(start, stop)!r} is outside STC coverage "
305
+ f"[{times[0]}, {times[-1]}]."
306
+ )
307
+ mask = (times >= start - tolerance) & (times <= stop + tolerance)
308
+ if not np.any(mask):
309
+ raise ValueError("time_window does not contain an STC sample.")
310
+ selected = np.asarray(stc.data[:, mask], dtype=float)
311
+ if reduce == "mean":
312
+ data = selected.mean(axis=1)
313
+ else:
314
+ data = np.sqrt(np.mean(np.square(selected), axis=1))
315
+ sampled_times = times[mask]
316
+ representative_time = float(sampled_times.mean())
317
+ one_time = _copy_surface_stc(
318
+ stc,
319
+ data[:, np.newaxis],
320
+ tmin=representative_time,
321
+ )
322
+ return _TimeSummary(
323
+ one_time,
324
+ "window",
325
+ None,
326
+ (start, stop),
327
+ (float(sampled_times[0]), float(sampled_times[-1])),
328
+ reduce,
329
+ )
330
+
331
+
332
+ _PERCENT_THRESHOLD = re.compile(
333
+ r"\s*(?:\d+(?:\.\d*)?|\.\d+)\s*%\s*"
334
+ )
335
+ _ACTIVATION_OPACITY = 0.72
336
+
337
+
338
+ def _finite_sparse_values(
339
+ values: np.ndarray,
340
+ *,
341
+ absolute: bool = False,
342
+ ) -> np.ndarray:
343
+ result = np.asarray(values, dtype=float).ravel()
344
+ result = result[np.isfinite(result)]
345
+ if absolute:
346
+ result = np.abs(result)
347
+ if result.size == 0:
348
+ raise ValueError(
349
+ "Display statistics require at least one finite sparse value."
350
+ )
351
+ return result
352
+
353
+
354
+ def _parse_threshold(
355
+ threshold: float | str | None,
356
+ sparse_values: np.ndarray,
357
+ ) -> float | None:
358
+ if threshold is None:
359
+ return None
360
+ if isinstance(threshold, str):
361
+ if _PERCENT_THRESHOLD.fullmatch(threshold) is None:
362
+ raise ValueError(
363
+ "threshold must be a non-negative number or a percentage "
364
+ "such as '75%'."
365
+ )
366
+ percentile = float(threshold.strip()[:-1])
367
+ if not 0.0 <= percentile <= 100.0:
368
+ raise ValueError(
369
+ "threshold percentile must be in the interval [0, 100]."
370
+ )
371
+ values = _finite_sparse_values(sparse_values, absolute=True)
372
+ return float(np.percentile(values, percentile))
373
+ value = _finite_real("threshold", threshold)
374
+ if value < 0.0:
375
+ raise ValueError("threshold must be non-negative.")
376
+ return value
377
+
378
+
379
+ @dataclass(frozen=True)
380
+ class _ColorScale:
381
+ color_range: tuple[float, float]
382
+ control_points: tuple[float, float, float] | None
383
+
384
+
385
+ def _resolve_color_scale(
386
+ clim: str | tuple[float, float] | dict[str, Any],
387
+ robust_percentile: float,
388
+ mode: str,
389
+ threshold: float | None,
390
+ sparse_values: np.ndarray,
391
+ ) -> _ColorScale:
392
+ values = _finite_sparse_values(
393
+ sparse_values,
394
+ absolute=(mode == "signed"),
395
+ )
396
+ control_points = None
397
+ if isinstance(clim, str):
398
+ if clim != "robust":
399
+ raise ValueError("clim string value must be 'robust'.")
400
+ vmax = float(np.percentile(values, robust_percentile))
401
+ if mode == "signed":
402
+ bounds = (-vmax, vmax)
403
+ else:
404
+ bounds = (
405
+ 0.0 if threshold is None else threshold,
406
+ vmax,
407
+ )
408
+ elif isinstance(clim, dict):
409
+ allowed_keys = {"kind", "lims", "pos_lims"}
410
+ unsupported = set(clim).difference(allowed_keys)
411
+ if unsupported:
412
+ names = ", ".join(sorted(str(key) for key in unsupported))
413
+ raise ValueError(f"clim contains unsupported key(s): {names}.")
414
+ has_lims = "lims" in clim
415
+ has_pos_lims = "pos_lims" in clim
416
+ if has_lims + has_pos_lims != 1:
417
+ raise ValueError(
418
+ "clim must contain exactly one of 'lims' and 'pos_lims'."
419
+ )
420
+ required_key = "pos_lims" if mode == "signed" else "lims"
421
+ if required_key not in clim:
422
+ raise ValueError(
423
+ f"{mode} mode requires clim['{required_key}']."
424
+ )
425
+ kind = clim.get("kind", "percent")
426
+ if kind not in ("percent", "value"):
427
+ raise ValueError("clim kind must be 'percent' or 'value'.")
428
+ requested = clim[required_key]
429
+ if (
430
+ not isinstance(requested, (list, tuple, np.ndarray))
431
+ or len(requested) != 3
432
+ ):
433
+ raise TypeError(
434
+ f"clim['{required_key}'] must contain three controls."
435
+ )
436
+ controls = np.array(
437
+ [
438
+ _finite_real(f"clim {required_key} control", item)
439
+ for item in requested
440
+ ],
441
+ dtype=float,
442
+ )
443
+ if np.any(controls < 0.0):
444
+ raise ValueError("clim controls must be non-negative.")
445
+ if np.any(np.diff(controls) < 0.0):
446
+ raise ValueError(
447
+ "clim controls must be monotonically increasing or equal."
448
+ )
449
+ if kind == "percent":
450
+ if np.any(controls > 100.0):
451
+ raise ValueError(
452
+ "clim percent controls must be in the interval [0, 100]."
453
+ )
454
+ controls = np.percentile(values, controls)
455
+ fmin, fmid, fmax = (float(item) for item in controls)
456
+ if fmax <= fmin:
457
+ raise ValueError(
458
+ "Resolved clim lower and upper controls must be distinct."
459
+ )
460
+ epsilon = 1e-5 * (fmax - fmin)
461
+ if fmid <= fmin:
462
+ fmid = fmin + epsilon
463
+ elif fmid >= fmax:
464
+ fmid = fmax - epsilon
465
+ control_points = (fmin, fmid, fmax)
466
+ bounds = (-fmax, fmax) if mode == "signed" else (fmin, fmax)
467
+ else:
468
+ if not isinstance(clim, tuple) or len(clim) != 2:
469
+ raise TypeError(
470
+ "clim must be 'robust', a two-item tuple, or an MNE-style "
471
+ "dictionary."
472
+ )
473
+ lower = _finite_real("clim lower", clim[0])
474
+ upper = _finite_real("clim upper", clim[1])
475
+ if upper <= lower:
476
+ raise ValueError(
477
+ "clim upper bound must exceed its lower bound."
478
+ )
479
+ if mode == "magnitude" and lower < 0.0:
480
+ raise ValueError(
481
+ "magnitude clim must have a non-negative lower bound."
482
+ )
483
+ if mode == "signed":
484
+ limit = max(abs(lower), abs(upper))
485
+ bounds = (-limit, limit)
486
+ else:
487
+ bounds = (lower, upper)
488
+ if bounds[1] <= bounds[0]:
489
+ raise ValueError(
490
+ "Resolved color range must have strictly increasing bounds."
491
+ )
492
+ if threshold is not None and threshold >= bounds[1]:
493
+ raise ValueError(
494
+ "threshold must be below the color-range upper bound."
495
+ )
496
+ return _ColorScale(
497
+ color_range=(float(bounds[0]), float(bounds[1])),
498
+ control_points=control_points,
499
+ )
500
+
501
+
502
+ def _resolve_color_range(
503
+ clim: str | tuple[float, float] | dict[str, Any],
504
+ robust_percentile: float,
505
+ mode: str,
506
+ threshold: float | None,
507
+ sparse_values: np.ndarray,
508
+ ) -> tuple[float, float]:
509
+ return _resolve_color_scale(
510
+ clim,
511
+ robust_percentile,
512
+ mode,
513
+ threshold,
514
+ sparse_values,
515
+ ).color_range
516
+
517
+
518
+ def _resolve_cmap(cmap: str | None, mode: str) -> str:
519
+ if cmap is not None and not isinstance(cmap, str):
520
+ raise TypeError("cmap must be a string or None.")
521
+ return cmap or ("Reds" if mode == "magnitude" else "RdBu_r")
522
+
523
+
524
+ def _build_render_cmap(
525
+ cmap_name: str,
526
+ mode: str,
527
+ color_range: tuple[float, float],
528
+ control_points: tuple[float, float, float] | None,
529
+ ) -> str | Any:
530
+ if control_points is None:
531
+ return cmap_name
532
+ from matplotlib import colormaps
533
+ from matplotlib.colors import ListedColormap
534
+
535
+ fmin, fmid, fmax = control_points
536
+ vmin, vmax = color_range
537
+ samples = np.linspace(0.0, 1.0, 256)
538
+ if mode == "magnitude":
539
+ control_locations = np.array(
540
+ [fmin, fmid, fmax], dtype=float
541
+ )
542
+ control_locations = (control_locations - vmin) / (vmax - vmin)
543
+ palette_locations = np.array([0.0, 0.5, 1.0])
544
+ alpha_controls = np.array([0.0, _ACTIVATION_OPACITY, _ACTIVATION_OPACITY])
545
+ else:
546
+ control_locations = np.array(
547
+ [-fmax, -fmid, -fmin, 0.0, fmin, fmid, fmax],
548
+ dtype=float,
549
+ )
550
+ control_locations = (control_locations - vmin) / (vmax - vmin)
551
+ palette_locations = np.array(
552
+ [0.0, 0.25, 0.5, 0.5, 0.5, 0.75, 1.0]
553
+ )
554
+ alpha_controls = np.array(
555
+ [
556
+ _ACTIVATION_OPACITY,
557
+ _ACTIVATION_OPACITY,
558
+ 0.0,
559
+ 0.0,
560
+ 0.0,
561
+ _ACTIVATION_OPACITY,
562
+ _ACTIVATION_OPACITY,
563
+ ]
564
+ )
565
+ palette_samples = np.interp(
566
+ samples,
567
+ control_locations,
568
+ palette_locations,
569
+ )
570
+ alpha_samples = np.interp(
571
+ samples,
572
+ control_locations,
573
+ alpha_controls,
574
+ )
575
+ rgba = np.asarray(colormaps[cmap_name](palette_samples), dtype=float)
576
+ rgba[:, 3] = alpha_samples
577
+ return ListedColormap(
578
+ rgba,
579
+ name=f"pysourceviz_{cmap_name}_control",
580
+ )
581
+
582
+
583
+ def _expand_to_full_surface(
584
+ one_time_stc: mne.SourceEstimate,
585
+ template: str,
586
+ subjects_dir: str | Path,
587
+ smoothing_steps: int | Literal["nearest"] | None = None,
588
+ ) -> mne.SourceEstimate:
589
+ try:
590
+ morph = mne.compute_source_morph(
591
+ one_time_stc,
592
+ subject_from=template,
593
+ subject_to=template,
594
+ subjects_dir=str(subjects_dir),
595
+ spacing=None,
596
+ smooth=smoothing_steps,
597
+ warn=isinstance(smoothing_steps, Integral),
598
+ )
599
+ full_stc = morph.apply(one_time_stc)
600
+ except Exception as error:
601
+ raise RuntimeError(
602
+ f"Could not expand source data on template {template!r}."
603
+ ) from error
604
+ if (
605
+ not isinstance(full_stc, mne.SourceEstimate)
606
+ or len(full_stc.vertices) != 2
607
+ ):
608
+ raise TypeError(
609
+ "surface morph must return a scalar two-hemisphere "
610
+ "SourceEstimate."
611
+ )
612
+ return full_stc
613
+
614
+
615
+ def _load_cortex_mask(
616
+ subjects_dir: str | Path,
617
+ template: str,
618
+ vertices: list[np.ndarray],
619
+ ) -> tuple[np.ndarray, np.ndarray]:
620
+ label_dir = Path(subjects_dir) / template / "label"
621
+ paths = (
622
+ label_dir / "lh.cortex.label",
623
+ label_dir / "rh.cortex.label",
624
+ )
625
+ missing = [path for path in paths if not path.is_file()]
626
+ if missing:
627
+ warnings.warn(
628
+ "Cortex label file(s) missing; medial-wall masking is disabled "
629
+ "for the affected hemisphere(s): "
630
+ + ", ".join(str(path) for path in missing),
631
+ RuntimeWarning,
632
+ stacklevel=2,
633
+ )
634
+ masks = []
635
+ for path, hemi_vertices in zip(paths, vertices):
636
+ if path.is_file():
637
+ label = mne.read_label(str(path))
638
+ masks.append(np.isin(hemi_vertices, label.vertices))
639
+ else:
640
+ masks.append(np.ones(len(hemi_vertices), dtype=bool))
641
+ return masks[0], masks[1]
642
+
643
+
644
+ def _mask_dense_activation(
645
+ full_stc: mne.SourceEstimate,
646
+ threshold: float | None,
647
+ cortex_mask: tuple[np.ndarray, np.ndarray],
648
+ ) -> tuple[np.ndarray, np.ndarray]:
649
+ split = len(full_stc.vertices[0])
650
+ values = np.asarray(full_stc.data[:, 0], dtype=float)
651
+ activation = [values[:split].copy(), values[split:].copy()]
652
+ for hemi in range(2):
653
+ mask = np.asarray(cortex_mask[hemi], dtype=bool)
654
+ if mask.shape != activation[hemi].shape:
655
+ raise ValueError(
656
+ "cortex mask length must match full surface vertices."
657
+ )
658
+ if threshold is not None:
659
+ hidden = np.abs(activation[hemi]) < threshold
660
+ activation[hemi][hidden] = np.nan
661
+ activation[hemi][~mask] = np.nan
662
+ return activation[0], activation[1]
663
+
664
+
665
+ @dataclass(frozen=True)
666
+ class _PreparedSource:
667
+ activation: tuple[np.ndarray, np.ndarray]
668
+ sparse_values: np.ndarray
669
+ template: str
670
+ smoothing_steps: int | Literal["nearest"] | None
671
+ selection: str
672
+ selected_time: float | None
673
+ time_window: tuple[float, float] | None
674
+ sampled_time_window: tuple[float, float] | None
675
+ reduce: str | None
676
+ mode: str
677
+ threshold: float | None
678
+ color_range: tuple[float, float]
679
+ color_control_points: tuple[float, float, float] | None
680
+ cmap: str
681
+ render_cmap: Any
682
+
683
+
684
+ @dataclass(frozen=True)
685
+ class _HemisphereGeometry:
686
+ coordinates: np.ndarray
687
+ faces: np.ndarray
688
+ sulc: np.ndarray
689
+
690
+
691
+ @dataclass(frozen=True)
692
+ class _SurfaceGeometry:
693
+ lh: _HemisphereGeometry
694
+ rh: _HemisphereGeometry
695
+
696
+
697
+ @dataclass(frozen=True)
698
+ class _PanelSpec:
699
+ row: int
700
+ column: int
701
+ hemi: Literal["left", "right"]
702
+ view: Literal["lateral", "medial"]
703
+ label: str
704
+
705
+
706
+ @dataclass(frozen=True)
707
+ class _LayoutSpec:
708
+ surface_layout: list[list[str]]
709
+ views: list[list[str]]
710
+ label_text: dict[str, list[str]]
711
+ size: tuple[int, int]
712
+ shape: tuple[int, int]
713
+ panels: tuple[_PanelSpec, ...]
714
+
715
+
716
+ def _normalize_sulc(sulc: np.ndarray) -> np.ndarray:
717
+ values = np.asarray(sulc, dtype=float)
718
+ finite = np.isfinite(values)
719
+ if not finite.any():
720
+ raise ValueError(
721
+ "sulc data must contain at least one finite value."
722
+ )
723
+ scale = float(np.percentile(np.abs(values[finite]), 95.0))
724
+ if scale == 0.0:
725
+ return np.zeros(values.shape, dtype=float)
726
+ clean = np.where(finite, values, 0.0)
727
+ return np.clip(clean / scale, -1.0, 1.0)
728
+
729
+
730
+ def _readonly_array(array: np.ndarray, dtype: Any) -> np.ndarray:
731
+ result = np.array(array, dtype=dtype, copy=True)
732
+ result.setflags(write=False)
733
+ return result
734
+
735
+
736
+ def _validate_hemisphere_geometry(
737
+ hemi: str,
738
+ coordinates: np.ndarray,
739
+ faces: np.ndarray,
740
+ sulc: np.ndarray,
741
+ ) -> _HemisphereGeometry:
742
+ coordinates = np.asarray(coordinates, dtype=float)
743
+ faces = np.asarray(faces)
744
+ sulc = np.asarray(sulc, dtype=float)
745
+ if coordinates.ndim != 2 or coordinates.shape[1] != 3:
746
+ raise ValueError(
747
+ f"{hemi} coordinates must have shape (n_vertices, 3)."
748
+ )
749
+ if not np.isfinite(coordinates).all():
750
+ raise ValueError(f"{hemi} coordinates must be finite.")
751
+ if faces.ndim != 2 or faces.shape[1] != 3:
752
+ raise ValueError(f"{hemi} faces must have shape (n_faces, 3).")
753
+ if not np.issubdtype(faces.dtype, np.integer):
754
+ raise ValueError(f"{hemi} faces must contain integer indices.")
755
+ if sulc.shape != (coordinates.shape[0],):
756
+ raise ValueError(
757
+ f"{hemi} sulc length {sulc.size} does not match surface "
758
+ f"vertex count {coordinates.shape[0]}."
759
+ )
760
+ if faces.size and (
761
+ int(faces.min()) < 0
762
+ or int(faces.max()) >= coordinates.shape[0]
763
+ ):
764
+ raise ValueError(
765
+ f"{hemi} faces contain out-of-range vertex indices."
766
+ )
767
+ return _HemisphereGeometry(
768
+ coordinates=_readonly_array(coordinates, float),
769
+ faces=_readonly_array(faces, int),
770
+ sulc=_readonly_array(_normalize_sulc(sulc), float),
771
+ )
772
+
773
+
774
+ def _load_surface_geometry(
775
+ subjects_dir: str | Path,
776
+ template: str,
777
+ surface: str,
778
+ ) -> _SurfaceGeometry:
779
+ root = str(Path(subjects_dir).expanduser().resolve())
780
+ return _load_surface_geometry_cached(root, template, surface)
781
+
782
+
783
+ @lru_cache(maxsize=16)
784
+ def _load_surface_geometry_cached(
785
+ subjects_dir: str,
786
+ template: str,
787
+ surface: str,
788
+ ) -> _SurfaceGeometry:
789
+ surf_dir = Path(subjects_dir) / template / "surf"
790
+ paths = {
791
+ "lh_surface": surf_dir / f"lh.{surface}",
792
+ "rh_surface": surf_dir / f"rh.{surface}",
793
+ "lh_sulc": surf_dir / "lh.sulc",
794
+ "rh_sulc": surf_dir / "rh.sulc",
795
+ }
796
+ missing = [path for path in paths.values() if not path.is_file()]
797
+ if missing:
798
+ raise FileNotFoundError(
799
+ "Required FreeSurfer surface file(s) missing: "
800
+ + ", ".join(str(path) for path in missing)
801
+ )
802
+ hemispheres = []
803
+ for hemi in ("lh", "rh"):
804
+ coordinates, faces = mne.read_surface(
805
+ str(paths[f"{hemi}_surface"])
806
+ )
807
+ sulc = read_morph_data(str(paths[f"{hemi}_sulc"]))
808
+ hemispheres.append(
809
+ _validate_hemisphere_geometry(
810
+ hemi,
811
+ coordinates,
812
+ faces,
813
+ sulc,
814
+ )
815
+ )
816
+ return _SurfaceGeometry(
817
+ lh=hemispheres[0],
818
+ rh=hemispheres[1],
819
+ )
820
+
821
+
822
+ def _configure_vtk_backend() -> None:
823
+ if (
824
+ sys.platform == "darwin"
825
+ and "VTK_DEFAULT_RENDER_WINDOW_TYPE" not in os.environ
826
+ ):
827
+ os.environ["VTK_DEFAULT_RENDER_WINDOW_TYPE"] = "Cocoa"
828
+
829
+
830
+ @lru_cache(maxsize=1)
831
+ def _brainspace_api() -> tuple[Any, Any, type]:
832
+ _configure_vtk_backend()
833
+ from brainspace.mesh.mesh_creation import build_polydata
834
+ from brainspace.plotting import plot_surf
835
+ from brainspace.plotting.utils import PTuple
836
+
837
+ return build_polydata, plot_surf, PTuple
838
+
839
+
840
+ @lru_cache(maxsize=1)
841
+ def _nilearn_api() -> tuple[Any, Any, Any, Any]:
842
+ """Import the optional Nilearn/Matplotlib renderer only on demand."""
843
+ try:
844
+ _import_module("nilearn")
845
+ pyplot = _import_module("matplotlib.pyplot")
846
+ figure_class = _import_module("matplotlib.figure").Figure
847
+ agg_canvas = _import_module(
848
+ "matplotlib.backends.backend_agg"
849
+ ).FigureCanvasAgg
850
+ nilearn_plotting = _import_module("nilearn.plotting")
851
+ except ImportError as error:
852
+ raise RuntimeError(
853
+ 'backend="nilearn" requires the optional Nilearn renderer. '
854
+ 'Install it with: python -m pip install "PySourceviz[nilearn]"'
855
+ ) from error
856
+ return (
857
+ pyplot,
858
+ nilearn_plotting.plot_surf_stat_map,
859
+ figure_class,
860
+ agg_canvas,
861
+ )
862
+
863
+
864
+ @lru_cache(maxsize=1)
865
+ def _cortex_cmap() -> Any:
866
+ from matplotlib import colormaps
867
+ from matplotlib.colors import LinearSegmentedColormap
868
+
869
+ soft_greys = colormaps["Greys_r"](np.linspace(0.72, 1.0, 256))
870
+ return LinearSegmentedColormap.from_list(
871
+ "pysourceviz_cortex",
872
+ soft_greys,
873
+ N=256,
874
+ )
875
+
876
+
877
+ def _build_brainspace_meshes(
878
+ geometry: _SurfaceGeometry,
879
+ activation: tuple[np.ndarray, np.ndarray],
880
+ ) -> dict[str, Any]:
881
+ build_polydata, _, _ = _brainspace_api()
882
+ meshes = {}
883
+ for index, (hemi, hemi_geometry) in enumerate(
884
+ (("lh", geometry.lh), ("rh", geometry.rh))
885
+ ):
886
+ values = np.asarray(activation[index], dtype=float)
887
+ n_vertices = hemi_geometry.coordinates.shape[0]
888
+ if values.shape != (n_vertices,):
889
+ raise ValueError(
890
+ f"{hemi} activation length {values.size} does not match "
891
+ f"surface vertex count {n_vertices}."
892
+ )
893
+ mesh = build_polydata(
894
+ hemi_geometry.coordinates,
895
+ hemi_geometry.faces,
896
+ )
897
+ mesh.append_array(
898
+ hemi_geometry.sulc,
899
+ name="sulc",
900
+ at="p",
901
+ )
902
+ mesh.append_array(
903
+ values.copy(),
904
+ name="activation",
905
+ at="p",
906
+ )
907
+ meshes[hemi] = mesh
908
+ return meshes
909
+
910
+
911
+ def _resolve_layout(
912
+ layout: str,
913
+ size: tuple[int, int] | None,
914
+ title: str | None,
915
+ ) -> _LayoutSpec:
916
+ # BrainSpace names global camera rotations, not hemisphere-relative
917
+ # anatomical views. The right hemisphere therefore needs the
918
+ # complementary rotation name to match Nilearn's lateral/medial views.
919
+ if layout == "row":
920
+ surface_layout = [["lh", "lh", "rh", "rh"]]
921
+ views = [["lateral", "medial", "lateral", "medial"]]
922
+ label_text = {
923
+ "bottom": [
924
+ "LH lateral",
925
+ "LH medial",
926
+ "RH medial",
927
+ "RH lateral",
928
+ ]
929
+ }
930
+ default_size = (1600, 420)
931
+ shape = (1, 4)
932
+ panels = (
933
+ _PanelSpec(0, 0, "left", "lateral", "LH lateral"),
934
+ _PanelSpec(0, 1, "left", "medial", "LH medial"),
935
+ _PanelSpec(0, 2, "right", "medial", "RH medial"),
936
+ _PanelSpec(0, 3, "right", "lateral", "RH lateral"),
937
+ )
938
+ else:
939
+ surface_layout = [["lh", "rh"], ["lh", "rh"]]
940
+ views = [
941
+ ["lateral", "medial"],
942
+ ["medial", "lateral"],
943
+ ]
944
+ label_text = {
945
+ "bottom": ["LH", "RH"],
946
+ "left": ["Lateral", "Medial"],
947
+ }
948
+ default_size = (900, 760)
949
+ shape = (2, 2)
950
+ panels = (
951
+ _PanelSpec(0, 0, "left", "lateral", "LH lateral"),
952
+ _PanelSpec(0, 1, "right", "lateral", "RH lateral"),
953
+ _PanelSpec(1, 0, "left", "medial", "LH medial"),
954
+ _PanelSpec(1, 1, "right", "medial", "RH medial"),
955
+ )
956
+ if title is not None:
957
+ label_text["top"] = [title]
958
+ return _LayoutSpec(
959
+ surface_layout=surface_layout,
960
+ views=views,
961
+ label_text=label_text,
962
+ size=default_size if size is None else size,
963
+ shape=shape,
964
+ panels=panels,
965
+ )
966
+
967
+
968
+ def _resolve_background(
969
+ background: str | tuple[float, float, float],
970
+ ) -> tuple[float, float, float]:
971
+ from matplotlib.colors import to_rgb
972
+
973
+ try:
974
+ resolved = to_rgb(background)
975
+ except (TypeError, ValueError) as error:
976
+ raise ValueError(
977
+ "background must be a valid color name or RGB triple."
978
+ ) from error
979
+ if len(resolved) != 3 or not all(
980
+ math.isfinite(float(value)) for value in resolved
981
+ ):
982
+ raise ValueError(
983
+ "background must resolve to three finite RGB values."
984
+ )
985
+ return tuple(float(value) for value in resolved)
986
+
987
+
988
+ def _enforce_overlay_ranges(
989
+ plotter: Any,
990
+ color_range: tuple[float, float],
991
+ ) -> None:
992
+ view_count = 0
993
+ try:
994
+ renderer_groups = plotter.renderers.values()
995
+ except AttributeError as error:
996
+ raise RuntimeError(
997
+ "BrainSpace plotter does not expose renderer actors."
998
+ ) from error
999
+ for renderers in renderer_groups:
1000
+ for renderer in renderers:
1001
+ collection = renderer.actors.VTKObject
1002
+ actor_count = int(collection.GetNumberOfItems())
1003
+ if actor_count == 0:
1004
+ continue
1005
+ if actor_count != 2:
1006
+ raise RuntimeError(
1007
+ "BrainSpace overlay compatibility requires exactly "
1008
+ "two actors in each brain view."
1009
+ )
1010
+ collection.InitTraversal()
1011
+ sulc_actor = collection.GetNextActor()
1012
+ activation_actor = collection.GetNextActor()
1013
+ sulc_lut = sulc_actor.GetMapper().GetLookupTable()
1014
+ activation_lut = (
1015
+ activation_actor.GetMapper().GetLookupTable()
1016
+ )
1017
+ sulc_lut.SetRange(-1.0, 1.0)
1018
+ activation_lut.SetRange(*color_range)
1019
+ view_count += 1
1020
+ if view_count != 4:
1021
+ raise RuntimeError(
1022
+ "BrainSpace overlay compatibility expected four brain-view "
1023
+ f"actor groups; found {view_count}."
1024
+ )
1025
+
1026
+
1027
+ def _build_brainspace_plotter(
1028
+ meshes: dict[str, Any],
1029
+ layout: _LayoutSpec,
1030
+ prepared: _PreparedSource,
1031
+ *,
1032
+ background: tuple[float, float, float],
1033
+ colorbar: bool,
1034
+ units: str | None,
1035
+ offscreen: bool,
1036
+ ) -> Any:
1037
+ _, plot_surf, PTuple = _brainspace_api()
1038
+ control_points = getattr(prepared, "color_control_points", None)
1039
+ render_cmap = getattr(prepared, "render_cmap", prepared.cmap)
1040
+ activation_opacity = 1.0 if control_points is not None else 0.72
1041
+ text_property = {
1042
+ "fontSize": 14,
1043
+ "bold": False,
1044
+ "color": (0.0, 0.0, 0.0),
1045
+ }
1046
+ kwargs = {
1047
+ "array_name": PTuple("sulc", "activation"),
1048
+ "view": layout.views,
1049
+ "color_bar": "right" if colorbar else None,
1050
+ "color_range": PTuple((-1.0, 1.0), prepared.color_range),
1051
+ "share": "both",
1052
+ "label_text": layout.label_text,
1053
+ "cmap": PTuple(_cortex_cmap(), render_cmap),
1054
+ "nan_color": (0.0, 0.0, 0.0, 0.0),
1055
+ "background": background,
1056
+ "size": layout.size,
1057
+ "return_plotter": True,
1058
+ "suppress_warnings": True,
1059
+ "offscreen": offscreen,
1060
+ "actor__opacity": PTuple(1.0, activation_opacity),
1061
+ "actor__forceOpaque": PTuple(True, False),
1062
+ "actor__ambient": PTuple(0.3, 0.12),
1063
+ "actor__specular": PTuple(0.08, 0.02),
1064
+ "actor__diffuse": PTuple(0.7, 0.92),
1065
+ "text__textScaleMode": "none",
1066
+ "text__textProperty": text_property,
1067
+ "cb__labelTextProperty": text_property,
1068
+ "cb__titleTextProperty": text_property,
1069
+ }
1070
+ if colorbar and units:
1071
+ kwargs["cb__title"] = units
1072
+ plotter = plot_surf(meshes, layout.surface_layout, **kwargs)
1073
+ _enforce_overlay_ranges(plotter, prepared.color_range)
1074
+ return plotter
1075
+
1076
+
1077
+ def _resolve_png_output(
1078
+ output: str | Path | None,
1079
+ ) -> Path | None:
1080
+ if output is None:
1081
+ return None
1082
+ path = Path(output)
1083
+ if path.suffix != ".png":
1084
+ raise ValueError("output must use the .png extension.")
1085
+ return path
1086
+
1087
+
1088
+ def _validate_render_options(
1089
+ *,
1090
+ title: str | None,
1091
+ units: str | None,
1092
+ colorbar: bool,
1093
+ transparent: bool,
1094
+ ) -> None:
1095
+ for name, value in (("title", title), ("units", units)):
1096
+ if value is not None and not isinstance(value, str):
1097
+ raise TypeError(f"{name} must be a string or None.")
1098
+ if not isinstance(colorbar, bool):
1099
+ raise TypeError("colorbar must be a bool.")
1100
+ if not isinstance(transparent, bool):
1101
+ raise TypeError("transparent must be a bool.")
1102
+
1103
+
1104
+ def _render_brainspace(
1105
+ meshes: dict[str, Any],
1106
+ layout: _LayoutSpec,
1107
+ prepared: _PreparedSource,
1108
+ *,
1109
+ background: tuple[float, float, float],
1110
+ colorbar: bool,
1111
+ units: str | None,
1112
+ output: Path | None,
1113
+ scale: tuple[int, int],
1114
+ transparent: bool,
1115
+ show: bool,
1116
+ ) -> Any | None:
1117
+ if output is not None:
1118
+ output.parent.mkdir(parents=True, exist_ok=True)
1119
+ static_plotter = _build_brainspace_plotter(
1120
+ meshes,
1121
+ layout,
1122
+ prepared,
1123
+ background=background,
1124
+ colorbar=colorbar,
1125
+ units=units,
1126
+ offscreen=True,
1127
+ )
1128
+ try:
1129
+ static_plotter.screenshot(
1130
+ str(output),
1131
+ transparent_bg=transparent,
1132
+ scale=scale,
1133
+ )
1134
+ finally:
1135
+ static_plotter.close()
1136
+ if show:
1137
+ interactive_plotter = _build_brainspace_plotter(
1138
+ meshes,
1139
+ layout,
1140
+ prepared,
1141
+ background=background,
1142
+ colorbar=colorbar,
1143
+ units=units,
1144
+ offscreen=False,
1145
+ )
1146
+ interactive_plotter.show(interactive=True)
1147
+ return interactive_plotter
1148
+ if output is None:
1149
+ return _build_brainspace_plotter(
1150
+ meshes,
1151
+ layout,
1152
+ prepared,
1153
+ background=background,
1154
+ colorbar=colorbar,
1155
+ units=units,
1156
+ offscreen=True,
1157
+ )
1158
+ return None
1159
+
1160
+
1161
+ def _nilearn_sulc_map(sulc: np.ndarray) -> np.ndarray:
1162
+ """Map normalized sulcal depth to a textured white/light-gray cortex."""
1163
+ values = np.asarray(sulc, dtype=float)
1164
+ normalized = (np.clip(values, -1.0, 1.0) + 1.0) / 2.0
1165
+ lower, upper = _NILEARN_SULC_RANGE
1166
+ return lower + normalized * (upper - lower)
1167
+
1168
+
1169
+ def _render_nilearn(
1170
+ geometry: _SurfaceGeometry,
1171
+ layout: _LayoutSpec,
1172
+ prepared: _PreparedSource,
1173
+ *,
1174
+ background: tuple[float, float, float],
1175
+ colorbar: bool,
1176
+ units: str | None,
1177
+ output: Path | None,
1178
+ scale: tuple[int, int],
1179
+ transparent: bool,
1180
+ show: bool,
1181
+ ) -> Any | None:
1182
+ """Render prepared vertex data with Nilearn's Matplotlib surface API."""
1183
+ pyplot, plot_surf_stat_map, figure_class, agg_canvas = _nilearn_api()
1184
+ logical_inches = (
1185
+ layout.size[0] / 100.0,
1186
+ layout.size[1] / 100.0,
1187
+ )
1188
+ figure_kwargs = {
1189
+ "figsize": logical_inches,
1190
+ "dpi": 100,
1191
+ "facecolor": background,
1192
+ }
1193
+ if show:
1194
+ figure = pyplot.figure(**figure_kwargs)
1195
+ else:
1196
+ figure = figure_class(**figure_kwargs)
1197
+ agg_canvas(figure)
1198
+ keep_open = False
1199
+ try:
1200
+ figure.patch.set_facecolor(background)
1201
+ title = layout.label_text.get("top", [None])[0]
1202
+ if title is not None:
1203
+ figure.suptitle(title, fontsize=14, y=0.98)
1204
+
1205
+ hemispheres = {
1206
+ "left": (
1207
+ geometry.lh,
1208
+ np.asarray(prepared.activation[0], dtype=float),
1209
+ ),
1210
+ "right": (
1211
+ geometry.rh,
1212
+ np.asarray(prepared.activation[1], dtype=float),
1213
+ ),
1214
+ }
1215
+ try:
1216
+ supports_darkness = "darkness" in inspect.signature(
1217
+ plot_surf_stat_map
1218
+ ).parameters
1219
+ except (TypeError, ValueError):
1220
+ supports_darkness = False
1221
+ control_points = prepared.color_control_points
1222
+ render_cmap = getattr(prepared, "render_cmap", prepared.cmap)
1223
+ rows, columns = layout.shape
1224
+ axes = []
1225
+ for panel in layout.panels:
1226
+ hemi_geometry, activation = hemispheres[panel.hemi]
1227
+ expected = (hemi_geometry.coordinates.shape[0],)
1228
+ if activation.shape != expected:
1229
+ raise ValueError(
1230
+ f"{panel.hemi} activation shape {activation.shape} does "
1231
+ f"not match surface vertex shape {expected}."
1232
+ )
1233
+ index = panel.row * columns + panel.column + 1
1234
+ axis = figure.add_subplot(
1235
+ rows,
1236
+ columns,
1237
+ index,
1238
+ projection="3d",
1239
+ )
1240
+ axis.set_facecolor(background)
1241
+ axes.append(axis)
1242
+ plot_kwargs = {
1243
+ "surf_mesh": [
1244
+ hemi_geometry.coordinates,
1245
+ hemi_geometry.faces,
1246
+ ],
1247
+ "stat_map": activation,
1248
+ "bg_map": _nilearn_sulc_map(hemi_geometry.sulc),
1249
+ "hemi": panel.hemi,
1250
+ "view": panel.view,
1251
+ "engine": "matplotlib",
1252
+ "cmap": render_cmap,
1253
+ "colorbar": False,
1254
+ "avg_method": "mean",
1255
+ "threshold": None,
1256
+ "alpha": 1.0,
1257
+ "bg_on_data": control_points is None,
1258
+ "vmin": prepared.color_range[0],
1259
+ "vmax": prepared.color_range[1],
1260
+ "symmetric_cbar": prepared.mode == "signed",
1261
+ "cbar_tick_format": "%.2g",
1262
+ "title": panel.label,
1263
+ "axes": axis,
1264
+ "figure": figure,
1265
+ }
1266
+ if supports_darkness:
1267
+ plot_kwargs["darkness"] = None
1268
+ plot_surf_stat_map(**plot_kwargs)
1269
+
1270
+ if rows == 1:
1271
+ figure.subplots_adjust(
1272
+ left=0.0,
1273
+ right=0.94 if colorbar else 0.99,
1274
+ bottom=0.0,
1275
+ top=0.88 if title else 0.98,
1276
+ wspace=-0.08,
1277
+ )
1278
+ else:
1279
+ figure.subplots_adjust(
1280
+ left=0.0,
1281
+ right=0.92 if colorbar else 0.98,
1282
+ bottom=0.0,
1283
+ top=0.91 if title else 0.98,
1284
+ wspace=-0.12,
1285
+ hspace=-0.16,
1286
+ )
1287
+
1288
+ if colorbar:
1289
+ normalizer = pyplot.Normalize(
1290
+ vmin=prepared.color_range[0],
1291
+ vmax=prepared.color_range[1],
1292
+ )
1293
+ mappable = pyplot.cm.ScalarMappable(
1294
+ norm=normalizer,
1295
+ cmap=render_cmap,
1296
+ )
1297
+ mappable.set_array([])
1298
+ colorbar_kwargs: dict[str, Any] = {
1299
+ "ax": axes,
1300
+ "fraction": 0.018,
1301
+ "pad": 0.01,
1302
+ "shrink": 0.65,
1303
+ "aspect": 18,
1304
+ }
1305
+ if control_points is not None:
1306
+ fmin, fmid, fmax = control_points
1307
+ if prepared.mode == "signed":
1308
+ colorbar_kwargs["ticks"] = (
1309
+ -fmax,
1310
+ -fmid,
1311
+ -fmin,
1312
+ 0.0,
1313
+ fmin,
1314
+ fmid,
1315
+ fmax,
1316
+ )
1317
+ else:
1318
+ colorbar_kwargs["ticks"] = control_points
1319
+ shared_colorbar = figure.colorbar(mappable, **colorbar_kwargs)
1320
+ if units:
1321
+ shared_colorbar.set_label(units)
1322
+
1323
+ if output is not None:
1324
+ output.parent.mkdir(parents=True, exist_ok=True)
1325
+ original_size = tuple(
1326
+ float(value) for value in figure.get_size_inches()
1327
+ )
1328
+ raster_scale = min(scale)
1329
+ output_dpi = 100 * raster_scale
1330
+ figure.set_size_inches(
1331
+ layout.size[0] * scale[0] / output_dpi,
1332
+ layout.size[1] * scale[1] / output_dpi,
1333
+ forward=False,
1334
+ )
1335
+ try:
1336
+ figure.savefig(
1337
+ output,
1338
+ dpi=output_dpi,
1339
+ facecolor=background,
1340
+ transparent=transparent,
1341
+ )
1342
+ finally:
1343
+ figure.set_size_inches(
1344
+ original_size[0],
1345
+ original_size[1],
1346
+ forward=False,
1347
+ )
1348
+ if show:
1349
+ pyplot.show()
1350
+ keep_open = True
1351
+ return figure
1352
+ if output is None:
1353
+ keep_open = True
1354
+ return figure
1355
+ return None
1356
+ finally:
1357
+ if not keep_open:
1358
+ pyplot.close(figure)
1359
+
1360
+
1361
+ def _prepare_source_data(
1362
+ stc: Any,
1363
+ *,
1364
+ subjects_dir: str | Path,
1365
+ template: str,
1366
+ smoothing_steps: int | Literal["nearest"] | None = None,
1367
+ time: float | str | None,
1368
+ time_window: tuple[float, float] | None,
1369
+ reduce: str,
1370
+ mode: str,
1371
+ threshold: float | str | None,
1372
+ clim: str | tuple[float, float] | dict[str, Any],
1373
+ robust_percentile: float,
1374
+ cmap: str | None,
1375
+ ) -> _PreparedSource:
1376
+ resolved_smoothing_steps = _validate_smoothing_steps(smoothing_steps)
1377
+ normalized = _normalize_surface_stc(stc, mode)
1378
+ summary = _summarize_time(
1379
+ normalized,
1380
+ time,
1381
+ time_window,
1382
+ reduce,
1383
+ )
1384
+ sparse_values = np.asarray(
1385
+ summary.stc.data[:, 0],
1386
+ dtype=float,
1387
+ ).copy()
1388
+ parsed_threshold = _parse_threshold(threshold, sparse_values)
1389
+ color_scale = _resolve_color_scale(
1390
+ clim,
1391
+ robust_percentile,
1392
+ mode,
1393
+ parsed_threshold,
1394
+ sparse_values,
1395
+ )
1396
+ full_stc = _expand_to_full_surface(
1397
+ summary.stc,
1398
+ template,
1399
+ subjects_dir,
1400
+ resolved_smoothing_steps,
1401
+ )
1402
+ cortex_mask = _load_cortex_mask(
1403
+ subjects_dir,
1404
+ template,
1405
+ full_stc.vertices,
1406
+ )
1407
+ activation = _mask_dense_activation(
1408
+ full_stc,
1409
+ parsed_threshold,
1410
+ cortex_mask,
1411
+ )
1412
+ cmap_name = _resolve_cmap(cmap, mode)
1413
+ return _PreparedSource(
1414
+ activation=activation,
1415
+ sparse_values=sparse_values,
1416
+ template=template,
1417
+ smoothing_steps=resolved_smoothing_steps,
1418
+ selection=summary.selection,
1419
+ selected_time=summary.selected_time,
1420
+ time_window=summary.requested_window,
1421
+ sampled_time_window=summary.sampled_window,
1422
+ reduce=summary.reduce,
1423
+ mode=mode,
1424
+ threshold=parsed_threshold,
1425
+ color_range=color_scale.color_range,
1426
+ color_control_points=color_scale.control_points,
1427
+ cmap=cmap_name,
1428
+ render_cmap=_build_render_cmap(
1429
+ cmap_name,
1430
+ mode,
1431
+ color_scale.color_range,
1432
+ color_scale.control_points,
1433
+ ),
1434
+ )
1435
+
1436
+
1437
+ def _resolve_template(stc: Any, template: str) -> str:
1438
+ if not isinstance(template, str):
1439
+ raise TypeError("template must be a non-empty string.")
1440
+ if not template.strip():
1441
+ raise ValueError("template must not be empty.")
1442
+ subject = stc.subject
1443
+ if template == "native":
1444
+ if not isinstance(subject, str) or not subject.strip():
1445
+ raise ValueError(
1446
+ "template='native' requires stc.subject to identify the "
1447
+ "FreeSurfer subject."
1448
+ )
1449
+ return subject
1450
+ if subject != template:
1451
+ current = subject if subject else "<unknown>"
1452
+ raise ValueError(
1453
+ f"STC subject space {current!r} does not match requested template "
1454
+ f"{template!r}. Morph the STC externally with "
1455
+ "mne.compute_source_morph before calling plot_source()."
1456
+ )
1457
+ return template
1458
+
1459
+
1460
+ def plot_source(
1461
+ stc: Any,
1462
+ *,
1463
+ subjects_dir: str | Path,
1464
+ backend: Literal["brainspace", "nilearn"] = "brainspace",
1465
+ template: str = "native",
1466
+ surface: Literal["inflated", "pial", "white"] = "inflated",
1467
+ smoothing_steps: int | Literal["nearest"] | None = None,
1468
+ time: float | Literal["peak"] | None = None,
1469
+ time_window: tuple[float, float] | None = None,
1470
+ reduce: Literal["mean", "rms"] = "mean",
1471
+ mode: Literal["magnitude", "signed"] = "magnitude",
1472
+ threshold: float | str | None = None,
1473
+ clim: (
1474
+ Literal["robust"]
1475
+ | tuple[float, float]
1476
+ | dict[str, Any]
1477
+ ) = "robust",
1478
+ robust_percentile: float = 98.0,
1479
+ cmap: str | None = None,
1480
+ units: str | None = None,
1481
+ layout: Literal["row", "grid"] = "row",
1482
+ title: str | None = None,
1483
+ colorbar: bool = True,
1484
+ background: str | tuple[float, float, float] = "white",
1485
+ output: str | Path | None = None,
1486
+ size: tuple[int, int] | None = None,
1487
+ scale: tuple[int, int] = (2, 2),
1488
+ transparent: bool = False,
1489
+ show: bool | None = None,
1490
+ ) -> dict[str, Any]:
1491
+ """Plot a cortical MNE source estimate with BrainSpace or Nilearn.
1492
+
1493
+ Numerical processing converts a supported real MNE source estimate into one
1494
+ full-surface activation map without modifying the input. FreeSurfer anatomy
1495
+ supplies the requested mesh, sulcal background, and medial wall mask. The
1496
+ four views use one shared whole-brain activation range and colorbar.
1497
+
1498
+ Parameters
1499
+ ----------
1500
+ stc
1501
+ A real MNE surface, vector-surface, mixed, or mixed-vector source
1502
+ estimate. Mixed inputs contribute only their cortical surface part;
1503
+ vector inputs are displayed by vector magnitude.
1504
+ subjects_dir
1505
+ Directory containing the resolved FreeSurfer subject. It must provide
1506
+ ``surf/lh.*``, ``surf/rh.*``, and ``surf/lh.sulc``/``rh.sulc``. Cortex
1507
+ labels are used to hide the medial wall when available.
1508
+ backend
1509
+ ``"brainspace"`` (default) or the optional ``"nilearn"`` renderer.
1510
+ Both receive the same prepared surface geometry, vertex values,
1511
+ threshold, color controls, and medial-wall mask.
1512
+ template
1513
+ ``"native"`` to use ``stc.subject``, or the name of the matching
1514
+ FreeSurfer subject. If anatomical spaces differ, morph the STC externally
1515
+ with :func:`mne.compute_source_morph` before plotting.
1516
+ surface
1517
+ FreeSurfer surface: ``"inflated"``, ``"pial"``, or ``"white"``.
1518
+ smoothing_steps
1519
+ Display interpolation used while expanding sparse source vertices to
1520
+ the full cortical mesh. ``None`` lets MNE choose enough adjacency
1521
+ iterations to cover the surface, ``"nearest"`` preserves the legacy
1522
+ nearest-source assignment, and a non-negative integer requests an
1523
+ exact number of iterations. This does not alter the pre-morph sparse
1524
+ values used for thresholds and color limits.
1525
+ time
1526
+ Time in seconds, ``"peak"``, or ``None``. A numeric request uses the
1527
+ nearest sample and the returned ``selected_time`` records the actual
1528
+ sample. A multi-time STC requires ``time`` or ``time_window``.
1529
+ time_window
1530
+ Inclusive ``(start, stop)`` interval in seconds. Mutually exclusive
1531
+ with ``time``; returned metadata records both requested and sampled
1532
+ bounds.
1533
+ reduce
1534
+ Whole-window reduction: ``"mean"`` or non-negative ``"rms"``.
1535
+ mode
1536
+ ``"magnitude"`` takes scalar absolute values or vector magnitude.
1537
+ ``"signed"`` preserves real scalar signs and is incompatible with
1538
+ vector inputs and RMS reduction.
1539
+ threshold
1540
+ Non-negative numeric display threshold, percentage string such as
1541
+ ``"95%"``, or ``None``. Hidden values become transparent. This is a
1542
+ visualization control, not a test of statistical significance.
1543
+ clim
1544
+ ``"robust"``, an explicit ``(lower, upper)`` tuple, or an MNE-shaped
1545
+ dictionary such as ``{"kind": "percent", "lims": [99, 99.9,
1546
+ 99.99]}``. The three dictionary controls set the lower, middle, and
1547
+ upper color/opacity points. Signed mode uses ``pos_lims``. Robust
1548
+ limits, percentage controls, and percentage thresholds use combined
1549
+ pre-morph sparse values from both hemispheres, so dense display
1550
+ expansion cannot bias them. These are display controls, not tests of
1551
+ statistical significance.
1552
+ robust_percentile
1553
+ Percentile used as the robust upper magnitude or symmetric signed
1554
+ limit. Must be in ``(0, 100]``.
1555
+ cmap
1556
+ Matplotlib colormap name. Defaults to ``"Reds"`` for magnitude and
1557
+ ``"RdBu_r"`` for signed data.
1558
+ units
1559
+ Optional activation colorbar title.
1560
+ layout
1561
+ ``"row"`` orders LH lateral, LH medial, RH medial, RH lateral;
1562
+ ``"grid"`` places lateral views above medial views.
1563
+ title
1564
+ Optional figure title.
1565
+ colorbar
1566
+ Whether to draw the single shared activation colorbar.
1567
+ background
1568
+ Matplotlib color name or RGB tuple for the render background.
1569
+ output
1570
+ Optional ``.png`` screenshot path. Missing parent directories are
1571
+ created.
1572
+ size
1573
+ Logical figure canvas ``(width, height)``. Layout-specific defaults
1574
+ are used when omitted.
1575
+ scale
1576
+ Positive integer PNG screenshot scale for each pixel axis.
1577
+ transparent
1578
+ Whether a saved PNG has a transparent background. Control-point data
1579
+ transparency is encoded separately in the activation colormap.
1580
+ show
1581
+ Whether to display an interactive plotter. Defaults to ``False`` when
1582
+ ``output`` is set and ``True`` otherwise.
1583
+
1584
+ Returns
1585
+ -------
1586
+ dict
1587
+ Resolved source-selection, display, and output metadata. Static-only
1588
+ PNG output returns ``plotter=None``; interactive or explicitly retained
1589
+ scenes return the BrainSpace plotter or Nilearn Matplotlib figure.
1590
+ ``selected_time`` is always an actual STC sample rather than the
1591
+ numeric request.
1592
+
1593
+ Raises
1594
+ ------
1595
+ TypeError
1596
+ If an argument or source-estimate family has an unsupported type.
1597
+ ValueError
1598
+ If an argument value is invalid or incompatible with another option.
1599
+ FileNotFoundError
1600
+ If a required FreeSurfer surface or sulc file is missing.
1601
+ RuntimeError
1602
+ If MNE cannot expand sparse vertices on the matching subject surface or
1603
+ the selected renderer is unavailable or cannot create the scene.
1604
+
1605
+ Notes
1606
+ -----
1607
+ Sparse-to-dense expansion uses an MNE same-subject morph as display
1608
+ interpolation only. The default automatically fills the cortical mesh;
1609
+ it does not apply Gaussian smoothing or create additional independent
1610
+ measurements. Cross-subject registration and statistical inference remain
1611
+ the caller's responsibility.
1612
+ """
1613
+ _validate_phase_one_options(
1614
+ subjects_dir=subjects_dir,
1615
+ backend=backend,
1616
+ surface=surface,
1617
+ time=time,
1618
+ time_window=time_window,
1619
+ reduce=reduce,
1620
+ mode=mode,
1621
+ robust_percentile=robust_percentile,
1622
+ layout=layout,
1623
+ output=output,
1624
+ size=size,
1625
+ scale=scale,
1626
+ show=show,
1627
+ )
1628
+ _validate_render_options(
1629
+ title=title,
1630
+ units=units,
1631
+ colorbar=colorbar,
1632
+ transparent=transparent,
1633
+ )
1634
+ resolved_smoothing_steps = _validate_smoothing_steps(smoothing_steps)
1635
+ stc_kind = _classify_stc(stc)
1636
+ if mode == "signed" and stc_kind in ("vector", "mixed-vector"):
1637
+ raise ValueError("mode='signed' is not available for vector STCs.")
1638
+ if mode == "signed" and reduce == "rms":
1639
+ raise ValueError("mode='signed' cannot be combined with reduce='rms'.")
1640
+ resolved_template = _resolve_template(stc, template)
1641
+ output_path, resolved_show = _resolve_output_and_show(output, show)
1642
+ output_path = _resolve_png_output(output_path)
1643
+ resolved_background = _resolve_background(background)
1644
+ resolved_size = (
1645
+ None if size is None else _validate_positive_int_pair("size", size)
1646
+ )
1647
+ resolved_scale = _validate_positive_int_pair("scale", scale)
1648
+ layout_spec = _resolve_layout(layout, resolved_size, title)
1649
+ geometry = _load_surface_geometry(
1650
+ subjects_dir,
1651
+ resolved_template,
1652
+ surface,
1653
+ )
1654
+ prepared = _prepare_source_data(
1655
+ stc,
1656
+ subjects_dir=subjects_dir,
1657
+ template=resolved_template,
1658
+ smoothing_steps=resolved_smoothing_steps,
1659
+ time=time,
1660
+ time_window=time_window,
1661
+ reduce=reduce,
1662
+ mode=mode,
1663
+ threshold=threshold,
1664
+ clim=clim,
1665
+ robust_percentile=robust_percentile,
1666
+ cmap=cmap,
1667
+ )
1668
+ render_kwargs = {
1669
+ "background": resolved_background,
1670
+ "colorbar": colorbar,
1671
+ "units": units,
1672
+ "output": output_path,
1673
+ "scale": resolved_scale,
1674
+ "transparent": transparent,
1675
+ "show": resolved_show,
1676
+ }
1677
+ if backend == "brainspace":
1678
+ meshes = _build_brainspace_meshes(geometry, prepared.activation)
1679
+ plotter = _render_brainspace(
1680
+ meshes,
1681
+ layout_spec,
1682
+ prepared,
1683
+ **render_kwargs,
1684
+ )
1685
+ else:
1686
+ plotter = _render_nilearn(
1687
+ geometry,
1688
+ layout_spec,
1689
+ prepared,
1690
+ **render_kwargs,
1691
+ )
1692
+ return {
1693
+ "backend": backend,
1694
+ "template": prepared.template,
1695
+ "surface": surface,
1696
+ "smoothing_steps": prepared.smoothing_steps,
1697
+ "selection": prepared.selection,
1698
+ "selected_time": prepared.selected_time,
1699
+ "time_window": prepared.time_window,
1700
+ "sampled_time_window": prepared.sampled_time_window,
1701
+ "reduce": prepared.reduce,
1702
+ "mode": prepared.mode,
1703
+ "threshold": prepared.threshold,
1704
+ "color_range": prepared.color_range,
1705
+ "color_control_points": prepared.color_control_points,
1706
+ "cmap": prepared.cmap,
1707
+ "layout": layout,
1708
+ "size": layout_spec.size,
1709
+ "scale": resolved_scale,
1710
+ "background": resolved_background,
1711
+ "transparent": transparent,
1712
+ "show": resolved_show,
1713
+ "colorbar": colorbar,
1714
+ "title": title,
1715
+ "units": units,
1716
+ "output": output_path,
1717
+ "plotter": plotter,
1718
+ }