Source code for mne_rt.viz.brain_plot

"""Real-time 3D brain activation display.

Built on PyVista / pyvistaqt with a Qt control panel, hemisphere toggles,
surface switching, view presets, and screenshot support.

Classes
-------
BrainPlot
    Interactive real-time 3D brain surface with NF activity overlay.
"""

from __future__ import annotations

import datetime
from pathlib import Path
from typing import Optional, Union

import numpy as np

try:
    import pyvista as pv

    _pyvista_available = True
except ImportError:
    pv = None  # type: ignore[assignment]
    _pyvista_available = False

try:
    from pyvistaqt import BackgroundPlotter as _BackgroundPlotter

    _pyvistaqt_available = True
except ImportError:
    _BackgroundPlotter = None  # type: ignore[assignment]
    _pyvistaqt_available = False

from mne_rt._logging import logger
from mne_rt.tools import setup_surface
from mne_rt.viz._smoothing import check_smoothing, ema_step

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_CMAPS = ["hot", "plasma", "viridis", "Reds", "YlOrRd", "RdBu_r"]

_SURFACES = ["inflated", "pial", "white", "sphere"]

# Display modes: label shown in the scalar bar title
_DISPLAY_MODES = [
    "Source Activation",
    "Alpha Power  (8–13 Hz)",
    "Beta Power   (13–30 Hz)",
    "Theta Power  (4–8 Hz)",
    "Gamma Power  (30–80 Hz)",
    "SMR Power    (12–15 Hz)",
]

# The band each display mode shows; None is the broadband activation.
# :attr:`BrainPlot.band` hands it to the computation in record_main.
_MODE_BANDS: dict[str, Optional[tuple[float, float]]] = {
    "Source Activation": None,
    "Alpha Power  (8–13 Hz)": (8.0, 13.0),
    "Beta Power   (13–30 Hz)": (13.0, 30.0),
    "Theta Power  (4–8 Hz)": (4.0, 8.0),
    "Gamma Power  (30–80 Hz)": (30.0, 80.0),
    "SMR Power    (12–15 Hz)": (12.0, 15.0),
}

# Sulcal depth is drawn in grey under the activity, light on gyri.
_SULC_CLIM = (-1.0, 1.5)
_SULC_GREYS = (0.30, 0.82)  # grey levels of sulci and gyri

_VIEW_PRESETS: dict[str, dict] = {
    "lateral_lh": {"position": (-450, 0, 0), "focal": (0, 0, 0), "up": (0, 0, 1)},
    "lateral_rh": {"position": (450, 0, 0), "focal": (0, 0, 0), "up": (0, 0, 1)},
    "dorsal": {"position": (0, 0, 450), "focal": (0, 0, 0), "up": (0, 1, 0)},
    "frontal": {"position": (0, -450, 0), "focal": (0, 0, 0), "up": (0, 0, 1)},
    "ventral": {"position": (0, 0, -450), "focal": (0, 0, 0), "up": (0, 1, 0)},
}

# Dark stylesheet for the Qt control panel — matches the dark UI of other plots
_PANEL_DARK_STYLE = """
    QWidget {
        background: #161b22;
        color: #e6edf3;
        font-family: -apple-system, 'Segoe UI', sans-serif;
        font-size: 11px;
    }
    QGroupBox {
        border: 1px solid #30363d;
        border-radius: 6px;
        margin-top: 10px;
        padding-top: 8px;
        color: #8b949e;
        font-weight: 600;
        font-size: 10px;
    }
    QGroupBox::title {
        subcontrol-origin: margin;
        left: 8px;
        padding: 0 4px;
        color: #8b949e;
    }
    QLabel { color: #c9d1d9; background: transparent; }
    QComboBox {
        background: #21262d;
        border: 1px solid #30363d;
        border-radius: 5px;
        padding: 4px 8px;
        color: #e6edf3;
    }
    QComboBox::drop-down { border: none; width: 18px; }
    QComboBox QAbstractItemView {
        background: #161b22;
        border: 1px solid #30363d;
        color: #e6edf3;
        selection-background-color: #1f6feb;
    }
    QSlider::groove:horizontal {
        height: 3px;
        background: #30363d;
        border-radius: 2px;
    }
    QSlider::handle:horizontal {
        background: #58a6ff;
        width: 12px;
        height: 12px;
        margin: -5px 0;
        border-radius: 6px;
    }
    QSlider::sub-page:horizontal {
        background: #388bfd;
        border-radius: 2px;
    }
    QPushButton {
        background: #21262d;
        border: 1px solid #30363d;
        border-radius: 5px;
        padding: 5px 8px;
        color: #c9d1d9;
        font-weight: 500;
    }
    QPushButton:hover { background: #30363d; border-color: #8b949e; color: #e6edf3; }
    QPushButton:pressed { background: #1f6feb; border-color: #388bfd; color: #ffffff; }
    QCheckBox { spacing: 6px; color: #c9d1d9; }
    QCheckBox::indicator {
        width: 13px; height: 13px;
        border-radius: 3px;
        border: 1px solid #30363d;
        background: #21262d;
    }
    QCheckBox::indicator:checked { background: #238636; border-color: #2ea043; }
    QDoubleSpinBox {
        background: #21262d;
        border: 1px solid #30363d;
        border-radius: 5px;
        padding: 3px 6px;
        color: #e6edf3;
    }
    QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {
        background: #30363d; border: none; width: 14px;
    }
    QDockWidget { color: #e6edf3; background: #0d1117; }
    QDockWidget::title {
        background: #161b22;
        border-bottom: 1px solid #30363d;
        padding: 6px 8px;
        color: #e6edf3;
        font-weight: 700;
    }
    QScrollBar:vertical { background: #0d1117; width: 8px; border-radius: 4px; }
    QScrollBar::handle:vertical {
        background: #30363d; border-radius: 4px; min-height: 20px;
    }
"""

