1+ #!/usr/bin/env python
2+ """
3+ ReceiveAndPlot example for LSL
4+
5+ This example shows data from all found outlets in realtime.
6+ It illustrates the following use cases:
7+ - efficiently pulling data, re-using buffers
8+ - automatically discarding older samples
9+ - online postprocessing
10+ """
11+
12+ import math
13+ from typing import List
14+
15+ import numpy as np
16+ import pyqtgraph as pg
17+ from pyqtgraph .Qt import QtCore , QtGui
18+
19+ import pylsl
20+
21+ # Basic parameters for the plotting window
22+ plot_duration = 5 # how many seconds of data to show
23+ update_interval = 60 # ms between screen updates
24+ pull_interval = 500 # ms between each pull operation
25+
26+
27+ class Inlet :
28+ """Base class to represent a plottable inlet"""
29+
30+ def __init__ (self , info : pylsl .StreamInfo ):
31+ # create an inlet and connect it to the outlet we found earlier.
32+ # max_buflen is set so data older the plot_duration is discarded
33+ # automatically and we only pull data new enough to show it
34+
35+ # Also, perform online clock synchronization so all streams are in the
36+ # same time domain as the local lsl_clock()
37+ # (see https://labstreaminglayer.readthedocs.io/projects/liblsl/ref/enums.html#_CPPv414proc_clocksync)
38+ # and dejitter timestamps
39+ self .inlet = pylsl .StreamInlet (
40+ info ,
41+ max_buflen = plot_duration ,
42+ processing_flags = pylsl .proc_clocksync | pylsl .proc_dejitter ,
43+ )
44+ # store the name and channel count
45+ self .name = info .name ()
46+ self .channel_count = info .channel_count ()
47+
48+ def pull_and_plot (self , plot_time : float , plt : pg .PlotItem ):
49+ """Pull data from the inlet and add it to the plot.
50+ :param plot_time: lowest timestamp that's still visible in the plot
51+ :param plt: the plot the data should be shown on
52+ """
53+ # We don't know what to do with a generic inlet, so we skip it.
54+ pass
55+
56+
57+ class DataInlet (Inlet ):
58+ """A DataInlet represents an inlet with continuous, multi-channel data that
59+ should be plotted as multiple lines."""
60+
61+ dtypes = [[], np .float32 , np .float64 , None , np .int32 , np .int16 , np .int8 , np .int64 ]
62+
63+ def __init__ (self , info : pylsl .StreamInfo , plt : pg .PlotItem ):
64+ super ().__init__ (info )
65+ # calculate the size for our buffer, i.e. two times the displayed data
66+ bufsize = (
67+ 2 * math .ceil (info .nominal_srate () * plot_duration ),
68+ info .channel_count (),
69+ )
70+ self .buffer = np .empty (bufsize , dtype = self .dtypes [info .channel_format ()])
71+ empty = np .array ([])
72+ # create one curve object for each channel/line that will handle displaying the data
73+ self .curves = [
74+ pg .PlotCurveItem (x = empty , y = empty , autoDownsample = True )
75+ for _ in range (self .channel_count )
76+ ]
77+ for curve in self .curves :
78+ plt .addItem (curve )
79+
80+ def pull_and_plot (self , plot_time , plt ):
81+ # pull the data
82+ _ , ts = self .inlet .pull_chunk (
83+ timeout = 0.0 , max_samples = self .buffer .shape [0 ], dest_obj = self .buffer
84+ )
85+ # ts will be empty if no samples were pulled, a list of timestamps otherwise
86+ if ts :
87+ ts = np .asarray (ts )
88+ y = self .buffer [0 : ts .size , :]
89+ this_x = None
90+ old_offset = 0
91+ new_offset = 0
92+ for ch_ix in range (self .channel_count ):
93+ # we don't pull an entire screen's worth of data, so we have to
94+ # trim the old data and append the new data to it
95+ old_x , old_y = self .curves [ch_ix ].getData ()
96+ # the timestamps are identical for all channels, so we need to do
97+ # this calculation only once
98+ if ch_ix == 0 :
99+ # find the index of the first sample that's still visible,
100+ # i.e. newer than the left border of the plot
101+ old_offset = old_x .searchsorted (plot_time )
102+ # same for the new data, in case we pulled more data than
103+ # can be shown at once
104+ new_offset = ts .searchsorted (plot_time )
105+ # append new timestamps to the trimmed old timestamps
106+ this_x = np .hstack ((old_x [old_offset :], ts [new_offset :]))
107+ # append new data to the trimmed old data
108+ this_y = np .hstack ((old_y [old_offset :], y [new_offset :, ch_ix ] - ch_ix ))
109+ # replace the old data
110+ self .curves [ch_ix ].setData (this_x , this_y )
111+
112+
113+ class MarkerInlet (Inlet ):
114+ """A MarkerInlet shows events that happen sporadically as vertical lines"""
115+
116+ def __init__ (self , info : pylsl .StreamInfo ):
117+ super ().__init__ (info )
118+
119+ def pull_and_plot (self , plot_time , plt ):
120+ # TODO: purge old markers
121+ strings , timestamps = self .inlet .pull_chunk (0 )
122+ if timestamps :
123+ for string , ts in zip (strings , timestamps ):
124+ plt .addItem (
125+ pg .InfiniteLine (ts , angle = 90 , movable = False , label = string [0 ])
126+ )
127+
128+
129+ def main ():
130+ # firstly resolve all streams that could be shown
131+ inlets : List [Inlet ] = []
132+ print ("looking for streams" )
133+ streams = pylsl .resolve_streams ()
134+
135+ # Create the pyqtgraph window
136+ pw = pg .plot (title = "LSL Plot" )
137+ plt = pw .getPlotItem ()
138+ plt .enableAutoRange (x = False , y = True )
139+
140+ # iterate over found streams, creating specialized inlet objects that will
141+ # handle plotting the data
142+ for info in streams :
143+ if info .type () == "Markers" :
144+ if (
145+ info .nominal_srate () != pylsl .IRREGULAR_RATE
146+ or info .channel_format () != pylsl .cf_string
147+ ):
148+ print ("Invalid marker stream " + info .name ())
149+ print ("Adding marker inlet: " + info .name ())
150+ inlets .append (MarkerInlet (info ))
151+ elif (
152+ info .nominal_srate () != pylsl .IRREGULAR_RATE
153+ and info .channel_format () != pylsl .cf_string
154+ ):
155+ print ("Adding data inlet: " + info .name ())
156+ inlets .append (DataInlet (info , plt ))
157+ else :
158+ print ("Don't know what to do with stream " + info .name ())
159+
160+ def scroll ():
161+ """Move the view so the data appears to scroll"""
162+ # We show data only up to a timepoint shortly before the current time
163+ # so new data doesn't suddenly appear in the middle of the plot
164+ fudge_factor = pull_interval * 0.002
165+ plot_time = pylsl .local_clock ()
166+ pw .setXRange (plot_time - plot_duration + fudge_factor , plot_time - fudge_factor )
167+
168+ def update ():
169+ # Read data from the inlet. Use a timeout of 0.0 so we don't block GUI interaction.
170+ mintime = pylsl .local_clock () - plot_duration
171+ # call pull_and_plot for each inlet.
172+ # Special handling of inlet types (markers, continuous data) is done in
173+ # the different inlet classes.
174+ for inlet in inlets :
175+ inlet .pull_and_plot (mintime , plt )
176+
177+ # create a timer that will move the view every update_interval ms
178+ update_timer = QtCore .QTimer ()
179+ update_timer .timeout .connect (scroll )
180+ update_timer .start (update_interval )
181+
182+ # create a timer that will pull and add new data occasionally
183+ pull_timer = QtCore .QTimer ()
184+ pull_timer .timeout .connect (update )
185+ pull_timer .start (pull_interval )
186+
187+ import sys
188+
189+ # Start Qt event loop unless running in interactive mode or using pyside.
190+ if (sys .flags .interactive != 1 ) or not hasattr (QtCore , "PYQT_VERSION" ):
191+ QtGui .QGuiApplication .instance ().exec ()
192+
193+
194+ if __name__ == "__main__" :
195+ main ()
0 commit comments