Source code for mne_rt.viz.epoch_plot

"""Real-time continuous M/EEG display with epoch/trigger markers.

Shows a scrolling multi-channel raw signal overlaid with event markers:

* Solid vertical line at each trigger (t = 0 of the epoch)
* Semi-transparent shaded region spanning [tmin, tmax] around each trigger
* Dashed boundary lines at the epoch edges (tmin, tmax)

tmin / tmax are adjustable live in the sidebar.  Different event codes
are assigned distinct colours via the ``event_id`` mapping.  Left-click a
shaded epoch span to interactively mark it bad (drawn in red); see
:attr:`EpochPlot.bad_epoch_ids`.

Classes
-------
EpochPlot
    Scrolling raw viewer with epoch / trigger overlays.
"""

from __future__ import annotations

import datetime
from collections import deque
from pathlib import Path

import numpy as np
import pyqtgraph as pg
import pyqtgraph.exporters
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
from qtpy.QtWidgets import (
    QCheckBox,
    QDoubleSpinBox,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QMainWindow,
    QPushButton,
    QScrollArea,
    QScrollBar,
    QVBoxLayout,
    QWidget,
)

# ---------------------------------------------------------------------------
# Colour palettes
# ---------------------------------------------------------------------------

_TRACE_COLORS = [
    "#4fc3f7",
    "#ef9a9a",
    "#a5d6a7",
    "#fff176",
    "#ffab91",
    "#ce93d8",
    "#80cbc4",
    "#ffcc80",
    "#80deea",
    "#b39ddb",
    "#f48fb1",
    "#c5e1a5",
    "#ffd54f",
    "#81d4fa",
    "#dce775",
    "#ff8a65",
    "#90caf9",
    "#e6ee9c",
    "#bcaaa4",
    "#ffe082",
]

# Trigger colours: red, green, cyan, yellow, magenta, orange
_EVENT_COLORS = [
    "#69f0ae",
    "#40c4ff",
    "#ffff00",
    "#ff9e80",
    "#ea80fc",
    "#80cbc4",
]

_TIME_WINDOW_OPTIONS = [2, 5, 10, 20]

_QSS = """
QMainWindow, QWidget {
    background-color: #1a1a2e;
    color: #e0e0e0;
    font-family: "Segoe UI", sans-serif;
}
QPushButton {
    background-color: #16213e;
    color: #d0d0e8;
    border: 1px solid #0f3460;
    border-radius: 5px;
    padding: 5px 10px;
    font-size: 12px;
}
QPushButton:hover  { background-color: #0f3460; }
QPushButton:pressed { background-color: #533483; }
QPushButton:checked {
    background-color: #533483;
    border-color: #a882dd;
    color: #ffffff;
}
QComboBox {
    background-color: #16213e;
    color: #d0d0e8;
    border: 1px solid #0f3460;
    border-radius: 4px;
    padding: 3px 6px;
}
QDoubleSpinBox, QSpinBox {
    background-color: #16213e;
    color: #d0d0e8;
    border: 1px solid #0f3460;
    border-radius: 4px;
    padding: 2px 4px;
}
QGroupBox {
    border: 1px solid #2a2a4a;
    border-radius: 6px;
    margin-top: 10px;
    padding-top: 6px;
    font-weight: bold;
    font-size: 11px;
    color: #8888aa;
}
QGroupBox::title {
    subcontrol-origin: margin;
    left: 8px;
    padding: 0 4px;
}
QLabel  { color: #b0b0c8; font-size: 11px; }
QCheckBox { color: #b0b0c8; font-size: 11px; }
QScrollArea { border: none; }
QStatusBar { background-color: #0d0d1a; color: #606080; font-size: 10px; }
QScrollBar:vertical {
    background-color: #0d0d1a;
    width: 14px;
    border: none;
    margin: 0px;
}
QScrollBar::handle:vertical {
    background-color: #2a2a4a;
    border-radius: 4px;
    min-height: 24px;
}
QScrollBar::handle:vertical:hover { background-color: #404060; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
    background-color: #0d0d1a;
}
"""


# ---------------------------------------------------------------------------
# Wheel-event filter (same as RawPlot)
# ---------------------------------------------------------------------------


