Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 92 additions & 9 deletions examples/lsl_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
python lsl_viewer.py --name MyStream # resolve stream by name
python lsl_viewer.py --type EEG # resolve stream by type
python lsl_viewer.py --visible 32 # show 32 channels at a time
python lsl_viewer.py --scatter # scatter/heatmap (needs channel locations)

Requires: pylsl, phosphor
pip install pylsl phosphor
Expand All @@ -23,7 +24,7 @@
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication

from phosphor import SweepConfig, SweepWidget
from phosphor import ScatterConfig, ScatterWidget, SweepConfig, SweepWidget

# Map pylsl channel formats to numpy dtypes
_LSL_DTYPES = {
Expand Down Expand Up @@ -57,12 +58,69 @@ def resolve_stream(name: str | None, type_: str | None) -> pylsl.StreamInfo:
return results[0]


def parse_channel_info(
info: pylsl.StreamInfo,
) -> tuple[list[str] | None, np.ndarray | None]:
"""Parse channel labels and locations from LSL stream info.

Returns ``(labels, positions)`` where *positions* is ``(n_channels, 3)``
float32 or ``None`` if no locations are present.
"""
n_ch = info.channel_count()
chans = info.desc().child("channels")
if chans.empty():
return None, None

labels: list[str] = []
positions: list[list[float]] = []
has_locations = False

ch_elem = chans.first_child()
while not ch_elem.empty():
# Label
label_val = ch_elem.child("label").child_value()
labels.append(label_val if label_val else "")

# Location — <location><X>val</X><Y>val</Y><Z>val</Z></location>
loc_elem = ch_elem.child("location")
if not loc_elem.empty():
x_val = loc_elem.child("X").child_value()
y_val = loc_elem.child("Y").child_value()
z_val = loc_elem.child("Z").child_value()
x = float(x_val) if x_val else 0.0
y = float(y_val) if y_val else 0.0
z = float(z_val) if z_val else 0.0
positions.append([x, y, z])
if x != 0.0 or y != 0.0 or z != 0.0:
has_locations = True
else:
positions.append([0.0, 0.0, 0.0])

ch_elem = ch_elem.next_sibling()

if len(labels) != n_ch:
return None, None

# Fill empty labels with channel indices
for i, lbl in enumerate(labels):
if not lbl:
labels[i] = f"Ch {i}"

pos_array = np.array(positions, dtype=np.float32) if has_locations else None
return labels, pos_array


def main():
parser = argparse.ArgumentParser(description="LSL Viewer (phosphor)")
parser.add_argument("--name", type=str, default=None, help="Resolve stream by name")
parser.add_argument("--type", type=str, default=None, help="Resolve stream by type")
parser.add_argument("--dur", type=float, default=2.0, help="Display duration in seconds")
parser.add_argument("--visible", type=int, default=None, help="Visible channels (default: all)")
parser.add_argument(
"--scatter",
action="store_true",
help="Use scatter/heatmap view (requires channel locations in stream metadata)",
)
args = parser.parse_args()

app = QApplication(sys.argv)
Expand All @@ -79,21 +137,46 @@ def main():
max_buflen=int(max(args.dur * 2, 1)),
processing_flags=pylsl.proc_clocksync | pylsl.proc_dejitter,
)
inlet.open_stream()

# Retrieve full stream info (includes description XML with channel metadata)
full_info = inlet.info()
channel_labels, channel_positions = parse_channel_info(full_info)

if channel_labels:
print(f"Channel labels: {channel_labels[0]} … {channel_labels[-1]}")
if channel_positions is not None:
print(f"Channel locations: found for {n_channels} channels")

# Pre-allocate pull buffer matching the stream's native dtype
max_samples = max(1024, math.ceil(srate / 30) * 2)
stream_dtype = _LSL_DTYPES.get(info.channel_format(), np.float32)
pull_buffer = np.empty((max_samples, n_channels), dtype=stream_dtype, order="C")