# Background colour presets  (bottom_hex, top_hex)
_BACKGROUNDS: dict[str, tuple[str, str]] = {
    "Deep space": ("#040810", "#0b1628"),
    "Midnight": ("#050d1a", "#0d1b35"),
    "Slate": ("#0d1117", "#1c2333"),
    "Charcoal": ("#111111", "#1e1e1e"),
    "Black": ("#000000", "#050505"),
    "Light": ("#e8eaf0", "#ffffff"),  # publication / presentations
}

_DEFAULT_BG = "Midnight"


#: Vertices per hemisphere of the ico-5 cortex the display draws.
N_ICO5 = 10242


def to_display(values: np.ndarray, vertices: list[np.ndarray]) -> tuple[np.ndarray, np.ndarray]:
    """One value per source, as ``(lh, rh)`` arrays over the display's vertices.

    ``vertices`` are the source estimate's vertex numbers per hemisphere; a
    display vertex without a source is NaN, drawn without activity.
    """
    n_lh = len(vertices[0])
    out = []
    for vertno, part in zip(vertices, (values[:n_lh], values[n_lh:])):
        full = np.full(N_ICO5, np.nan)
        full[vertno] = part
        out.append(full)
    return out[0], out[1]


def _lut(cmap: str) -> np.ndarray:
    """256 RGB rows of a matplotlib colormap, in [0, 1]."""
    import matplotlib

    return matplotlib.colormaps[cmap](np.linspace(0.0, 1.0, 256))[:, :3]


