visualize_streams.py 12.1 KB
Newer Older
1
#!/usr/bin/env python
Marios Fanourakis's avatar
Marios Fanourakis committed
2 3 4 5 6 7
""" Lists and plots LSL streams

A basic QT interface to list available inlets and select the ones to plot.
Will plot all channels of a given data inlet in a separate plot.
All Marker Inlets are plotted on one plot.
Interface includes a button to update the inlet list.
8

Marios Fanourakis's avatar
Marios Fanourakis committed
9 10 11
This code has been edited from:
ReceiveAndPlot example available at
https://github.com/labstreaminglayer/liblsl-Python/tree/master/pylsl/examples
12
"""
Marios Fanourakis's avatar
Marios Fanourakis committed
13 14 15
import sys
from typing import List
import math
16 17 18 19

import numpy as np
import pylsl
import pyqtgraph as pg
Marios Fanourakis's avatar
Marios Fanourakis committed
20 21 22 23
from pyqtgraph.Qt import QtCore, QtGui, QtWidgets


__author__ = "Marios Fanourakis"
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46

# Basic parameters for the plotting window
plot_duration = 10  # how many seconds of data to show
update_interval = 30  # ms between screen updates
pull_interval = 50  # ms between each pull operation


class Inlet:
    """Base class to represent a plottable inlet"""
    def __init__(self, info: pylsl.StreamInfo):
        # create an inlet and connect it to the outlet we found earlier.
        # max_buflen is set so data older the plot_duration is discarded
        # automatically and we only pull data new enough to show it

        # Also, perform online clock synchronization so all streams are in the
        # same time domain as the local lsl_clock()
        # (see https://labstreaminglayer.readthedocs.io/projects/liblsl/ref/enums.html#_CPPv414proc_clocksync)
        # and dejitter timestamps
        self.inlet = pylsl.StreamInlet(info, max_buflen=plot_duration,
                                       processing_flags=pylsl.proc_clocksync | pylsl.proc_dejitter)
        # store the name and channel count
        self.name = info.name()
        self.channel_count = info.channel_count()
47
        self.uid = info.uid()
48 49 50
        self.plt_ixs: List[int] = []
        self.curves = None
        self.enabled = False
51

52 53 54 55
    def set_enabled(self, enabled: bool):
        self.enabled = enabled

    def pull_and_plot(self, plot_time: float, gl: pg.GraphicsLayout, plts: [pg.PlotItem]):
56 57
        """Pull data from the inlet and add it to the plot.
        :param plot_time: lowest timestamp that's still visible in the plot
58
        :param plts: the plot the data should be shown on
59
        """
60 61 62 63 64
        if not self.enabled:
            return
        else:
            # We don't know what to do with a generic inlet, so we skip it.
            pass
65 66 67 68 69 70 71


class DataInlet(Inlet):
    """A DataInlet represents an inlet with continuous, multi-channel data that
    should be plotted as multiple lines."""
    dtypes = [[], np.float32, np.float64, None, np.int32, np.int16, np.int8, np.int64]

72
    def __init__(self, info: pylsl.StreamInfo):
73 74 75 76
        super().__init__(info)
        # calculate the size for our buffer, i.e. two times the displayed data
        bufsize = (2 * math.ceil(info.nominal_srate() * plot_duration), info.channel_count())
        self.buffer = np.empty(bufsize, dtype=self.dtypes[info.channel_format()])
77 78 79 80 81 82 83 84 85 86 87 88 89 90

    def pull_and_plot(self, plot_time, gl: pg.GraphicsLayout, plts: [pg.PlotItem]):
        if not self.enabled:
            return
        if len(self.plt_ixs) == 0:
            empty = np.array([])
            # create one curve object for each channel/line that will handle displaying the data
            self.curves = [pg.PlotCurveItem(x=empty, y=empty, autoDownsample=True) for _ in range(self.channel_count)]
            ch_ix = 0
            for curve in self.curves:
                gl.nextRow()
                plts.append(gl.addPlot())
                plts[-1].addItem(curve)
                plts[-1].setXLink(plts[0])
91 92 93
                plts[-2].hideAxis('bottom')
                plts[-1].hideAxis('top')
                plts[-1].setTitle(title=self.name + ' channel ' + str(ch_ix))