config = SweepConfig(
n_channels=n_channels,
srate=srate,
display_dur=args.dur,
n_visible=n_visible,
)
widget = SweepWidget(config)
use_scatter = args.scatter
if use_scatter and channel_positions is None:
print("Warning: --scatter requested but no channel locations found; falling back to sweep view")
use_scatter = False

if use_scatter:
config = ScatterConfig(
positions=channel_positions,
channel_labels=channel_labels,
)
widget = ScatterWidget(config)
widget.resize(800, 800)
else:
config = SweepConfig(
n_channels=n_channels,
srate=srate,
display_dur=args.dur,
n_visible=n_visible,
channel_labels=channel_labels,
)
widget = SweepWidget(config)
widget.resize(1200, 800)

widget.setWindowTitle(f"LSL: {info.name()} ({n_channels}ch @ {srate}Hz)")
widget.resize(1200, 800)
widget.show()

def pull_and_push():
Expand Down
136 changes: 136 additions & 0 deletions examples/scatter_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python
"""
Scatter Demo — simulated scalp-map heatmap with the ScatterWidget.

Generates electrode positions on concentric rings (mimicking a 10-20 EEG
montage layout) and drives them with a rotating spatial wave so color and
size ripple outward from a wandering hotspot.

Usage:
python scatter_demo.py # 64 electrodes, color+size
python scatter_demo.py --channels 128 # more electrodes
python scatter_demo.py --no-size # color only
python scatter_demo.py --cmap plasma # different colormap
python scatter_demo.py --fixed-range 0 1 # fixed vmin/vmax

Requires: phosphor
pip install phosphor
"""

import argparse
import sys

import numpy as np
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication

from phosphor import ScatterConfig, ScatterWidget


def make_scalp_positions(n: int) -> np.ndarray:
"""Generate electrode positions on concentric rings like a scalp map.

Returns an (n, 2) array with positions roughly within a unit circle.
"""
positions = []

# Place one electrode at the center (Cz-like)
positions.append([0.0, 0.0])
remaining = n - 1

ring = 1
while remaining > 0:
radius = ring * 0.25
# More electrodes on outer rings
count = min(remaining, 6 * ring)
angles = np.linspace(0, 2 * np.pi, count, endpoint=False)
# Offset alternate rings for nicer packing
angles += (ring % 2) * np.pi / count
for a in angles:
positions.append([radius * np.cos(a), radius * np.sin(a)])
remaining -= count
ring += 1

return np.array(positions[:n], dtype=np.float32)


def make_electrode_labels(n: int) -> list[str]:
"""Generate electrode labels (E0, E1, …)."""
return [f"E{i}" for i in range(n)]


def main():
parser = argparse.ArgumentParser(description="Phosphor scatter/heatmap demo")
parser.add_argument("--channels", type=int, default=64, help="Number of electrodes")
parser.add_argument("--cmap", type=str, default="viridis", help="Colormap name")
parser.add_argument("--no-size", action="store_true", help="Disable size modulation")
parser.add_argument(
"--fixed-range",
type=float,
nargs=2,
metavar=("VMIN", "VMAX"),
default=None,
help="Fixed value range (default: autoscale)",
)
parser.add_argument("--marker-size", type=float, default=14.0, help="Base marker size")
parser.add_argument("--fps", type=float, default=60.0, help="Data push rate (Hz)")
args = parser.parse_args()

app = QApplication(sys.argv)

n_ch = args.channels
positions = make_scalp_positions(n_ch)
labels = make_electrode_labels(n_ch)

vmin = args.fixed_range[0] if args.fixed_range else None
vmax = args.fixed_range[1] if args.fixed_range else None

config = ScatterConfig(
positions=positions,
cmap=args.cmap,
modulate_color=True,
modulate_size=not args.no_size,
marker_size=args.marker_size,
size_range=(4.0, 24.0),
vmin=vmin,
vmax=vmax,
channel_labels=labels,
)

