|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import sys |
| 3 | +import argparse |
| 4 | +import numpy as np |
| 5 | + |
| 6 | +from PyQt5 import QtCore |
| 7 | +from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout |
| 8 | +import pyqtgraph as pg |
| 9 | + |
| 10 | +from pyoephys.interface._lsl_client import LSLClient, NotReadyError |
| 11 | +from pyoephys.io import parse_numeric_args |
| 12 | +from pyoephys.logging import configure |
| 13 | + |
| 14 | +def _parse_channels_arg(tokens): |
| 15 | + if tokens is None: |
| 16 | + return None |
| 17 | + if isinstance(tokens, str): |
| 18 | + s = tokens.strip().lower() |
| 19 | + if s == "all": |
| 20 | + return None |
| 21 | + s = s.replace(":", "-") |
| 22 | + return parse_numeric_args(s) |
| 23 | + if len(tokens) == 1: |
| 24 | + t0 = tokens[0].strip().lower() |
| 25 | + if t0 == "all": |
| 26 | + return None |
| 27 | + return parse_numeric_args(t0.replace(":", "-")) |
| 28 | + return parse_numeric_args([int(x) for x in tokens]) |
| 29 | + |
| 30 | +def _robust_span(y): |
| 31 | + """Median ± k*MAD for a robust amplitude estimate.""" |
| 32 | + med = np.median(y) |
| 33 | + mad = 1.4826 * np.median(np.abs(y - med)) # ~std for Gaussian |
| 34 | + return max(1e-6, 4.0 * mad) # 4*MAD ≈ ~97% span |
| 35 | + |
| 36 | +class LSLViewer(QWidget): |
| 37 | + def __init__( |
| 38 | + self, |
| 39 | + stream_name=None, |
| 40 | + stream_type="EMG", |
| 41 | + window_s=5.0, |
| 42 | + downsample=1, |
| 43 | + ylim=None, |
| 44 | + channels=None, |
| 45 | + timeout_s=5.0, |
| 46 | + timer_ms=16, |
| 47 | + precise_timer=False, |
| 48 | + resample_hz=120.0, # visual grid Hz |
| 49 | + clip_to_view=False, |
| 50 | + stacked=False, |
| 51 | + stack_spacing=None, # float (units of signal). None = auto |
| 52 | + verbose=False, |
| 53 | + ): |
| 54 | + super().__init__() |
| 55 | + self.setWindowTitle("pyoephys LSL Viewer") |
| 56 | + self.client = LSLClient( |
| 57 | + stream_name=stream_name, |
| 58 | + stream_type=stream_type or "EMG", |
| 59 | + timeout_s=timeout_s, |
| 60 | + buffer_seconds=max(window_s * 2.5, 30.0), |
| 61 | + verbose=verbose, |
| 62 | + ) |
| 63 | + self.window_s = float(window_s) |
| 64 | + self.downsample = max(1, int(downsample)) |
| 65 | + self.fixed_ylim = ylim |
| 66 | + self.req_channels = channels |
| 67 | + self.idx = None |
| 68 | + self.resample_hz = float(resample_hz) if resample_hz else None |
| 69 | + self.clip_to_view = bool(clip_to_view) |
| 70 | + self.stacked = bool(stacked) |
| 71 | + self.stack_spacing = stack_spacing # None => auto per frame |
| 72 | + |
| 73 | + # UI |
| 74 | + layout = QVBoxLayout(self) |
| 75 | + self.plot = pg.PlotWidget() |
| 76 | + self.plot.setBackground("w") |
| 77 | + self.plot.addLegend(offset=(10, 10)) |
| 78 | + if self.fixed_ylim is not None and not self.stacked: |
| 79 | + self.plot.setYRange(self.fixed_ylim[0], self.fixed_ylim[1]) |
| 80 | + self.plot.showGrid(x=True, y=True, alpha=0.3) |
| 81 | + layout.addWidget(self.plot) |
| 82 | + |
| 83 | + self.curves = [] |
| 84 | + |
| 85 | + # start client thread |
| 86 | + self.client.start() |
| 87 | + |
| 88 | + # timer |
| 89 | + self.timer = pg.QtCore.QTimer(self) |
| 90 | + if precise_timer: |
| 91 | + self.timer.setTimerType(QtCore.Qt.PreciseTimer) |
| 92 | + self.timer.timeout.connect(self._on_timer) |
| 93 | + self.timer.start(int(timer_ms)) |
| 94 | + |
| 95 | + def closeEvent(self, e): |
| 96 | + try: |
| 97 | + self.timer.stop() |
| 98 | + except Exception: |
| 99 | + pass |
| 100 | + self.client.stop() |
| 101 | + return super().closeEvent(e) |
| 102 | + |
| 103 | + def _ensure_curves(self): |
| 104 | + if not self.client.ready_event.is_set(): |
| 105 | + return False |
| 106 | + if self.curves: |
| 107 | + return True |
| 108 | + |
| 109 | + C = self.client.n_channels or 0 |
| 110 | + if C <= 0: |
| 111 | + return False |
| 112 | + |
| 113 | + # resolve channel indices |
| 114 | + if self.req_channels is None: |
| 115 | + self.idx = list(range(C)) |
| 116 | + else: |
| 117 | + self.idx = [i for i in self.req_channels if 0 <= i < C] or [0] |
| 118 | + |
| 119 | + palette = [pg.intColor(i, hues=len(self.idx)) for i in range(len(self.idx))] |
| 120 | + for k, ch in enumerate(self.idx): |
| 121 | + item = self.plot.plot([], [], pen=pg.mkPen(palette[k], width=1.0), |
| 122 | + name=f"ch{ch}") |
| 123 | + if self.clip_to_view: |
| 124 | + item.setClipToView(True) |
| 125 | + item.setDownsampling(auto=True, method="peak") |
| 126 | + self.curves.append(item) |
| 127 | + |
| 128 | + # In stacked mode, let Y auto-range; we’ll offset traces ourselves. |
| 129 | + if self.fixed_ylim is not None and not self.stacked: |
| 130 | + self.plot.setYRange(self.fixed_ylim[0], self.fixed_ylim[1]) |
| 131 | + |
| 132 | + return True |
| 133 | + |
| 134 | + def _resample_for_display(self, t, y): |
| 135 | + if self.resample_hz is None or t.size < 4: |
| 136 | + return t, y |
| 137 | + t0 = max(t[0], t[-1] - self.window_s) |
| 138 | + n_pts = max(64, int(self.window_s * self.resample_hz)) |
| 139 | + t_uniform = np.linspace(t0, t[-1], n_pts, dtype=np.float64) |
| 140 | + y_out = np.empty((y.shape[0], n_pts), dtype=y.dtype) |
| 141 | + for i in range(y.shape[0]): |
| 142 | + y_out[i] = np.interp(t_uniform, t, y[i]) |
| 143 | + return t_uniform, y_out |
| 144 | + |
| 145 | + def _apply_stacking(self, y_disp): |
| 146 | + """Return y_disp with vertical offsets per channel.""" |
| 147 | + n = y_disp.shape[0] |
| 148 | + if n <= 1: |
| 149 | + return y_disp, 0.0 |
| 150 | + |
| 151 | + if self.stack_spacing is None: |
| 152 | + # auto spacing based on robust span of all channels in view |
| 153 | + span = max(_robust_span(y_disp[i]) for i in range(n)) |
| 154 | + spacing = 1.25 * span # small gap |
| 155 | + else: |
| 156 | + spacing = float(self.stack_spacing) |
| 157 | + |
| 158 | + y_off = np.empty_like(y_disp) |
| 159 | + for i in range(n): |
| 160 | + y_off[i] = y_disp[i] + (n - 1 - i) * spacing # top-down stacking |
| 161 | + return y_off, spacing |
| 162 | + |
| 163 | + def _on_timer(self): |
| 164 | + if not self._ensure_curves(): |
| 165 | + return |
| 166 | + try: |
| 167 | + y, t = self.client.get_window(self.window_s) |
| 168 | + except NotReadyError: |
| 169 | + return |
| 170 | + if y.size == 0: |
| 171 | + return |
| 172 | + |
| 173 | + # optional decimation |
| 174 | + if self.downsample > 1: |
| 175 | + y = y[:, :: self.downsample] |
| 176 | + t = t[:: self.downsample] |
| 177 | + |
| 178 | + # resample for smooth visual motion |
| 179 | + t_disp, y_disp = self._resample_for_display(t, y) |
| 180 | + |
| 181 | + # stacked vs overlay |
| 182 | + if self.stacked and y_disp.shape[0] > 1: |
| 183 | + y_disp, spacing = self._apply_stacking(y_disp) |
| 184 | + # annotate left axis with stack step |
| 185 | + self.plot.setLabel("left", f"stacked (step={spacing:.3g})") |
| 186 | + else: |
| 187 | + self.plot.setLabel("left", "") |
| 188 | + |
| 189 | + # update curves |
| 190 | + for k, ch in enumerate(self.idx): |
| 191 | + self.curves[k].setData(t_disp, y_disp[k]) |
| 192 | + |
| 193 | + self.plot.setXRange(t_disp[0], t_disp[-1], padding=0.02) |
| 194 | + |
| 195 | +def main(argv=None): |
| 196 | + ap = argparse.ArgumentParser(description="Real-time LSL plot (overlay or stacked).") |
| 197 | + ap.add_argument("--stream_name", type=str, default=None) |
| 198 | + ap.add_argument("--stream_type", type=str, default="EMG") |
| 199 | + ap.add_argument("--channels", nargs="+", default=None, |
| 200 | + help='Channels: "all" | "0,1,2" | "0-7" | "0:7" | multiple tokens.') |
| 201 | + ap.add_argument("--window_s", type=float, default=5.0) |
| 202 | + ap.add_argument("--downsample", type=int, default=1) |
| 203 | + ap.add_argument("--ylim", type=float, nargs=2, default=None, help="Ignored in stacked mode") |
| 204 | + ap.add_argument("--timeout_s", type=float, default=5.0) |
| 205 | + ap.add_argument("--timer_ms", type=int, default=16) |
| 206 | + ap.add_argument("--precise_timer", action="store_true") |
| 207 | + ap.add_argument("--resample_hz", type=float, default=120.0) |
| 208 | + ap.add_argument("--opengl", action="store_true") |
| 209 | + ap.add_argument("--clip_to_view", action="store_true") |
| 210 | + ap.add_argument("--stacked", action="store_true", help="Draw channels with vertical offsets") |
| 211 | + ap.add_argument("--stack_spacing", type=float, default=None, help="Fixed spacing (signal units) between stacked traces") |
| 212 | + ap.add_argument("--verbose", action="store_true") |
| 213 | + args = ap.parse_args(argv) |
| 214 | + |
| 215 | + configure("INFO" if args.verbose else "WARNING") |
| 216 | + if args.opengl: |
| 217 | + pg.setConfigOptions(useOpenGL=True) |
| 218 | + |
| 219 | + chans = _parse_channels_arg(args.channels) |
| 220 | + |
| 221 | + app = QApplication(sys.argv) |
| 222 | + w = LSLViewer( |
| 223 | + stream_name=args.stream_name, |
| 224 | + stream_type=args.stream_type, |
| 225 | + window_s=args.window_s, |
| 226 | + downsample=args.downsample, |
| 227 | + ylim=tuple(args.ylim) if args.ylim is not None else None, |
| 228 | + channels=chans, |
| 229 | + timeout_s=args.timeout_s, |
| 230 | + timer_ms=args.timer_ms, |
| 231 | + precise_timer=args.precise_timer, |
| 232 | + resample_hz=args.resample_hz, |
| 233 | + clip_to_view=args.clip_to_view, |
| 234 | + stacked=args.stacked, |
| 235 | + stack_spacing=args.stack_spacing, |
| 236 | + verbose=args.verbose, |
| 237 | + ) |
| 238 | + w.resize(1000, 600) |
| 239 | + w.show() |
| 240 | + sys.exit(app.exec_()) |
| 241 | + |
| 242 | +if __name__ == "__main__": |
| 243 | + main() |
0 commit comments