94 95 96
                self.plt_ixs.append(len(plts) - 1)
                ch_ix = ch_ix + 1

97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
        # pull the data
        _, ts = self.inlet.pull_chunk(timeout=0.0,
                                      max_samples=self.buffer.shape[0],
                                      dest_obj=self.buffer)
        # ts will be empty if no samples were pulled, a list of timestamps otherwise
        if ts:
            ts = np.asarray(ts)
            y = self.buffer[0:ts.size, :]
            this_x = None
            old_offset = 0
            new_offset = 0
            for ch_ix in range(self.channel_count):
                # we don't pull an entire screen's worth of data, so we have to
                # trim the old data and append the new data to it
                old_x, old_y = self.curves[ch_ix].getData()
                # the timestamps are identical for all channels, so we need to do
                # this calculation only once
                if ch_ix == 0:
                    # find the index of the first sample that's still visible,
                    # i.e. newer than the left border of the plot
                    old_offset = old_x.searchsorted(plot_time)
                    # same for the new data, in case we pulled more data than
                    # can be shown at once
                    new_offset = ts.searchsorted(plot_time)
                    # append new timestamps to the trimmed old timestamps
                    this_x = np.hstack((old_x[old_offset:], ts[new_offset:]))
                # append new data to the trimmed old data
                this_y = np.hstack((old_y[old_offset:], y[new_offset:, ch_ix] - ch_ix))
                # replace the old data
                self.curves[ch_ix].setData(this_x, this_y)


class MarkerInlet(Inlet):
    """A MarkerInlet shows events that happen sporadically as vertical lines"""
    def __init__(self, info: pylsl.StreamInfo):
        super().__init__(info)

134 135 136
    def pull_and_plot(self, plot_time, gl: pg.GraphicsLayout, plts: [pg.PlotItem]):
        if not self.enabled:
            return
137 138 139 140 141 142 143
        # TODO: purge old markers
        strings, timestamps = self.inlet.pull_chunk(0)
        if timestamps:
            for string, ts in zip(strings, timestamps):
                plts[0].addItem(pg.InfiniteLine(ts, angle=90, movable=False, label=string[0]))