widget = ScatterWidget(config)
widget.setWindowTitle(f"Phosphor Scatter Demo — {n_ch} electrodes")
widget.resize(800, 800)
widget.show()

# --- Simulation state ---
t = 0.0
dt = 1.0 / args.fps

def push_data():
nonlocal t

# Hotspot wanders in a figure-8 (Lissajous) pattern
hx = 0.5 * np.sin(t * 0.7)
hy = 0.5 * np.sin(t * 1.1)

# Distance from each electrode to the hotspot
dx = positions[:, 0] - hx
dy = positions[:, 1] - hy
dist = np.sqrt(dx**2 + dy**2)

# Gaussian blob centered on the hotspot, plus a small amount of noise
values = np.exp(-(dist**2) / 0.15) + np.random.randn(n_ch).astype(np.float32) * 0.03

widget.push_data(values.astype(np.float32))
t += dt

timer = QTimer()
timer.timeout.connect(push_data)
timer.start(max(1, int(1000 / args.fps)))

app.aboutToQuit.connect(timer.stop)
sys.exit(app.exec())


if __name__ == "__main__":
main()
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ readme = "README.md"
requires-python = ">=3.11"
dynamic = ["version"]
dependencies = [
"fastplotlib>=0.6.1",
"numpy>=2.4.2",
"pyside6>=6.10.2",
"rendercanvas>=2.6.1",
"wgpu>=0.29.0",
]

[dependency-groups]
Expand Down
3 changes: 3 additions & 0 deletions src/phosphor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

from .__version__ import __version__ as __version__
from .channel_plot import ChannelPlotWidget
from .scatter_widget import ScatterConfig, ScatterWidget
from .spectrum_widget import SpectrumConfig, SpectrumWidget
from .sweep_widget import SweepConfig, SweepWidget

__all__ = [
"ChannelPlotWidget",
"ScatterConfig",
"ScatterWidget",
"SpectrumConfig",
"SpectrumWidget",
"SweepConfig",
Expand Down
37 changes: 34 additions & 3 deletions src/phosphor/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication

from phosphor import SpectrumConfig, SpectrumWidget, SweepConfig, SweepWidget
from phosphor import (
ScatterConfig,
ScatterWidget,
SpectrumConfig,
SpectrumWidget,
SweepConfig,
SweepWidget,
)


def main():
parser = argparse.ArgumentParser(description="Phosphor renderer demo")
parser.add_argument("--mode", choices=["sweep", "spectrum"], default="sweep", help="Render mode")
parser.add_argument("--mode", choices=["sweep", "spectrum", "scatter"], default="sweep", help="Render mode")
parser.add_argument("--channels", type=int, default=128, help="Total channels")
parser.add_argument("--srate", type=float, default=30000.0, help="Sample rate (Hz)")
parser.add_argument("--dur", type=float, default=2.0, help="Display duration (s)")
Expand All @@ -27,7 +34,31 @@ def main():
chunk_size = max(1, int(args.srate / 60)) # ~500 samples per timer tick
sample_counter = 0

if args.mode == "spectrum":
if args.mode == "scatter":
n_ch = args.channels
# Generate positions on a unit circle
angles = np.linspace(0, 2 * np.pi, n_ch, endpoint=False)
positions = np.column_stack([np.cos(angles), np.sin(angles)]).astype(np.float32)
labels = [f"E{i}" for i in range(n_ch)]
config = ScatterConfig(
positions=positions,
cmap="viridis",
modulate_color=True,
modulate_size=True,
channel_labels=labels,
)
widget = ScatterWidget(config)
widget.setWindowTitle("Phosphor Demo — Scatter")
phase = 0.0

def push_chunk():
nonlocal phase
# Rotating wave around the circle
values = (np.sin(angles + phase) * 0.5 + 0.5).astype(np.float32)
widget.push_data(values)
phase += 0.05

elif args.mode == "spectrum":
fft_size = int(args.srate)
n_bins = fft_size // 2
config = SpectrumConfig(
Expand Down
Loading
Loading