class _WheelFilter(QObject):
    def __init__(self, callback, parent=None):
        super().__init__(parent)
        self._cb = callback

    def eventFilter(self, obj, event):
        if event.type() == QEvent.Type.Wheel:
            self._cb(1 if event.angleDelta().y() > 0 else -1)
            return True
        return False


# ---------------------------------------------------------------------------
# EpochPlot
# ---------------------------------------------------------------------------


[docs] class EpochPlot(QMainWindow): """Real-time scrolling M/EEG viewer with epoch / trigger overlays. Displays all channels stacked vertically (identical layout to :class:`~mne_rt.viz.RawPlot`) with coloured event markers overlaid on the signal. For each trigger event a solid vertical line marks t = 0, a semi-transparent shaded band spans the epoch window [tmin, tmax], and dashed boundary lines sit at the epoch edges. Parameters ---------- ch_names : list of str Channel names. One row per channel. sfreq : float Sampling frequency in Hz. tmin : float, default -0.1 Epoch start in seconds relative to each trigger. tmax : float, default 0.5 Epoch end in seconds relative to each trigger. n_shown : int, default 20 Number of channels visible simultaneously. time_window : float, default 10.0 Visible time range in seconds at startup. scale_uv : float, default 100.0 Amplitude scale in µV (half of per-channel row height). event_id : dict[str, int] | None, default None Maps condition names to integer trigger codes. Each code gets a distinct colour; unmapped codes use the first colour. info : mne.Info | None, default None If provided, used for channel-type detection and right-click sensor position. verbose : bool | str | None, default None See Also -------- mne_rt.viz.RawPlot : Continuous raw viewer without epoch overlays. mne_rt.RTEpochs : Event-triggered epoch accumulator. Notes ----- Feed data with :meth:`push` (shape ``(n_ch, n_times)``) and trigger events with :meth:`push_trigger`. Both calls are safe to make from an acquisition thread. Left-click a shaded epoch span to mark it bad (rendered in red); click again to unmark it. Marked epochs are tracked by :attr:`bad_epoch_ids` for use downstream (e.g. excluding them when building an :class:`mne.Epochs` object from the corresponding trigger stream). The "Clear bad-epoch marks" sidebar button resets all marks without discarding the triggers themselves. .. versionadded:: 1.1.0 """
[docs] def __init__( self, ch_names: list[str], sfreq: float, tmin: float = -0.1, tmax: float = 0.5, n_shown: int = 20, time_window: float = 10.0, scale_uv: float = 100.0, event_id: dict[str, int] | None = None, info=None, verbose=None, ) -> None: from mne_rt._logging import set_log_level set_log_level(verbose) super().__init__() self._ch_names = list(ch_names) self._n_ch = len(ch_names) self._sfreq = float(sfreq) self._tmin = float(tmin) self._tmax = float(tmax) self._n_shown = min(int(n_shown), self._n_ch) self._time_window = float(time_window) self._scale = float(scale_uv) * 1e-6 self._event_id = dict(event_id) if event_id else {} self._info = info self._page_start = 0 self._paused = False # Stream state self._total_pushed: int = 0 n_pts = max(int(sfreq * time_window), 30) self._time_axis = np.linspace(0.0, time_window, n_pts) self._buf = np.zeros((self._n_ch, n_pts)) # Trigger history: (abs_sample_idx, event_code, epoch_id) self._triggers: deque[tuple[int, int, int]] = deque(maxlen=500) self._next_epoch_id: int = 0 # Overlay items currently on the plot (cleared each redraw) self._epoch_overlay_items: list = [] # Epoch ids marked bad by left-clicking their shaded span self._bad_epoch_ids: set[int] = set() # (epoch_id, x_lo, x_hi) for epochs drawn in the current redraw — # used to hit-test left-clicks against the visible epoch spans. self._visible_epoch_spans: list[tuple[int, float, float]] = [] # Thread-safe pending queue: ('data', ndarray) | ('trigger', int) # push()/push_trigger() enqueue here (any thread); _process_pending() # drains it in the main Qt thread at 30 Hz via a QTimer. self._pending: deque = deque() # Build colour map: event_code → colour string self._code_colors: dict[int, str] = {} for i, (name, code) in enumerate(self._event_id.items()): self._code_colors[code] = _EVENT_COLORS[i % len(_EVENT_COLORS)] self._default_color = _EVENT_COLORS[0] # Per-channel colours self._ch_colors = [_TRACE_COLORS[i % len(_TRACE_COLORS)] for i in range(self._n_ch)] pg.setConfigOptions(antialias=True, foreground="#c0c0d8", background="#0d0d1a") self._build_ui() self.setWindowTitle("MNE-RT — Epoch Viewer") self.resize(1500, 720) # 30 Hz render timer — processes queued data in the main Qt thread. self._render_timer = QTimer(self) self._render_timer.setInterval(33) self._render_timer.timeout.connect(self._process_pending) self._render_timer.start()
# ------------------------------------------------------------------ # UI construction # ------------------------------------------------------------------ def _build_ui(self) -> None: self.setStyleSheet(_QSS) central = QWidget() self.setCentralWidget(central) root = QHBoxLayout(central) root.setContentsMargins(8, 8, 4, 8) root.setSpacing(0) root.addWidget(self._build_plot_widget(), stretch=5) self._ch_scroll = QScrollBar(Qt.Orientation.Vertical) self._ch_scroll.setRange(0, max(0, self._n_ch - self._n_shown)) self._ch_scroll.setPageStep(self._n_shown) self._ch_scroll.setSingleStep(1) self._ch_scroll.setFixedWidth(14) self._ch_scroll.valueChanged.connect(lambda v: self._set_page_start(v, source="scrollbar")) root.addWidget(self._ch_scroll) root.addSpacing(4) root.addWidget(self._build_control_panel(), stretch=0) self._status = self.statusBar() self._status.showMessage("Waiting for data …") def _build_plot_widget(self) -> pg.GraphicsLayoutWidget: glw = pg.GraphicsLayoutWidget() glw.setBackground("#0d0d1a") self._pi = glw.addPlot(row=0, col=0) self._pi.setMouseEnabled(x=False, y=False) vb = self._pi.getViewBox() vb.setMouseEnabled(x=False, y=False) vb.setMenuEnabled(False) self._pi.showGrid(x=True, y=False, alpha=0.25) for ax_name in ("left", "bottom"): ax = self._pi.getAxis(ax_name) ax.setPen(pg.mkPen("#303050")) ax.setTextPen(pg.mkPen("#9090aa")) self._pi.getAxis("left").setWidth(80) self._pi.setLabel("bottom", "Time", units="s", color="#9090aa") self._pi.setXRange(0.0, self._time_window, padding=0.01) self._pi.setYRange(-0.5, self._n_shown - 0.5, padding=0) self._curves: list[pg.PlotCurveItem] = [ pg.PlotCurveItem(pen=pg.mkPen(color=_TRACE_COLORS[0], width=1)) for _ in range(self._n_shown) ] for c in self._curves: self._pi.addItem(c) sep_pen = pg.mkPen(color=(70, 70, 110, 55), width=1, style=Qt.PenStyle.DotLine) for i in range(self._n_shown): self._pi.addItem(pg.InfiniteLine(pos=i, angle=0, pen=sep_pen)) self._update_tick_labels() self._wheel_filter = _WheelFilter(self._on_plot_wheel, self) glw.viewport().installEventFilter(self._wheel_filter) glw.scene().sigMouseClicked.connect(self._on_scene_clicked) self._glw = glw return glw def _build_control_panel(self) -> QScrollArea: scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFixedWidth(230) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) panel = QWidget() layout = QVBoxLayout(panel) layout.setSpacing(8) layout.setContentsMargins(6, 6, 6, 6) layout.addWidget(self._grp_playback()) layout.addWidget(self._grp_amplitude()) layout.addWidget(self._grp_display()) layout.addWidget(self._grp_epoch()) layout.addWidget(self._grp_events()) layout.addStretch() scroll.setWidget(panel) return scroll # ── sidebar groups ───────────────────────────────────────────────── def _grp_playback(self) -> QGroupBox: grp = QGroupBox("Playback") lay = QVBoxLayout(grp) self._btn_pause = QPushButton("⏸ Pause") self._btn_pause.setCheckable(True) self._btn_pause.clicked.connect(self._toggle_pause) btn_clear = QPushButton("⟳ Clear") btn_clear.clicked.connect(self._clear) btn_shot = QPushButton("📷 Screenshot") btn_shot.clicked.connect(self._screenshot) for w in (self._btn_pause, btn_clear, btn_shot): lay.addWidget(w) return grp def _grp_amplitude(self) -> QGroupBox: grp = QGroupBox("Amplitude") lay = QVBoxLayout(grp) self._scale_lbl = QLabel(self._fmt_scale()) self._scale_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self._scale_lbl.setStyleSheet("color:#7ec8e3; font-size:12px; font-weight:bold;") row = QHBoxLayout() btn_dn = QPushButton("÷2") btn_up = QPushButton("×2") for b in (btn_dn, btn_up): b.setFixedSize(42, 26) btn_dn.clicked.connect(self._scale_down) btn_up.clicked.connect(self._scale_up) row.addStretch() row.addWidget(btn_dn) row.addWidget(btn_up) row.addStretch() lay.addWidget(self._scale_lbl) lay.addLayout(row) return grp def _grp_display(self) -> QGroupBox: grp = QGroupBox("Display") lay = QVBoxLayout(grp) row = QHBoxLayout() row.addWidget(QLabel("Time window:")) from qtpy.QtWidgets import QComboBox self._cmb_tw = QComboBox() for secs in _TIME_WINDOW_OPTIONS: self._cmb_tw.addItem(f"{secs} s", secs) best = min(_TIME_WINDOW_OPTIONS, key=lambda s: abs(s - self._time_window)) self._cmb_tw.setCurrentIndex(_TIME_WINDOW_OPTIONS.index(best)) self._cmb_tw.currentIndexChanged.connect(self._change_time_window) row.addWidget(self._cmb_tw) lay.addLayout(row) chk_grid = QCheckBox("Show grid") chk_grid.setChecked(True) chk_grid.toggled.connect( lambda on: self._pi.showGrid(x=on, y=False, alpha=0.25 if on else 0.0) ) lay.addWidget(chk_grid) return grp def _grp_epoch(self) -> QGroupBox: grp = QGroupBox("Epoch Window") lay = QVBoxLayout(grp) lay.setSpacing(5) tmin_row = QHBoxLayout() tmin_row.addWidget(QLabel("tmin:")) self._tmin_spin = QDoubleSpinBox() self._tmin_spin.setRange(-5.0, 0.0) self._tmin_spin.setValue(self._tmin) self._tmin_spin.setSuffix(" s") self._tmin_spin.setDecimals(2) self._tmin_spin.setSingleStep(0.05) tmin_row.addWidget(self._tmin_spin) lay.addLayout(tmin_row) tmax_row = QHBoxLayout() tmax_row.addWidget(QLabel("tmax:")) self._tmax_spin = QDoubleSpinBox() self._tmax_spin.setRange(0.0, 5.0) self._tmax_spin.setValue(self._tmax) self._tmax_spin.setSuffix(" s") self._tmax_spin.setDecimals(2) self._tmax_spin.setSingleStep(0.05) tmax_row.addWidget(self._tmax_spin) lay.addLayout(tmax_row) btn_apply = QPushButton("Apply") btn_apply.setStyleSheet( "background:#132744; color:#80d8ff; border:1px solid #2a6090;" "border-radius:4px; padding:4px; font-size:11px;" ) btn_apply.clicked.connect(self._apply_epoch_window) lay.addWidget(btn_apply) self._epoch_lbl = QLabel(f"Window: {self._tmin:.2f}{self._tmax:.2f} s") self._epoch_lbl.setStyleSheet("color:#80d8ff; font-size:10px;") self._epoch_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.addWidget(self._epoch_lbl) chk_region = QCheckBox("Show epoch region") chk_region.setChecked(True) chk_region.toggled.connect(self._set_show_epoch_region) lay.addWidget(chk_region) self._show_epoch_region: bool = True chk_bounds = QCheckBox("Show tmin/tmax lines") chk_bounds.setChecked(True) chk_bounds.toggled.connect(self._set_show_epoch_bounds) lay.addWidget(chk_bounds) self._show_epoch_bounds: bool = True return grp def _grp_events(self) -> QGroupBox: grp = QGroupBox("Events") lay = QVBoxLayout(grp) lay.setSpacing(4) self._event_count_lbl = QLabel("No triggers received") self._event_count_lbl.setStyleSheet("color:#505070; font-size:10px;") self._event_count_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.addWidget(self._event_count_lbl) btn_clear_trigs = QPushButton("Clear triggers") btn_clear_trigs.clicked.connect(self._clear_triggers) lay.addWidget(btn_clear_trigs) bad_hint = QLabel("Click an epoch's shaded\nspan to mark it bad (red)") bad_hint.setWordWrap(True) bad_hint.setStyleSheet("color:#7a7a9a; font-size:9px;") lay.addWidget(bad_hint) btn_clear_bad = QPushButton("Clear bad-epoch marks") btn_clear_bad.clicked.connect(self._clear_bad_epochs) lay.addWidget(btn_clear_bad) # Legend: one colour swatch per event code if self._event_id: lay.addWidget(QLabel("─── Legend ───")) for name, code in self._event_id.items(): color = self._code_colors.get(code, self._default_color) legend_row = QHBoxLayout() swatch = QLabel("■") swatch.setStyleSheet(f"color:{color}; font-size:14px;") legend_row.addWidget(swatch) legend_row.addWidget(QLabel(f"{name} (code {code})")) legend_row.addStretch() lay.addLayout(legend_row) return grp # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _fmt_scale(self) -> str: uv = self._scale * 1e6 if uv >= 1000: return f"{uv / 1000:.4g} mV / row" if uv >= 1: return f"{uv:.4g} µV / row" return f"{uv * 1000:.4g} nV / row" def _update_tick_labels(self) -> None: end = min(self._page_start + self._n_shown, self._n_ch) n_actual = end - self._page_start ticks = [(n_actual - 1 - i, self._ch_names[self._page_start + i]) for i in range(n_actual)] self._pi.getAxis("left").setTicks([ticks, []]) def _set_page_start(self, new_start: int, source: str = "other") -> None: new_start = max(0, min(new_start, max(0, self._n_ch - self._n_shown))) if new_start == self._page_start: return self._page_start = new_start self._update_tick_labels() if source != "scrollbar": self._ch_scroll.blockSignals(True) self._ch_scroll.setValue(new_start) self._ch_scroll.blockSignals(False) self._redraw() def _trigger_color(self, code: int) -> str: return self._code_colors.get(code, self._default_color) # ------------------------------------------------------------------ # Callbacks — scroll / wheel / click # ------------------------------------------------------------------ def _on_plot_wheel(self, direction: int) -> None: step = max(1, self._n_shown // 4) self._set_page_start(self._page_start - direction * step, source="wheel") def _on_scene_clicked(self, event) -> None: pos = event.scenePos() btn = event.button() vb = self._pi.getViewBox() if btn == Qt.MouseButton.RightButton: axis = self._pi.getAxis("left") if not axis.sceneBoundingRect().contains(pos): return y_val = vb.mapSceneToView(pos).y() end = min(self._page_start + self._n_shown, self._n_ch) n_actual = end - self._page_start vis_idx = int(round(n_actual - 1 - y_val)) if 0 <= vis_idx < n_actual: ch_idx = self._page_start + vis_idx self._show_channel_location(self._ch_names[ch_idx]) return if btn == Qt.MouseButton.LeftButton and not event.double(): if vb.sceneBoundingRect().contains(pos): x_val = vb.mapSceneToView(pos).x() epoch_id = self._epoch_id_at(x_val) if epoch_id is not None: # Defer to keep Qt widget calls in the main thread, same # pattern as RawPlot._toggle_bad_channel. QTimer.singleShot(0, lambda eid=epoch_id: self._toggle_bad_epoch(eid)) def _epoch_id_at(self, x_val: float) -> int | None: """Return the epoch id whose span contains ``x_val``, if any.""" for epoch_id, x_lo, x_hi in self._visible_epoch_spans: if x_lo <= x_val <= x_hi: return epoch_id return None def _toggle_bad_epoch(self, epoch_id: int) -> None: if epoch_id in self._bad_epoch_ids: self._bad_epoch_ids.discard(epoch_id) else: self._bad_epoch_ids.add(epoch_id) self._update_event_count_label() self._redraw() def _show_channel_location(self, ch_name: str) -> None: if self._info is None: self._status.showMessage(f"No Info — cannot show position for {ch_name}") return try: import matplotlib.pyplot as plt import mne fig = mne.viz.plot_sensors( self._info, show_names=True, title=f"Sensor position — {ch_name}", show=False, ) for ax in fig.axes: for txt in ax.texts: if txt.get_text() == ch_name: txt.set_color("#cc0000") txt.set_fontsize(10) txt.set_fontweight("bold") plt.show(block=False) except Exception as exc: self._status.showMessage(f"Could not show sensor position: {exc}") # ------------------------------------------------------------------ # Callbacks — playback / display # ------------------------------------------------------------------ def _toggle_pause(self, checked: bool) -> None: self._paused = checked self._btn_pause.setText("▶ Resume" if checked else "⏸ Pause") def _clear(self) -> None: self._pending.clear() self._buf[:] = 0.0 self._triggers.clear() self._epoch_overlay_items.clear() self._bad_epoch_ids.clear() self._visible_epoch_spans.clear() self._next_epoch_id = 0 self._total_pushed = 0 self._redraw() def _screenshot(self) -> None: from qtpy.QtWidgets import QFileDialog ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") default = str(Path.home() / f"epoch_plot_{ts}.png") path, _ = QFileDialog.getSaveFileName(self, "Save Screenshot", default, "PNG Image (*.png)") if not path: return exp = pg.exporters.ImageExporter(self._glw.scene()) exp.parameters()["width"] = 1920 exp.export(path) def _scale_up(self) -> None: self._scale *= 2.0 self._scale_lbl.setText(self._fmt_scale()) self._redraw() def _scale_down(self) -> None: self._scale /= 2.0 self._scale_lbl.setText(self._fmt_scale()) self._redraw() def _change_time_window(self, idx: int) -> None: secs = float(self._cmb_tw.itemData(idx)) self._time_window = secs n_pts = max(int(self._sfreq * secs), 30) self._time_axis = np.linspace(0.0, secs, n_pts) self._buf = np.zeros((self._n_ch, n_pts)) # _total_pushed intentionally kept — triggers remain valid relative to stream self._pi.setXRange(0.0, secs, padding=0.01) def _apply_epoch_window(self) -> None: self._tmin = self._tmin_spin.value() self._tmax = self._tmax_spin.value() self._epoch_lbl.setText(f"Window: {self._tmin:.2f}{self._tmax:.2f} s") self._redraw() def _set_show_epoch_region(self, on: bool) -> None: self._show_epoch_region = on self._redraw() def _set_show_epoch_bounds(self, on: bool) -> None: self._show_epoch_bounds = on self._redraw() def _clear_triggers(self) -> None: self._triggers.clear() self._bad_epoch_ids.clear() self._event_count_lbl.setText("No triggers received") self._event_count_lbl.setStyleSheet("color:#505070; font-size:10px;") self._redraw() def _clear_bad_epochs(self) -> None: self._bad_epoch_ids.clear() self._update_event_count_label() self._redraw() def _update_event_count_label(self) -> None: n_t = len(self._triggers) n_bad = len(self._bad_epoch_ids) if n_t == 0: self._event_count_lbl.setText("No triggers received") self._event_count_lbl.setStyleSheet("color:#505070; font-size:10px;") return text = f"{n_t} trigger{'s' if n_t != 1 else ''} received" if n_bad: text += f" ✕ {n_bad} bad" self._event_count_lbl.setText(text) self._event_count_lbl.setStyleSheet("color:#80d8ff; font-size:10px;") # ------------------------------------------------------------------ # Epoch overlay rendering # ------------------------------------------------------------------ #: Bad-epoch overlay colour (red) — overrides the per-code colour so a #: rejected epoch is unmistakable regardless of its event code. _BAD_EPOCH_COLOR = "#ff5252" def _redraw_epoch_overlays(self) -> None: for item in self._epoch_overlay_items: self._pi.removeItem(item) self._epoch_overlay_items.clear() self._visible_epoch_spans.clear() if not self._triggers: return buf_size = self._buf.shape[1] buf_start = self._total_pushed - buf_size # absolute sample of leftmost buf column dash_style = Qt.PenStyle.DashLine for trig_abs, code, epoch_id in self._triggers: # x coordinate of the trigger (t=0) in the current view x0 = (trig_abs - buf_start) / self._sfreq # Accept if the epoch window overlaps the visible range x_lo = x0 + self._tmin x_hi = x0 + self._tmax if x_hi < 0 or x_lo > self._time_window: continue is_bad = epoch_id in self._bad_epoch_ids color = self._BAD_EPOCH_COLOR if is_bad else self._trigger_color(code) self._visible_epoch_spans.append((epoch_id, x_lo, x_hi)) # ── solid trigger line at t=0 ────────────────────────────── trig_line = pg.InfiniteLine( pos=x0, angle=90, pen=pg.mkPen(color=color, width=3 if is_bad else 2), ) self._pi.addItem(trig_line) self._epoch_overlay_items.append(trig_line) # ── shaded epoch region ──────────────────────────────────── if self._show_epoch_region: r, g, b = self._hex_to_rgb(color) region = pg.LinearRegionItem( values=(x_lo, x_hi), brush=pg.mkBrush(r, g, b, 70 if is_bad else 30), movable=False, pen=pg.mkPen(color=color, width=1) if is_bad else pg.mkPen(None), ) self._pi.addItem(region) self._epoch_overlay_items.append(region) # ── dashed epoch boundary lines ──────────────────────────── if self._show_epoch_bounds: for x_bnd in (x_lo, x_hi): if 0 <= x_bnd <= self._time_window: bnd_line = pg.InfiniteLine( pos=x_bnd, angle=90, pen=pg.mkPen(color=color, width=1, style=dash_style), ) self._pi.addItem(bnd_line) self._epoch_overlay_items.append(bnd_line) @staticmethod def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]: h = hex_color.lstrip("#") return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) # ------------------------------------------------------------------ # Redraw # ------------------------------------------------------------------ def _redraw(self) -> None: end = min(self._page_start + self._n_shown, self._n_ch) visible = list(range(self._page_start, end)) n_actual = len(visible) gain = 1.0 / (self._scale + 1e-300) for vis_idx, ch_idx in enumerate(visible): raw = self._buf[ch_idx].copy() color = self._ch_colors[ch_idx] self._curves[vis_idx].setPen(pg.mkPen(color=color, width=1)) offset = float(n_actual - 1 - vis_idx) self._curves[vis_idx].setData(self._time_axis, offset + raw * gain) for vis_idx in range(n_actual, self._n_shown): self._curves[vis_idx].setData([], []) self._redraw_epoch_overlays() # ------------------------------------------------------------------ # Public interface # ------------------------------------------------------------------ @property def bad_epoch_ids(self) -> list[int]: """Sorted list of epoch ids marked bad by clicking their span.""" return sorted(self._bad_epoch_ids)
[docs] def is_epoch_bad(self, epoch_id: int) -> bool: """Whether ``epoch_id`` has been marked bad. Parameters ---------- epoch_id : int Epoch id, as assigned in push order starting at 0 (the id of the ``n``-th call to :meth:`push_trigger` is ``n``, ignoring any epochs since dropped from the trigger history). """ return epoch_id in self._bad_epoch_ids
[docs] def push(self, data: np.ndarray) -> None: """Enqueue a raw data chunk for display. Thread-safe: may be called from any thread. The data is written into the circular buffer and rendered by the main-thread timer at ~30 Hz. Parameters ---------- data : ndarray, shape (n_channels, n_samples) New raw data chunk. No-op when paused. """ if self._paused: return self._pending.append(("data", data.copy()))
[docs] def push_trigger(self, code: int = 1) -> None: """Enqueue a trigger event at the current stream position. Thread-safe: may be called from any thread. The trigger is placed after all data chunks already in the queue, so the sample index is computed correctly in the main thread. Parameters ---------- code : int, default 1 Integer event code matched against :attr:`event_id`. """ self._pending.append(("trigger", code))
def _process_pending(self) -> None: """Drain the pending queue and redraw — called in the main thread at 30 Hz.""" if not self._pending: return changed = False while self._pending: kind, payload = self._pending.popleft() if kind == "data": n = payload.shape[1] self._buf = np.roll(self._buf, -n, axis=1) self._buf[:, -n:] = payload self._total_pushed += n changed = True else: # trigger epoch_id = self._next_epoch_id self._next_epoch_id += 1 self._triggers.append((self._total_pushed, payload, epoch_id)) self._update_event_count_label() changed = True if changed: end = min(self._page_start + self._n_shown, self._n_ch) self._status.showMessage( f"Streaming — ch {self._page_start + 1}{end} of {self._n_ch}" f" | triggers: {len(self._triggers)}" ) self._redraw()
[docs] def closeEvent(self, event) -> None: self._render_timer.stop() super().closeEvent(event)