144 145 146 147 148 149 150 151 152 153
class MainWindow(QtWidgets.QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.mainWidget = QtGui.QWidget()
        self.layout = QtGui.QGridLayout()
        self.mainWidget.setLayout(self.layout)

        self.plots = pg.GraphicsLayoutWidget()
154
        self.selectAllButton = QtGui.QPushButton('select/deselect all')
155
        self.checkTable = pg.CheckTable([''])
156
        self.updateButton = QtGui.QPushButton('Update inlets')
157

158 159 160 161
        self.layout.addWidget(self.selectAllButton, 0, 0)
        self.layout.addWidget(self.checkTable, 1, 0)
        self.layout.addWidget(self.updateButton, 2, 0)
        self.layout.addWidget(self.plots, 1, 1)
162 163 164 165

        self.setCentralWidget(self.mainWidget)


166
def main():
167 168 169 170 171 172
    app = QtWidgets.QApplication(sys.argv)
    win = MainWindow()
    win.show()

    # initialize with first plot (to display all marker data)
    plts: List[pg.PlotItem] = [win.plots.addPlot()]
173 174 175
    plts[-1].setTitle(title='All Inlets Marker Data')
    plts[-1].hideAxis('bottom')
    plts[-1].showAxis('top', show=True)
176 177

    inlets: List[Inlet] = []
178

179 180 181 182 183 184 185 186 187 188 189
    def remove_plots(inlet=None):
        if inlet is None:
            nonlocal inlets
            for inlet in inlets:
                for plt_ix in inlet.plt_ixs:
                    win.plots.removeItem(plts[plt_ix])
                inlet.plt_ixs = []
        else:
            for plt_ix in inlet.plt_ixs:
                win.plots.removeItem(plts[plt_ix])
            inlet.plt_ixs = []
190

191 192 193 194
    def update_inlets():
        # clear old inlet stuff
        nonlocal inlets
        for inlet in inlets:
195
            remove_plots(inlet)
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227
        rows = win.checkTable.rowNames.copy()
        for row in rows:
            win.checkTable.removeRow(row)

        inlets = []

        # firstly resolve all streams that could be shown
        print("looking for streams")
        streams = pylsl.resolve_streams()

        # iterate over found streams, creating specialized inlet objects that will
        # handle plotting the data
        for info in streams:
            if info.type() == 'Markers':
                if info.nominal_srate() != pylsl.IRREGULAR_RATE \
                        or info.channel_format() != pylsl.cf_string:
                    print('Invalid marker stream ' + info.name())
                print('Adding marker inlet: ' + info.name())
                inlets.append(MarkerInlet(info))
                # give unique name to inlet and add to checkbox table
                inlets[-1].name = str(len(inlets) - 1) + ' ' + inlets[-1].name
                win.checkTable.addRow(inlets[-1].name)
            elif info.nominal_srate() != pylsl.IRREGULAR_RATE \
                    and info.channel_format() != pylsl.cf_string:
                print('Adding data inlet: ' + info.name())
                inlets.append(DataInlet(info))
                # give unique name to inlet and add to checkbox table
                inlets[-1].name = str(len(inlets) - 1) + ' ' + inlets[-1].name
                win.checkTable.addRow(inlets[-1].name)
            else:
                print('Don\'t know what to do with stream ' + info.name())
        return
228 229 230 231 232 233 234 235 236

    def scroll():
        """Move the view so the data appears to scroll"""
        # We show data only up to a timepoint shortly before the current time
        # so new data doesn't suddenly appear in the middle of the plot
        fudge_factor = pull_interval * .002
        plot_time = pylsl.local_clock()
        plts[0].setXRange(plot_time - plot_duration + fudge_factor, plot_time - fudge_factor)

237
    def update_data(inlet=None):
238 239 240 241 242
        # Read data from the inlet. Use a timeout of 0.0 so we don't block GUI interaction.
        mintime = pylsl.local_clock() - plot_duration
        # call pull_and_plot for each inlet.
        # Special handling of inlet types (markers, continuous data) is done in
        # the different inlet classes.
243 244 245 246 247
        if inlet is None:
            nonlocal inlets
            for inlet in inlets:
                inlet.pull_and_plot(mintime, win.plots, plts)
        else:
248
            inlet.pull_and_plot(mintime, win.plots, plts)
249

250 251 252 253 254 255 256 257 258 259 260
    def on_check_change(row, col, state):
        if type(state) is int:
            if state > 0:
                state = True
            else:
                state = False
        elif type(state) is not bool:
            print('State is of unknown type!! ')
            return

        print("check changed: " + row + " " + str(state))
261
        nonlocal inlets
262 263 264 265
        for inlet in inlets:
            if inlet.name == row:
                if state:
                    inlet.set_enabled(True)
266
                    update_data(inlet)
267 268
                else:
                    inlet.set_enabled(False)
269 270 271 272 273
                    remove_plots(inlet)

    def on_select_all_button_click():
        is_all_selected = True
        state = win.checkTable.saveState()
274

275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
        # check if all are selected and if not, select
        for row in state['rows']:
            if not row[1]:
                is_all_selected = False
                row[1] = True

        # if all are selected, then deselect all
        if is_all_selected:
            for row in state['rows']:
                row[1] = False

        # update states on the checkmark table
        win.checkTable.restoreState(state)

    def on_update_button_click():
        # first update lsl inlets
291
        update_inlets()
292
        # then reapply the selections of the old inlets
293 294 295
        for row in win.checkTable.saveState()['rows']:
            on_check_change(row[0], None, row[1])

296
    # connect button events to relevant functions
297
    win.checkTable.sigStateChanged.connect(on_check_change)
298 299 300 301
    win.updateButton.clicked.connect(on_update_button_click)
    win.selectAllButton.clicked.connect(on_select_all_button_click)

    # initialize inlets and the inlet list
302 303
    update_inlets()

304 305 306 307 308 309 310
    # create a timer that will move the view every update_interval ms
    update_timer = QtCore.QTimer()
    update_timer.timeout.connect(scroll)
    update_timer.start(update_interval)

    # create a timer that will pull and add new data occasionally
    pull_timer = QtCore.QTimer()
311
    pull_timer.timeout.connect(update_data)
312 313
    pull_timer.start(pull_interval)

314
    sys.exit(app.exec_())
315 316 317 318


if __name__ == '__main__':
    main()