[docs] class BrainPlot: """Interactive real-time 3D brain activation display. Renders bilateral ``fsaverage`` cortical surfaces with a colour-mapped activity overlay and a Qt control panel docked on the right. Designed to run alongside :class:`~mne_rt.viz.NFPlot` inside a shared Qt event loop — call :meth:`update_from_arrays` or :meth:`update` from the acquisition thread's pump timer. Parameters ---------- subjects_fs_dir : str | Path FreeSurfer subjects directory. The MNE-bundled ``fsaverage`` template is used automatically (auto-downloaded on first use); this path is accepted for API compatibility. clim : tuple of float, default (0.0, 0.6) Initial ``(min, max)`` colour-map range for the activity overlay. hemi_distance : float, default 20.0 Gap in mm between the medial walls of the two hemispheres. surf : {"inflated", "pial", "white", "sphere"}, default "inflated" Initial cortical surface geometry to display. cmap : str, default "hot" Initial colour map name. Must be one of ``["hot", "plasma", "viridis", "Reds", "YlOrRd", "RdBu_r"]``. opacity : float, default 0.6 Initial opacity of the activity overlay (0 = transparent, 1 = opaque). window_size : tuple of int, default (1600, 1000) Width × height of the render window in pixels. display_smoothing : float, default 0.3 EMA factor applied to the per-vertex activation arrays before each render, in ``(0, 1]``. ``1.0`` disables smoothing; lower values blend consecutive frames so the cortical map transitions smoothly instead of jumping between analysis windows. verbose : bool | str | None, default None Verbosity level. See Also -------- mne_rt.viz.NFPlot : Scrolling real-time NF signal plot. mne_rt.RTStream.record_main : Main NF loop that drives both plots. Notes ----- The cortex is drawn at the resolution of an ico-5 source space: the 10 242 vertices per hemisphere the activity is estimated on, which are the first 10 242 of fsaverage's. Values are interpolated across each triangle. The full 163 842-vertex surface made every update upload sixteen times more data to the GPU for no extra information, about 0.2 s per frame. .. versionadded:: 1.0.0 """
[docs] def __init__( self, subjects_fs_dir: Union[str, Path], clim: tuple[float, float] = (0.0, 0.6), hemi_distance: float = 20.0, surf: str = "inflated", cmap: str = "hot", opacity: float = 0.6, window_size: tuple[int, int] = (1600, 1000), display_smoothing: float = 0.3, verbose: Union[bool, str, None] = None, ) -> None: if not _pyvista_available or not _pyvistaqt_available: raise ImportError( "pyvista and pyvistaqt are required for BrainPlot. " "Install them with: pip install 'mne-rt[viz]'" ) from mne_rt._logging import set_log_level set_log_level(verbose) if cmap not in _CMAPS: raise ValueError(f"cmap {cmap!r} not recognised. Choose from {_CMAPS}.") if surf not in _SURFACES: raise ValueError(f"surf {surf!r} not recognised. Choose from {_SURFACES}.") display_smoothing = check_smoothing(display_smoothing) self._subjects_fs_dir = Path(subjects_fs_dir) self._clim = list(clim) self._hemi_distance = hemi_distance self._surf = surf self._opacity = opacity self._threshold = 0.0 self._cmap_idx = _CMAPS.index(cmap) self._hemi_visible = {"lh": True, "rh": True} self._bg_name = _DEFAULT_BG self._display_mode = _DISPLAY_MODES[0] self._recording = False self._video_writer = None self._video_path: Path | None = None self._display_alpha = float(display_smoothing) self._lh_ema: np.ndarray | None = None self._rh_ema: np.ndarray | None = None # Custom band overlay state self._custom_band_hz: tuple[float, float] | None = None # Parcellation border overlay state self._parc_name: str | None = None self._parc_actor = None self._parc_edges: np.ndarray | None = None # (n_edges, 2) vertex index pairs logger.info("Loading %s surface …", surf) self._load_surface(surf) self._plotter = self._build_plotter(window_size) self._add_key_bindings() self._add_overlays() logger.info("BrainPlot ready.")
# ------------------------------------------------------------------ # Surface management # ------------------------------------------------------------------ def _load_surface(self, surf: str) -> None: ( self._hemi_offsets, self._scalars_full, self._mesh, self._verts_stc, self._nn_map, ) = setup_surface( str(self._subjects_fs_dir), hemi_distance=self._hemi_distance, surf=surf, grade=5, ) self._n_lh = int(self._hemi_offsets["rh"]) t = np.clip((self._mesh["base"] - _SULC_CLIM[0]) / np.ptp(_SULC_CLIM), 0.0, 1.0) grey = _SULC_GREYS[0] + (_SULC_GREYS[1] - _SULC_GREYS[0]) * (1.0 - t) self._base_rgb = np.repeat(grey[:, np.newaxis], 3, axis=1) self._mesh["rgb"] = (self._base_rgb * 255).astype(np.uint8) # ------------------------------------------------------------------ # Plotter construction # ------------------------------------------------------------------ def _build_plotter(self, window_size: tuple[int, int]) -> "_BackgroundPlotter": p = _BackgroundPlotter( window_size=tuple(window_size), lighting="three lights", title="MNE-RT — Brain Activation", toolbar=False, menu_bar=False, editor=False, # Rendered when there is something new (record_main's brain timer), # not also on pyvistaqt's own timer, five times a second regardless. auto_update=False, ) bg_bot, bg_top = _BACKGROUNDS[_DEFAULT_BG] p.set_background(bg_bot, top=bg_top) self._add_cortex_actor(p) self._add_scalar_bar(p) # FXAA only: SSAO (a 256-sample kernel), SSAA (the frame rendered several # times over) and eye-dome lighting (made for point clouds) cost a weak # GPU far more than they add, and made offscreen VTK report errors. p.enable_anti_aliasing("fxaa") p.add_camera_orientation_widget() p.camera_position = "yz" p.camera.azimuth = 45 self._add_control_panel(p) return p def _add_cortex_actor(self, p: "_BackgroundPlotter") -> None: """One actor, coloured on the CPU: the sulcal grey blended with activity. Two overlapping actors, one translucent, doubled the geometry, needed transparency sorting and flickered where the two coincided. """ self._actor = p.add_mesh( self._mesh, scalars="rgb", rgb=True, smooth_shading=True, show_scalar_bar=False ) # The array the actor draws from, written in place on each update. self._rgb = self._actor.mapper.dataset.point_data["rgb"] self._refresh_scalars(deferred=True) def _add_scalar_bar(self, p: "_BackgroundPlotter") -> None: from vtkmodules.vtkCommonCore import vtkLookupTable from vtkmodules.vtkRenderingAnnotation import vtkScalarBarActor self._bar_lut = vtkLookupTable() self._bar = vtkScalarBarActor() self._bar.SetLookupTable(self._bar_lut) self._bar.SetPosition(0.02, 0.20) self._bar.SetWidth(0.04) self._bar.SetHeight(0.38) self._bar.SetNumberOfLabels(0) p.add_actor(self._bar) self._update_scalar_bar() def _update_scalar_bar(self) -> None: lut = _lut(_CMAPS[self._cmap_idx]) self._bar_lut.SetNumberOfTableValues(len(lut)) for i, (r, g, b) in enumerate(lut): self._bar_lut.SetTableValue(i, r, g, b, 1.0) self._bar_lut.SetRange(*self._clim) self._bar_lut.Build() # ------------------------------------------------------------------ # Qt control panel # ------------------------------------------------------------------ def _add_control_panel(self, p: "_BackgroundPlotter") -> None: """Dock a Qt control panel on the right side of the brain window.""" from qtpy.QtCore import Qt as Qt_ from qtpy.QtWidgets import ( QCheckBox, QComboBox, QDockWidget, QDoubleSpinBox, QGroupBox, QHBoxLayout, QLabel, QPushButton, QSlider, QVBoxLayout, QWidget, ) # ── helpers ────────────────────────────────────────────────────── def _hslider(lo: int = 0, hi: int = 100, val: int = 0) -> QSlider: sl = QSlider(Qt_.Orientation.Horizontal) sl.setRange(lo, hi) sl.setValue(val) return sl def _group(title: str) -> tuple[QGroupBox, QVBoxLayout]: grp = QGroupBox(title) ly = QVBoxLayout(grp) ly.setSpacing(4) ly.setContentsMargins(6, 12, 6, 6) return grp, ly # ── root panel ──────────────────────────────────────────────────── panel = QWidget() panel.setMinimumWidth(210) panel.setMaximumWidth(260) panel.setStyleSheet(_PANEL_DARK_STYLE) root = QVBoxLayout(panel) root.setSpacing(6) root.setContentsMargins(6, 6, 6, 6) # ── Surface ─────────────────────────────────────────────────────── grp, ly = _group("Surface") surf_combo = QComboBox() surf_combo.addItems(_SURFACES) surf_combo.setCurrentText(self._surf) surf_combo.currentTextChanged.connect( lambda s: self.set_surface(s) if s != self._surf else None ) ly.addWidget(surf_combo) root.addWidget(grp) # ── Parcellation ────────────────────────────────────────────────── grp, ly = _group("Parcellation") _PARC_ITEMS = ["None", "aparc (Desikan)", "aparc.a2009s (Destrieux)"] self._parc_combo = QComboBox() self._parc_combo.addItems(_PARC_ITEMS) self._parc_combo.currentTextChanged.connect(self._set_parcellation) ly.addWidget(self._parc_combo) self._parc_status_lbl = QLabel("") self._parc_status_lbl.setWordWrap(True) self._parc_status_lbl.setStyleSheet("color:#8b949e; font-size:9px;") ly.addWidget(self._parc_status_lbl) root.addWidget(grp) # ── Hemispheres ─────────────────────────────────────────────────── grp, ly = _group("Hemispheres") hl = QHBoxLayout() lh_cb = QCheckBox("Left") lh_cb.setChecked(True) rh_cb = QCheckBox("Right") rh_cb.setChecked(True) def _on_lh(state: int) -> None: self._hemi_visible["lh"] = bool(state) self._refresh_scalars() def _on_rh(state: int) -> None: self._hemi_visible["rh"] = bool(state) self._refresh_scalars() lh_cb.stateChanged.connect(_on_lh) rh_cb.stateChanged.connect(_on_rh) hl.addWidget(lh_cb) hl.addWidget(rh_cb) ly.addLayout(hl) root.addWidget(grp) # ── Colormap ────────────────────────────────────────────────────── grp, ly = _group("Colormap") cmap_combo = QComboBox() cmap_combo.addItems(_CMAPS) cmap_combo.setCurrentText(_CMAPS[self._cmap_idx]) def _on_cmap(name: str) -> None: idx = _CMAPS.index(name) if idx == self._cmap_idx: return self._cmap_idx = idx self._update_scalar_bar() self._refresh_scalars() cmap_combo.currentTextChanged.connect(_on_cmap) ly.addWidget(cmap_combo) root.addWidget(grp) # ── Color range (clim) ──────────────────────────────────────────── grp, ly = _group("Color Range") clim_max_lbl = QLabel(f"max {self._clim[1]:.2f}") clim_max_sl = _hslider(0, 100, int(self._clim[1] * 100)) clim_min_lbl = QLabel(f"min {self._clim[0]:.2f}") clim_min_sl = _hslider(0, 100, int(self._clim[0] * 100)) def _on_clim_max(v: int) -> None: self._clim[1] = v / 100.0 clim_max_lbl.setText(f"max {self._clim[1]:.2f}") self._update_scalar_bar() self._refresh_scalars() def _on_clim_min(v: int) -> None: self._clim[0] = v / 100.0 clim_min_lbl.setText(f"min {self._clim[0]:.2f}") self._update_scalar_bar() self._refresh_scalars() clim_max_sl.valueChanged.connect(_on_clim_max) clim_min_sl.valueChanged.connect(_on_clim_min) ly.addWidget(clim_max_lbl) ly.addWidget(clim_max_sl) ly.addWidget(clim_min_lbl) ly.addWidget(clim_min_sl) root.addWidget(grp) # ── Opacity ─────────────────────────────────────────────────────── grp, ly = _group("Opacity") op_lbl = QLabel(f"{self._opacity:.2f}") op_sl = _hslider(0, 100, int(self._opacity * 100)) def _on_opacity(v: int) -> None: self._opacity = v / 100.0 op_lbl.setText(f"{self._opacity:.2f}") self._refresh_scalars() op_sl.valueChanged.connect(_on_opacity) ly.addWidget(op_lbl) ly.addWidget(op_sl) root.addWidget(grp) # ── Threshold ───────────────────────────────────────────────────── grp, ly = _group("Threshold (0 → 1)") thr_lbl = QLabel(f"{self._threshold:.3f}") thr_sl = _hslider(0, 100, 0) def _on_threshold(v: int) -> None: self._threshold = v / 100.0 thr_lbl.setText(f"{self._threshold:.3f}") self._refresh_scalars() thr_sl.valueChanged.connect(_on_threshold) ly.addWidget(thr_lbl) ly.addWidget(thr_sl) root.addWidget(grp) # ── Background ──────────────────────────────────────────────────── grp, ly = _group("Background") bg_combo = QComboBox() bg_combo.addItems(list(_BACKGROUNDS.keys())) bg_combo.setCurrentText(_DEFAULT_BG) def _on_bg(name: str) -> None: self._bg_name = name bot, top = _BACKGROUNDS[name] p.set_background(bot, top=top) p.render() bg_combo.currentTextChanged.connect(_on_bg) ly.addWidget(bg_combo) root.addWidget(grp) # ── View presets ────────────────────────────────────────────────── grp, ly = _group("View Presets") btn_row = QHBoxLayout() for label, key, tip in [ ("L", "lateral_lh", "Left lateral"), ("R", "lateral_rh", "Right lateral"), ("D", "dorsal", "Dorsal (top)"), ("F", "frontal", "Frontal"), ("V", "ventral", "Ventral (bottom)"), ]: btn = QPushButton(label) btn.setFixedSize(32, 28) btn.setToolTip(tip) btn.clicked.connect(lambda _=False, k=key: self._set_view(k)) btn_row.addWidget(btn) ly.addLayout(btn_row) root.addWidget(grp) # ── Display Mode ────────────────────────────────────────────────── grp, ly = _group("Display Mode") mode_combo = self._mode_combo = QComboBox() mode_combo.addItems(_DISPLAY_MODES) mode_combo.setCurrentText(self._display_mode) mode_lbl = QLabel( "<span style='color:#8b949e;font-size:9px;'>" "Activation, or source power in the chosen band; " "applies from the next window" "</span>" ) mode_lbl.setWordWrap(True) def _on_display_mode(name: str) -> None: self.set_display_mode(name) mode_combo.currentTextChanged.connect(_on_display_mode) ly.addWidget(mode_combo) ly.addWidget(mode_lbl) root.addWidget(grp) # ── Custom frequency band ───────────────────────────────────────── grp, ly = _group("Custom Band (Hz)") band_row = QHBoxLayout() self._custom_lo_spin = QDoubleSpinBox() self._custom_lo_spin.setRange(0.5, 200.0) self._custom_lo_spin.setValue(13.0) self._custom_lo_spin.setSuffix(" Hz") self._custom_lo_spin.setDecimals(1) dash_lbl2 = QLabel("–") dash_lbl2.setStyleSheet("color:#8b949e;") self._custom_hi_spin = QDoubleSpinBox() self._custom_hi_spin.setRange(1.0, 500.0) self._custom_hi_spin.setValue(30.0) self._custom_hi_spin.setSuffix(" Hz") self._custom_hi_spin.setDecimals(1) band_row.addWidget(self._custom_lo_spin) band_row.addWidget(dash_lbl2) band_row.addWidget(self._custom_hi_spin) ly.addLayout(band_row) btn_band_row = QHBoxLayout() btn_set_band = QPushButton("Set") def _on_set_band() -> None: lo, hi = self._custom_lo_spin.value(), self._custom_hi_spin.value() if lo < hi: # an empty band is ignored, as before self.set_custom_band(lo, hi) btn_set_band.clicked.connect(_on_set_band) btn_clr_band = QPushButton("Clear") btn_clr_band.clicked.connect(self.clear_custom_band) btn_band_row.addWidget(btn_set_band) btn_band_row.addWidget(btn_clr_band) ly.addLayout(btn_band_row) self._custom_band_lbl = QLabel("No custom band set") self._custom_band_lbl.setStyleSheet("color:#8b949e; font-size:10px;") self._custom_band_lbl.setWordWrap(True) ly.addWidget(self._custom_band_lbl) root.addWidget(grp) # ── Actions ─────────────────────────────────────────────────────── grp, ly = _group("Actions") reset_btn = QPushButton("Reset activity (r)") reset_btn.clicked.connect(self.reset_activity) shot_btn = QPushButton("Screenshot (s)") shot_btn.clicked.connect(lambda: self.screenshot()) ly.addWidget(reset_btn) ly.addWidget(shot_btn) root.addWidget(grp) root.addStretch() # ── Keyboard hint ───────────────────────────────────────────────── hint = QLabel( "<span style='color:#8b949e; font-size:10px;'>" "Keys: 1-5 views · i/p/w/h surface · s screenshot · r reset" "</span>" ) hint.setWordWrap(True) root.addWidget(hint) # ── Dock widget ─────────────────────────────────────────────────── dock = QDockWidget("Brain Controls") dock.setWidget(panel) dock.setFeatures( QDockWidget.DockWidgetFeature.DockWidgetMovable | QDockWidget.DockWidgetFeature.DockWidgetFloatable, ) p.app_window.addDockWidget(Qt_.DockWidgetArea.RightDockWidgetArea, dock) def _add_key_bindings(self) -> None: p = self._plotter p.add_key_event("1", lambda: self._set_view("lateral_lh")) p.add_key_event("2", lambda: self._set_view("lateral_rh")) p.add_key_event("3", lambda: self._set_view("dorsal")) p.add_key_event("4", lambda: self._set_view("frontal")) p.add_key_event("5", lambda: self._set_view("ventral")) p.add_key_event("s", self.screenshot) p.add_key_event("r", self.reset_activity) p.add_key_event("i", lambda: self.set_surface("inflated")) p.add_key_event("p", lambda: self.set_surface("pial")) p.add_key_event("w", lambda: self.set_surface("white")) p.add_key_event("h", lambda: self.set_surface("sphere")) def _add_overlays(self) -> None: self._rec_label = self._plotter.add_text( "", position="lower_right", font_size=12, color="#FF4444", ) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _refresh_scalars(self, deferred: bool = False) -> None: """Colour the cortex: activity over the sulcal grey, where it is shown. Activity is shown where it is finite, at least the threshold, and in a visible hemisphere; the opacity sets how much of the grey it covers. """ v = self._scalars_full lo, hi = self._clim with np.errstate(invalid="ignore"): shown = np.isfinite(v) & (v >= self._threshold) t = np.clip((v - lo) / ((hi - lo) or 1.0), 0.0, 1.0) if not self._hemi_visible["lh"]: shown[: self._n_lh] = False if not self._hemi_visible["rh"]: shown[self._n_lh :] = False idx = np.nan_to_num(t * 255.0).astype(np.intp) w = (self._opacity * shown)[:, np.newaxis] rgb = self._base_rgb * (1.0 - w) + _lut(_CMAPS[self._cmap_idx])[idx] * w self._rgb[:] = (rgb * 255.0).astype(np.uint8) if not deferred: self._plotter.render() def _set_view(self, preset: str) -> None: if preset not in _VIEW_PRESETS: return v = _VIEW_PRESETS[preset] self._plotter.camera_position = [v["position"], v["focal"], v["up"]] self._plotter.render() def _rebuild_actors(self) -> None: """Remove and re-add the cortex actor after a surface swap.""" p = self._plotter p.remove_actor(self._actor) self._remove_parc_actor() self._add_cortex_actor(p) # Re-overlay parcellation borders on the new surface geometry if self._parc_edges is not None: self._build_parc_actor() p.render() # ------------------------------------------------------------------ # Parcellation border overlay # ------------------------------------------------------------------ def _set_parcellation(self, parc_label: str) -> None: """Load annotation and draw parcel boundary edges on the surface.""" self._remove_parc_actor() self._parc_edges = None self._parc_name = None if parc_label == "None": if hasattr(self, "_parc_status_lbl"): self._parc_status_lbl.setText("") return parc_id = parc_label.split()[0] # "aparc (Desikan)" → "aparc" logger.info("Loading parcellation %r …", parc_id) if hasattr(self, "_parc_status_lbl"): self._parc_status_lbl.setText("Loading …") try: from pathlib import Path as _Path import mne fs_dir = _Path(mne.datasets.fetch_fsaverage(verbose=False)) subjects_dir = str(fs_dir.parent) labels_lh = mne.read_labels_from_annot( "fsaverage", parc=parc_id, hemi="lh", subjects_dir=subjects_dir, verbose=False, ) labels_rh = mne.read_labels_from_annot( "fsaverage", parc=parc_id, hemi="rh", subjects_dir=subjects_dir, verbose=False, ) n_lh = self._n_lh n_rh = self._mesh.n_points - n_lh parcel_lh = np.full(n_lh, -1, dtype=np.int32) parcel_rh = np.full(n_rh, -1, dtype=np.int32) for i, lbl in enumerate(labels_lh): verts = lbl.vertices[lbl.vertices < n_lh] parcel_lh[verts] = i for i, lbl in enumerate(labels_rh): verts = lbl.vertices[lbl.vertices < n_rh] parcel_rh[verts] = i parcel_all = np.concatenate([parcel_lh, parcel_rh]) # Extract triangle edges and keep those crossing parcel boundaries faces = self._mesh.faces.reshape(-1, 4)[:, 1:] # (n_faces, 3) e01 = np.sort(faces[:, :2], axis=1) e12 = np.sort(faces[:, 1:], axis=1) e02 = np.sort(faces[:, [0, 2]], axis=1) all_edges = np.unique(np.vstack([e01, e12, e02]), axis=0) mask = parcel_all[all_edges[:, 0]] != parcel_all[all_edges[:, 1]] self._parc_edges = all_edges[mask] self._parc_name = parc_id self._build_parc_actor() n_bnd = len(self._parc_edges) logger.info("Parcellation %r: %d boundary edges", parc_id, n_bnd) if hasattr(self, "_parc_status_lbl"): self._parc_status_lbl.setText(f"{n_bnd:,} boundary edges") except Exception as exc: self._parc_edges = None self._parc_name = None logger.warning("Parcellation load failed: %s", exc) if hasattr(self, "_parc_status_lbl"): self._parc_status_lbl.setText(f"Error: {exc}") def _build_parc_actor(self) -> None: """Create a pyvista line mesh from stored boundary edges and add it.""" if self._parc_edges is None or len(self._parc_edges) == 0: return pts = self._mesh.points n_segs = len(self._parc_edges) seg_pts = np.empty((n_segs * 2, 3), dtype=np.float32) seg_pts[0::2] = pts[self._parc_edges[:, 0]] seg_pts[1::2] = pts[self._parc_edges[:, 1]] lines_conn = np.empty(n_segs * 3, dtype=np.int_) lines_conn[0::3] = 2 lines_conn[1::3] = np.arange(0, n_segs * 2, 2) lines_conn[2::3] = np.arange(1, n_segs * 2, 2) bnd = pv.PolyData(seg_pts, lines=lines_conn) self._parc_actor = self._plotter.add_mesh( bnd, color="#ffffff", line_width=2.0, render_lines_as_tubes=False, show_scalar_bar=False, ) def _remove_parc_actor(self) -> None: if self._parc_actor is not None: self._plotter.remove_actor(self._parc_actor) self._parc_actor = None # ------------------------------------------------------------------ # Custom frequency band # ------------------------------------------------------------------
[docs] def set_custom_band(self, lo: float, hi: float) -> None: """Show source power in ``lo``–``hi`` Hz, from the next window on. Parameters ---------- lo, hi : float Band edges in Hz, ``lo < hi``. """ if not 0 < lo < hi: raise ValueError(f"Need 0 < lo < hi, got ({lo}, {hi}).") # On top of the display mode, which stays as it was: Clear goes back to it. self._custom_band_hz = (float(lo), float(hi)) if hasattr(self, "_custom_band_lbl"): self._custom_band_lbl.setText(f"Active: {lo:.1f}–{hi:.1f} Hz") self._custom_band_lbl.setStyleSheet("color:#80d8ff; font-size:10px;") self._reset_smoothing() logger.info("BrainPlot custom band → %.1f–%.1f Hz", lo, hi)
[docs] def set_band(self, band: tuple[float, float] | None) -> None: """Show activation (``None``) or source power in ``band``, in Hz. A band that one of the display modes covers selects that mode; any other becomes a custom band. """ if band is None: self.set_display_mode("Source Activation") return band = (float(band[0]), float(band[1])) for mode, mode_band in _MODE_BANDS.items(): if mode_band == band: self.set_display_mode(mode) return self.set_custom_band(*band)
[docs] def clear_custom_band(self) -> None: """Go back to the display mode's own band.""" self._custom_band_hz = None if hasattr(self, "_custom_band_lbl"): self._custom_band_lbl.setText("No custom band set") self._custom_band_lbl.setStyleSheet("color:#8b949e; font-size:10px;") self._reset_smoothing() logger.info("BrainPlot custom band cleared")
@property def custom_band(self) -> tuple[float, float] | None: """Active custom frequency band ``(lo, hi)`` in Hz, or ``None``.""" return self._custom_band_hz @property def band(self) -> tuple[float, float] | None: """What to compute for the display: a band in Hz, or ``None`` for activation. The custom band when one is set, else the display mode's. :meth:`~mne_rt.RTStream.record_main` reads it for every window, so the controls change what is computed, not just the label. """ if self._custom_band_hz is not None: return self._custom_band_hz return _MODE_BANDS.get(self._display_mode) def _reset_smoothing(self) -> None: # What is shown changes meaning: blending in the old values would put # the previous band's map under the new label for the next frames. self._lh_ema = None self._rh_ema = None # ------------------------------------------------------------------ # Public interface # ------------------------------------------------------------------
[docs] def set_surface(self, surf: str) -> None: """Switch the cortical surface geometry. Parameters ---------- surf : {"inflated", "pial", "white", "sphere"} Target surface geometry. """ if surf not in _SURFACES: raise ValueError(f"surf {surf!r} not recognised. Choose from {_SURFACES}.") if surf == self._surf: return logger.info("Switching surface: %s → %s", self._surf, surf) saved_scalars = self._scalars_full.copy() self._surf = surf self._load_surface(surf) if len(saved_scalars) == len(self._scalars_full): self._scalars_full[:] = saved_scalars self._rebuild_actors() logger.info("Surface changed to %r.", surf)
[docs] def update_from_arrays( self, lh_scalars: np.ndarray, rh_scalars: np.ndarray, mode: str = "power", deferred: bool = False, ) -> None: """Update the brain display from per-source-vertex values. Parameters ---------- lh_scalars, rh_scalars : ndarray, shape (10242,) One value per ico-5 vertex of each hemisphere (NaN where there is none, which is drawn without activity). mode : str, default "power" Kept for API symmetry; unused internally. deferred : bool, default False If ``True``, skip the immediate ``render()`` call. """ lh_scalars = np.asarray(lh_scalars, dtype=float) rh_scalars = np.asarray(rh_scalars, dtype=float) if self._display_alpha < 1.0: if self._lh_ema is None: self._lh_ema, self._rh_ema = lh_scalars.copy(), rh_scalars.copy() else: a = self._display_alpha self._lh_ema = ema_step(self._lh_ema, lh_scalars, a) self._rh_ema = ema_step(self._rh_ema, rh_scalars, a) # A vertex with no value this frame is drawn blank, not with the # average it had: what is shown is never older than the data. lh_scalars = np.where(np.isfinite(lh_scalars), self._lh_ema, np.nan) rh_scalars = np.where(np.isfinite(rh_scalars), self._rh_ema, np.nan) n_lh = self._n_lh self._scalars_full[:n_lh] = lh_scalars[self._nn_map["lh"]] self._scalars_full[n_lh:] = rh_scalars[self._nn_map["rh"]] self._refresh_scalars(deferred=deferred)
[docs] def update(self, stc, mode: str = "power") -> None: """Show one frame of a surface source estimate on fsaverage. Parameters ---------- stc : mne.SourceEstimate On an fsaverage ico-5 (or coarser) surface source space. mode : {"power", "activation"}, default "power" ``"power"`` displays mean squared amplitude; ``"activation"`` displays the time-averaged amplitude. """ vals = np.mean(stc.data**2, axis=1) if mode == "power" else stc.data.mean(axis=1) self.update_from_arrays(*to_display(vals, stc.vertices))
[docs] def set_display_mode(self, mode: str) -> None: """Choose what the display shows, from the next window on. Parameters ---------- mode : str One of :data:`_DISPLAY_MODES`: ``"Source Activation"``, or source power in one of the named bands (see :attr:`band`). Clears any custom band. """ if mode not in _DISPLAY_MODES: raise ValueError(f"mode {mode!r} not recognised. Choose from {_DISPLAY_MODES}.") # The mode first: `band` reads the custom band before the mode, so a # window submitted in between still gets one of the two, not neither. self._display_mode = mode self._custom_band_hz = None if hasattr(self, "_custom_band_lbl"): self._custom_band_lbl.setText("No custom band set") self._custom_band_lbl.setStyleSheet("color:#8b949e; font-size:10px;") combo = getattr(self, "_mode_combo", None) if combo is not None and combo.currentText() != mode: # Set from code (record_main): the menu has to say so, or picking # what it shows would change nothing. combo.blockSignals(True) combo.setCurrentText(mode) combo.blockSignals(False) self._reset_smoothing() logger.info("BrainPlot display mode → %r (band %s)", mode, self.band)
@property def display_mode(self) -> str: """Current display mode string (e.g. ``"Alpha Power (8–13 Hz)"``). ``"Custom lo–hi Hz"`` while a custom band is set. What is computed is :attr:`band`. """ if self._custom_band_hz is not None: lo, hi = self._custom_band_hz return f"Custom {lo:.1f}–{hi:.1f} Hz" return self._display_mode
[docs] def reset_activity(self) -> None: """Zero out all activity scalars and refresh the display.""" self._scalars_full[:] = 0.0 self._reset_smoothing() # or the next update blends into what was cleared self._refresh_scalars()
# ------------------------------------------------------------------ # Video recording # ------------------------------------------------------------------
[docs] def record_video(self, path: Union[str, Path, None] = None) -> Path: """Start recording the brain display to an MP4 video file. Parameters ---------- path : str | Path | None, default None Destination file. Defaults to ``~/ant_brain_<timestamp>.mp4``. Returns ------- path : Path """ import imageio if self._recording: raise RuntimeError("Already recording. Call stop_recording() first.") if path is None: ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") path = Path.home() / f"ant_brain_{ts}.mp4" path = Path(path) self._video_writer = imageio.get_writer( str(path), fps=24, macro_block_size=None, plugin="ffmpeg" ) self._video_path = path self._recording = True self._rec_label.SetInput("● REC") self._plotter.render() logger.info("Brain video recording started: %s", path) return path
[docs] def stop_recording(self) -> Union[Path, None]: """Stop recording and finalise the video file. Returns ------- path : Path | None """ if not self._recording: return None if self._video_writer is not None: self._video_writer.close() self._video_writer = None self._recording = False self._rec_label.SetInput("") self._plotter.render() path = self._video_path self._video_path = None logger.info("Brain video saved: %s", path) return path
[docs] def write_frame_if_recording(self) -> None: """Capture a video frame if recording is active (no-op otherwise).""" if not self._recording or self._video_writer is None: return try: frame = self._plotter.screenshot(return_img=True) self._video_writer.append_data(frame) except Exception: if not getattr(self, "_frame_warned", False): self._frame_warned = True logger.warning("Writing a video frame failed.", exc_info=True)
[docs] def screenshot(self, path: Union[str, Path, None] = None) -> Path: """Save a PNG screenshot of the current brain view. Parameters ---------- path : str | Path | None, default None Destination file. Defaults to ``~/ant_brain_<timestamp>.png``. Returns ------- path : Path """ if path is None: ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") path = Path.home() / f"ant_brain_{ts}.png" self._plotter.screenshot(str(path)) logger.info("Screenshot saved: %s", path) return Path(path)
@property def plotter(self) -> "_BackgroundPlotter": """The underlying :class:`pyvistaqt.BackgroundPlotter` instance.""" return self._plotter @property def surf(self) -> str: """Currently displayed surface geometry name.""" return self._surf