From 91b1274b63dded67b8addff40c0d2a2d47271519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Sat, 25 Jul 2026 07:48:04 +0200 Subject: [PATCH] docs(examples): two demos for the 2.4.0 multi-panel viewer features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channel_grid_demo.py already covers one viewer picking channels out of a multi-grid stream. These cover the arrangements 2.4.0 made possible: multi_panel_grids.py — a panel per electrode grid. Five 64-channel arrays of one 320-channel stream, each its own SignalViewer in a Grid(2, 3) cell, showing why both parameters are needed: widget_id keeps the five states apart (without it they resolve to one and render identically), and channel_scope is the hard restriction, so All/Invert, the N/total count, [Edit…] and shift-click all stay inside that panel's array. It passes initial_channels alongside the scope, since scope alone would open each panel on its array's first 16 channels. electrode_overview.py — the same five arrays as live spatial RMS maps plus a detail viewer, built on Heatmap's new vrange. All five share ONE colour range recomputed across all 320 channels, which is what makes them comparable; with per-map autoscaling a silent array renders exactly like a busy one. The source runs each array at a different amplitude and kills one contact on IN3, so the maps have something to reveal. Both use the wall-clock-paced synthetic source pattern from channel_grid_demo, so the acquire thread doesn't busy-loop generating data faster than fs. --- examples/synthetic/electrode_overview.py | 150 +++++++++++++++++++++++ examples/synthetic/multi_panel_grids.py | 138 +++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 examples/synthetic/electrode_overview.py create mode 100644 examples/synthetic/multi_panel_grids.py diff --git a/examples/synthetic/electrode_overview.py b/examples/synthetic/electrode_overview.py new file mode 100644 index 0000000..8073874 --- /dev/null +++ b/examples/synthetic/electrode_overview.py @@ -0,0 +1,150 @@ +"""Five electrode arrays at a glance: spatial RMS maps that are comparable. + +The overview counterpart to `multi_panel_grids.py`. Five 64-channel arrays +(IN1-IN5) of one 320-channel stream, each drawn as a live **spatial RMS +heatmap** in a `Grid(2, 3)` cell, with a full `SignalViewer` in the sixth for +temporal detail. + +Why maps rather than traces: 64 overlaid traces in a small tile is texture, not +signal. A map answers "which electrode is dead, loose or noisy?" instantly, +which is the question a bring-up check actually asks. + +The load-bearing detail is `Heatmap.ui(vrange=...)`: every map is given **one +shared colour range**, recomputed each frame across all 320 channels. Without +it each heatmap maps its own min/max, and a silent array renders exactly like a +busy one — which inverts the conclusion you would draw. + +Run with: + uv run python examples/synthetic/electrode_overview.py + +What to try: + * Watch the arrays: their amplitudes differ by design, and the shared scale + is what makes that visible. Comment out `vrange=` to see them all light + up identically. + * Use the detail viewer's `[Edit...]` grid to pick which electrodes to + trace; with several grids it tiles them near-square. +""" + +import time + +import numpy as np +from imgui_bundle import imgui + +from myogestic import App, Grid, Stream +from myogestic.stream import ChannelGrid, StreamInfo +from myogestic.widgets import Heatmap, SignalViewer + +N_GRIDS = 5 +GRID_ROWS, GRID_COLS = 8, 8 # 8x8 electrode block per array +CHANNELS_PER_GRID = GRID_ROWS * GRID_COLS +N_CHANNELS = N_GRIDS * CHANNELS_PER_GRID # 320 +LAYOUT_COLS = 3 # 3 across x 2 down: 5 maps + 1 detail panel +FS = 2048.0 +CHUNK = 64 +RMS_MS = 250.0 # short integration, so a map tracks contractions live + + +def _grid_cells(index: int) -> list[list[int]]: + """An 8x8 block of consecutive column indices for array `index`.""" + start = index * CHANNELS_PER_GRID + return [ + list(range(start + r * GRID_COLS, start + (r + 1) * GRID_COLS)) for r in range(GRID_ROWS) + ] + + +GRIDS = [ChannelGrid(f"IN{i + 1}", _grid_cells(i)) for i in range(N_GRIDS)] + +# Column index per electrode cell, precomputed once (-1 marks an empty cell). +# Turns the per-channel RMS vector into each array's 2-D map with one gather. +INDEX_MAPS = [ + np.array([[-1 if c is None else c for c in row] for row in g.cells], dtype=np.intp) + for g in GRIDS +] +HEATMAPS = [Heatmap(g.label, size=(-1, -1), label_fmt="%.2f") for g in GRIDS] + + +class _SyntheticArraySource: + """In-process 320-channel source (no LSL) as five 8x8 electrode arrays. + + Wall-clock paced (see `channel_grid_demo.py` for why). Each array runs at a + different amplitude, and one electrode is deliberately dead, so the map has + something to reveal. + """ + + def __init__(self) -> None: + self._pos = 0 + self._last_read_time: float | None = None + self._rng = np.random.default_rng(0) + self._scale = np.repeat( + np.geomspace(1.0, 0.15, N_GRIDS).astype(np.float32), CHANNELS_PER_GRID + ) + self._scale[CHANNELS_PER_GRID * 2 + 27] = 0.0 # a dead contact on IN3 + + def connect(self) -> StreamInfo: + return StreamInfo( + n_channels=N_CHANNELS, fs=FS, dtype=np.float32, channel_grids=GRIDS + ) + + def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: + now = time.perf_counter() + if self._last_read_time is None: + samples_due = CHUNK + else: + samples_due = int((now - self._last_read_time) * FS) + if samples_due < 1: + return None, None + self._last_read_time = now + + noise = self._rng.standard_normal((samples_due, N_CHANNELS)).astype(np.float32) + data = noise * self._scale[None, :] + ts = (self._pos + np.arange(samples_due, dtype=np.float64)) / FS + self._pos += samples_due + return data, ts + + def disconnect(self) -> None: + pass + + +app = App(f"Electrode overview - {N_GRIDS} x {CHANNELS_PER_GRID} ch @ {FS:.0f} Hz") +app.streams(Stream("emg", source=_SyntheticArraySource(), window_ms=2000, buffer_ms=10000)) +viewer = SignalViewer("emg") +grid = Grid(2, LAYOUT_COLS) + + +def _channel_rms(stream) -> np.ndarray | None: + """Per-channel RMS over the last `RMS_MS`, or None before any data lands.""" + data, _ = stream.get_window() # channels-first (n_channels, n_samples) + if data.size == 0: + return None + n = max(1, min(data.shape[1], int(RMS_MS / 1000.0 * FS))) + return np.sqrt(np.mean(np.square(data[:, -n:], dtype=np.float64), axis=1)) + + +@app.ui +def demo_ui(ctx): + stream = ctx.streams.get("emg") + rms = _channel_rms(stream) if stream is not None else None + # ONE range across every array: comparing them is the entire point, and + # per-map autoscaling would make a silent array look like a busy one. + vmax = float(rms.max()) if rms is not None and rms.size else 0.0 + vrange = (0.0, vmax if vmax > 0.0 else 1.0) + + for i, (g, heatmap, idx) in enumerate(zip(GRIDS, HEATMAPS, INDEX_MAPS, strict=True)): + with grid[divmod(i, LAYOUT_COLS)]: + if rms is None: + imgui.text_disabled(f"{g.label}: no data") + continue + if i == 0: + imgui.text_disabled(f"RMS 0-{vrange[1]:.2f} (shared across arrays)") + heatmap.ui(np.where(idx >= 0, rms[idx], 0.0), vrange=vrange) + + with grid[1, LAYOUT_COLS - 1]: # last cell: temporal detail + viewer.ui(ctx) + + +def main() -> None: + app.run() + + +if __name__ == "__main__": + main() diff --git a/examples/synthetic/multi_panel_grids.py b/examples/synthetic/multi_panel_grids.py new file mode 100644 index 0000000..504e64f --- /dev/null +++ b/examples/synthetic/multi_panel_grids.py @@ -0,0 +1,138 @@ +"""One stream, one viewer per electrode grid, tiled 3 x 2. + +Where `channel_grid_demo.py` shows *one* viewer picking channels out of a +multi-grid stream, this shows the other arrangement: a **panel per grid**. Five +64-channel arrays (IN1-IN5) of a single 320-channel stream, each with its own +`SignalViewer` in a `Grid(2, 3)` cell. + +Two parameters make that work, and both matter: + +* ``widget_id`` — viewer state and ImGui ids key off it instead of the stream, + so five viewers on ``"emg"`` keep separate channels, scale, filter and pause. + Without it they would all resolve to one state and render identically. +* ``channel_scope`` — the hard restriction: the columns a panel may *ever* + show. All / None / Invert, the ``N/total`` count, the ``[Edit...]`` grid and + shift-click ranges all stay inside that panel's array. + +Run with: + uv run python examples/synthetic/multi_panel_grids.py + +What to try: + * Hit **All** in one tile: it selects that array's 64 channels, and the + count reads ``64/64`` — not the stream's 320. (``initial_channels`` alone + could not do this: it only seeds the opening selection.) + * Open **[Edit...]** in a tile: only that array's grid is listed. + * Press the header's toggle to collapse a tile's chrome down to the plot. + * Note the tiles do **not** share a y-scale — each auto-scales alone, so do + not compare amplitudes across them by eye. Give them a common manual + range when that matters, or see `electrode_overview.py` for the + shared-scale spatial view. +""" + +import time + +import numpy as np + +from myogestic import App, Grid, Stream +from myogestic.stream import ChannelGrid, StreamInfo +from myogestic.widgets import SignalViewer +from myogestic.widgets.signals._channel_grid import auto_shape + +N_GRIDS = 5 +CHANNELS_PER_GRID = 64 +N_CHANNELS = N_GRIDS * CHANNELS_PER_GRID # 320 +LAYOUT_COLS = 3 # 3 across x 2 down: 5 tiles, one cell to spare +FS = 2048.0 +CHUNK = 64 +WINDOW_S = 1.0 # short on purpose: every viewer copies the buffer tail per frame + + +def _grid_columns(index: int) -> list[int]: + start = index * CHANNELS_PER_GRID + return list(range(start, start + CHANNELS_PER_GRID)) + + +class _SyntheticGridSource: + """In-process 320-channel source (no LSL) laid out as five electrode grids. + + Self-paced from wall clock like `myogestic.sources.replay.ReplaySource`: + ``read()`` returns ``(None, None)`` until real time has produced samples, + so the free-spinning acquire thread doesn't busy-loop a core generating + data far faster than ``FS``. Each array gets its own amplitude so the tiles + look visibly different. + """ + + def __init__(self) -> None: + self._pos = 0 + self._last_read_time: float | None = None + self._rng = np.random.default_rng(0) + # Distinct amplitude per array — also what makes the *absence* of a + # shared y-scale across tiles obvious. + self._scale = np.repeat( + np.geomspace(1.0, 0.15, N_GRIDS).astype(np.float32), CHANNELS_PER_GRID + ) + + def connect(self) -> StreamInfo: + return StreamInfo( + n_channels=N_CHANNELS, + fs=FS, + dtype=np.float32, + channel_grids=[ + ChannelGrid(f"IN{i + 1}", auto_shape(_grid_columns(i))) for i in range(N_GRIDS) + ], + ) + + def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: + now = time.perf_counter() + if self._last_read_time is None: + samples_due = CHUNK + else: + samples_due = int((now - self._last_read_time) * FS) + if samples_due < 1: + return None, None + self._last_read_time = now + + noise = self._rng.standard_normal((samples_due, N_CHANNELS)).astype(np.float32) + data = noise * self._scale[None, :] + ts = (self._pos + np.arange(samples_due, dtype=np.float64)) / FS + self._pos += samples_due + return data, ts + + def disconnect(self) -> None: + pass + + +app = App(f"Multi-panel grids - {N_GRIDS} x {CHANNELS_PER_GRID} ch @ {FS:.0f} Hz") +app.streams(Stream("emg", source=_SyntheticGridSource(), window_ms=2000, buffer_ms=10000)) + +# `channel_scope` restricts; `initial_channels` seeds. Both are passed because +# scope alone would open each panel on its array's FIRST 16 channels (the +# >32-channel default policy, applied within the scope), not all 64. +viewers = [ + SignalViewer( + "emg", + widget_id=f"emg:grid{i}", + title=f"IN{i + 1} - {CHANNELS_PER_GRID} ch", + channel_scope=_grid_columns(i), + initial_channels=_grid_columns(i), + show_controls=False, + window_s=WINDOW_S, + ) + for i in range(N_GRIDS) +] +grid = Grid(2, LAYOUT_COLS) + + +@app.ui +def demo_ui(ctx): + for i, viewer in enumerate(viewers): + with grid[divmod(i, LAYOUT_COLS)]: + viewer.ui(ctx) + + +def main() -> None: + app.run() + + +if __name__ == "__main__": + main()