From d513ca347e57bf95742de1757503daf6750491ae Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:36:38 -0400 Subject: [PATCH 01/14] style: drop createUI.bat --- createUI.bat | 1 - 1 file changed, 1 deletion(-) delete mode 100644 createUI.bat diff --git a/createUI.bat b/createUI.bat deleted file mode 100644 index fd22c46..0000000 --- a/createUI.bat +++ /dev/null @@ -1 +0,0 @@ -"C:\Users\Yida Lin\AppData\Local\Programs\Python\Python37\Scripts\pyuic5.exe" -x ui_sigvisualizer.ui -o ui_sigvisualizer.py \ No newline at end of file From b89c05d26688dd30ffdd7ccb5b36690e9cac0a96 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:21:49 -0400 Subject: [PATCH 02/14] feat: pyproject --- pyproject.toml | 24 ++++++++++++++++++++++++ sigvisualizer.py | 6 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc4977a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "sigvisualizer" +version = "0.1.0" +description = "Real-time PyQt5 GUI for visualizing EEG signals streamed via Lab Streaming Layer (LSL)" +readme = "README.md" +license = { text = "GPL-3.0-or-later" } +requires-python = ">=3.8" +dependencies = [ + "PyQt5", + "pylsl", +] + +[project.urls] +Homepage = "https://github.com/labstreaminglayer/App-SigVisualizer" + +[project.scripts] +sigvisualizer = "sigvisualizer:main" + +[tool.setuptools] +py-modules = ["sigvisualizer", "paintwidget", "ui_sigvisualizer", "LSL_sender"] diff --git a/sigvisualizer.py b/sigvisualizer.py index e31062b..f0e03f4 100644 --- a/sigvisualizer.py +++ b/sigvisualizer.py @@ -75,8 +75,12 @@ def toggle_panel(self): self.ui.toggleButton.setIconSize(QSize(30, 30)) -if __name__ == '__main__': +def main(): app = QApplication(sys.argv) window = SigVisualizer() window.show() sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() From c68773e12e425c83764247f4febdf34f30037957 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:24:53 -0400 Subject: [PATCH 03/14] feat: standard python project structure --- README.md | 2 +- .../SigVisualizer_demo.gif | Bin pyproject.toml | 10 ++++++++-- LSL_sender.py => scripts/LSL_sender.py | 0 src/sigvisualizer/__init__.py | 6 ++++++ sigvisualizer.py => src/sigvisualizer/app.py | 13 ++++++++----- paintwidget.py => src/sigvisualizer/paintwidget.py | 0 .../resources/icons}/chevron_left.svg | 0 .../resources/icons}/chevron_right.svg | 0 .../sigvisualizer/resources/sigvisualizer.ico | Bin .../sigvisualizer/resources/sigvisualizer.png | Bin .../sigvisualizer/resources/ui_sigvisualizer.ui | 0 .../sigvisualizer/ui_sigvisualizer.py | 2 +- 13 files changed, 24 insertions(+), 9 deletions(-) rename SigVisualizer_demo.gif => docs/SigVisualizer_demo.gif (100%) rename LSL_sender.py => scripts/LSL_sender.py (100%) create mode 100644 src/sigvisualizer/__init__.py rename sigvisualizer.py => src/sigvisualizer/app.py (84%) rename paintwidget.py => src/sigvisualizer/paintwidget.py (100%) rename {icons => src/sigvisualizer/resources/icons}/chevron_left.svg (100%) rename {icons => src/sigvisualizer/resources/icons}/chevron_right.svg (100%) rename sigvisualizer.ico => src/sigvisualizer/resources/sigvisualizer.ico (100%) rename sigvisualizer.png => src/sigvisualizer/resources/sigvisualizer.png (100%) rename ui_sigvisualizer.ui => src/sigvisualizer/resources/ui_sigvisualizer.ui (100%) rename ui_sigvisualizer.py => src/sigvisualizer/ui_sigvisualizer.py (98%) diff --git a/README.md b/README.md index a3baa69..6bce814 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,4 @@ SigVisualizer is a PyQt5 GUI application that visualizes electroencephalogram si * Python 3.x * PyQt5 -![image](SigVisualizer_demo.gif) +![image](docs/SigVisualizer_demo.gif) diff --git a/SigVisualizer_demo.gif b/docs/SigVisualizer_demo.gif similarity index 100% rename from SigVisualizer_demo.gif rename to docs/SigVisualizer_demo.gif diff --git a/pyproject.toml b/pyproject.toml index fc4977a..72fa45b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,13 @@ dependencies = [ Homepage = "https://github.com/labstreaminglayer/App-SigVisualizer" [project.scripts] -sigvisualizer = "sigvisualizer:main" +sigvisualizer = "sigvisualizer.app:main" [tool.setuptools] -py-modules = ["sigvisualizer", "paintwidget", "ui_sigvisualizer", "LSL_sender"] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +sigvisualizer = ["resources/*.ico", "resources/*.png", "resources/*.ui", "resources/icons/*.svg"] diff --git a/LSL_sender.py b/scripts/LSL_sender.py similarity index 100% rename from LSL_sender.py rename to scripts/LSL_sender.py diff --git a/src/sigvisualizer/__init__.py b/src/sigvisualizer/__init__.py new file mode 100644 index 0000000..26ceeab --- /dev/null +++ b/src/sigvisualizer/__init__.py @@ -0,0 +1,6 @@ +from importlib.metadata import version, PackageNotFoundError + +try: + __version__ = version("sigvisualizer") +except PackageNotFoundError: + __version__ = "0.0.0" diff --git a/sigvisualizer.py b/src/sigvisualizer/app.py similarity index 84% rename from sigvisualizer.py rename to src/sigvisualizer/app.py index f0e03f4..4c9d121 100644 --- a/sigvisualizer.py +++ b/src/sigvisualizer/app.py @@ -1,11 +1,14 @@ import sys +from pathlib import Path from PyQt5.QtCore import QSize, Qt, pyqtSignal from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import (QApplication, QMainWindow, QStatusBar, QTreeWidgetItem) -from ui_sigvisualizer import Ui_MainWindow +from .ui_sigvisualizer import Ui_MainWindow + +RESOURCES_DIR = Path(__file__).parent / "resources" class SigVisualizer(QMainWindow): @@ -17,12 +20,12 @@ def __init__(self): self.ui.setupUi(self) self.setWindowTitle('Real Time Signal Visualizer') self.ui.treeWidget.setHeaderLabel('Streams') - self.setWindowIcon(QIcon('sigvisualizer.ico')) + self.setWindowIcon(QIcon(str(RESOURCES_DIR / "sigvisualizer.ico"))) self.statusBar = QStatusBar() self.setStatusBar(self.statusBar) - self.ui.toggleButton.setIcon(QIcon("icons/chevron_left.svg")) + self.ui.toggleButton.setIcon(QIcon(str(RESOURCES_DIR / "icons" / "chevron_left.svg"))) self.ui.toggleButton.setIconSize(QSize(30, 30)) self.ui.toggleButton.clicked.connect(self.toggle_panel) @@ -65,13 +68,13 @@ def toggle_panel(self): self.panelHidden = False self.ui.treeWidget.show() self.ui.updateButton.show() - self.ui.toggleButton.setIcon(QIcon("icons/chevron_left.svg")) + self.ui.toggleButton.setIcon(QIcon(str(RESOURCES_DIR / "icons" / "chevron_left.svg"))) self.ui.toggleButton.setIconSize(QSize(30, 30)) else: self.panelHidden = True self.ui.treeWidget.hide() self.ui.updateButton.hide() - self.ui.toggleButton.setIcon(QIcon("icons/chevron_right.svg")) + self.ui.toggleButton.setIcon(QIcon(str(RESOURCES_DIR / "icons" / "chevron_right.svg"))) self.ui.toggleButton.setIconSize(QSize(30, 30)) diff --git a/paintwidget.py b/src/sigvisualizer/paintwidget.py similarity index 100% rename from paintwidget.py rename to src/sigvisualizer/paintwidget.py diff --git a/icons/chevron_left.svg b/src/sigvisualizer/resources/icons/chevron_left.svg similarity index 100% rename from icons/chevron_left.svg rename to src/sigvisualizer/resources/icons/chevron_left.svg diff --git a/icons/chevron_right.svg b/src/sigvisualizer/resources/icons/chevron_right.svg similarity index 100% rename from icons/chevron_right.svg rename to src/sigvisualizer/resources/icons/chevron_right.svg diff --git a/sigvisualizer.ico b/src/sigvisualizer/resources/sigvisualizer.ico similarity index 100% rename from sigvisualizer.ico rename to src/sigvisualizer/resources/sigvisualizer.ico diff --git a/sigvisualizer.png b/src/sigvisualizer/resources/sigvisualizer.png similarity index 100% rename from sigvisualizer.png rename to src/sigvisualizer/resources/sigvisualizer.png diff --git a/ui_sigvisualizer.ui b/src/sigvisualizer/resources/ui_sigvisualizer.ui similarity index 100% rename from ui_sigvisualizer.ui rename to src/sigvisualizer/resources/ui_sigvisualizer.ui diff --git a/ui_sigvisualizer.py b/src/sigvisualizer/ui_sigvisualizer.py similarity index 98% rename from ui_sigvisualizer.py rename to src/sigvisualizer/ui_sigvisualizer.py index 530c9b1..342f03f 100644 --- a/ui_sigvisualizer.py +++ b/src/sigvisualizer/ui_sigvisualizer.py @@ -59,7 +59,7 @@ def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) self.updateButton.setText(_translate("MainWindow", "Update Streams")) -from paintwidget import PaintWidget +from .paintwidget import PaintWidget if __name__ == "__main__": import sys From d54127437c991929cf68526722d656d934077100 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:26:24 -0400 Subject: [PATCH 04/14] feat: use importlib for resources --- pyproject.toml | 2 +- src/sigvisualizer/app.py | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 72fa45b..432e5e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Real-time PyQt5 GUI for visualizing EEG signals streamed via Lab Streaming Layer (LSL)" readme = "README.md" license = { text = "GPL-3.0-or-later" } -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "PyQt5", "pylsl", diff --git a/src/sigvisualizer/app.py b/src/sigvisualizer/app.py index 4c9d121..91d6e86 100644 --- a/src/sigvisualizer/app.py +++ b/src/sigvisualizer/app.py @@ -1,5 +1,7 @@ +import atexit import sys -from pathlib import Path +from contextlib import ExitStack +from importlib import resources from PyQt5.QtCore import QSize, Qt, pyqtSignal from PyQt5.QtGui import QIcon @@ -8,7 +10,13 @@ from .ui_sigvisualizer import Ui_MainWindow -RESOURCES_DIR = Path(__file__).parent / "resources" +_resource_stack = ExitStack() +atexit.register(_resource_stack.close) + + +def _resource_path(*parts): + ref = resources.files("sigvisualizer").joinpath("resources", *parts) + return str(_resource_stack.enter_context(resources.as_file(ref))) class SigVisualizer(QMainWindow): @@ -20,12 +28,12 @@ def __init__(self): self.ui.setupUi(self) self.setWindowTitle('Real Time Signal Visualizer') self.ui.treeWidget.setHeaderLabel('Streams') - self.setWindowIcon(QIcon(str(RESOURCES_DIR / "sigvisualizer.ico"))) + self.setWindowIcon(QIcon(_resource_path("sigvisualizer.ico"))) self.statusBar = QStatusBar() self.setStatusBar(self.statusBar) - self.ui.toggleButton.setIcon(QIcon(str(RESOURCES_DIR / "icons" / "chevron_left.svg"))) + self.ui.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_left.svg"))) self.ui.toggleButton.setIconSize(QSize(30, 30)) self.ui.toggleButton.clicked.connect(self.toggle_panel) @@ -68,13 +76,13 @@ def toggle_panel(self): self.panelHidden = False self.ui.treeWidget.show() self.ui.updateButton.show() - self.ui.toggleButton.setIcon(QIcon(str(RESOURCES_DIR / "icons" / "chevron_left.svg"))) + self.ui.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_left.svg"))) self.ui.toggleButton.setIconSize(QSize(30, 30)) else: self.panelHidden = True self.ui.treeWidget.hide() self.ui.updateButton.hide() - self.ui.toggleButton.setIcon(QIcon(str(RESOURCES_DIR / "icons" / "chevron_right.svg"))) + self.ui.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_right.svg"))) self.ui.toggleButton.setIconSize(QSize(30, 30)) From 89783210225b033bee3a6311ca7bd8cf9a7a8191 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:30:34 -0400 Subject: [PATCH 05/14] feat: switch to qtpy for Qt 6 support with Qt 5 backwards compatibility --- README.md | 6 +++--- pyproject.toml | 10 ++++++++-- src/sigvisualizer/app.py | 12 ++++++------ src/sigvisualizer/paintwidget.py | 18 +++++++++--------- src/sigvisualizer/ui_sigvisualizer.py | 8 ++++---- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6bce814..8d35959 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # SigVisualizer -SigVisualizer is a PyQt5 GUI application that visualizes electroencephalogram signals streamed from [__lab streaming layer__ (LSL)](https://github.com/sccn/labstreaminglayer "LabStreamingLayer (LSL)") in real time. +SigVisualizer is a Qt GUI application that visualizes electroencephalogram signals streamed from [__lab streaming layer__ (LSL)](https://github.com/sccn/labstreaminglayer "LabStreamingLayer (LSL)") in real time. ## Requirements -* Python 3.x -* PyQt5 +* Python 3.9+ +* One of: PyQt5, PyQt6, PySide2, PySide6 ![image](docs/SigVisualizer_demo.gif) diff --git a/pyproject.toml b/pyproject.toml index 432e5e4..678b331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,15 +5,21 @@ build-backend = "setuptools.build_meta" [project] name = "sigvisualizer" version = "0.1.0" -description = "Real-time PyQt5 GUI for visualizing EEG signals streamed via Lab Streaming Layer (LSL)" +description = "Real-time Qt GUI for visualizing EEG signals streamed via Lab Streaming Layer (LSL)" readme = "README.md" license = { text = "GPL-3.0-or-later" } requires-python = ">=3.9" dependencies = [ - "PyQt5", + "qtpy>=2.4", "pylsl", ] +[project.optional-dependencies] +pyqt5 = ["PyQt5"] +pyqt6 = ["PyQt6"] +pyside2 = ["PySide2"] +pyside6 = ["PySide6"] + [project.urls] Homepage = "https://github.com/labstreaminglayer/App-SigVisualizer" diff --git a/src/sigvisualizer/app.py b/src/sigvisualizer/app.py index 91d6e86..8e580fa 100644 --- a/src/sigvisualizer/app.py +++ b/src/sigvisualizer/app.py @@ -3,9 +3,9 @@ from contextlib import ExitStack from importlib import resources -from PyQt5.QtCore import QSize, Qt, pyqtSignal -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import (QApplication, QMainWindow, QStatusBar, +from qtpy.QtCore import QSize, Qt, Signal +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import (QApplication, QMainWindow, QStatusBar, QTreeWidgetItem) from .ui_sigvisualizer import Ui_MainWindow @@ -20,7 +20,7 @@ def _resource_path(*parts): class SigVisualizer(QMainWindow): - stream_expanded = pyqtSignal(str) + stream_expanded = Signal(str) def __init__(self): super().__init__() @@ -62,7 +62,7 @@ def update_metadata_widget(self, metadata, default_idx): for m in range(s_meta["ch_count"]): channel_item = QTreeWidgetItem(item) channel_item.setText(0, 'Channel {}'.format(m+1)) - channel_item.setCheckState(0, Qt.Checked) + channel_item.setCheckState(0, Qt.CheckState.Checked) item.setExpanded(True if s_ix == default_idx else False) self.ui.treeWidget.addTopLevelItem(item) @@ -90,7 +90,7 @@ def main(): app = QApplication(sys.argv) window = SigVisualizer() window.show() - sys.exit(app.exec_()) + sys.exit(app.exec()) if __name__ == '__main__': diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index 10a5967..b95d859 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -1,6 +1,6 @@ -from PyQt5.QtCore import QThread, Qt, pyqtSignal -from PyQt5.QtGui import QPalette, QPainter, QPen -from PyQt5.QtWidgets import QWidget +from qtpy.QtCore import QThread, Qt, Signal +from qtpy.QtGui import QPalette, QPainter, QPen +from qtpy.QtWidgets import QWidget import pylsl import math import copy @@ -10,9 +10,9 @@ class DataThread(QThread): - updateStreamNames = pyqtSignal(list, int) - sendData = pyqtSignal(list, list, list, list) - changedStream = pyqtSignal() + updateStreamNames = Signal(list, int) + sendData = Signal(list, list, list, list) + changedStream = Signal() def_stream_parms = {'chunk_idx': 0, 'metadata': {}, 'srate': None, 'chunkSize': None, 'downSampling': None, 'downSamplingFactor': None, 'downSamplingBuffer': None, @@ -103,7 +103,7 @@ def __init__(self, widget): super().__init__() self.reset() pal = QPalette() - pal.setColor(QPalette.Background, Qt.white) + pal.setColor(QPalette.ColorRole.Window, Qt.GlobalColor.white) self.setAutoFillBackground(True) self.setPalette(pal) @@ -171,7 +171,7 @@ def get_data(self, sig_ts, sig_buffer, marker_ts, marker_buffer): def paintEvent(self, event): painter = QPainter(self) if self.dataBuffer is not None: - painter.setPen(QPen(Qt.blue)) + painter.setPen(QPen(Qt.GlobalColor.blue)) n_samps = len(self.dataBuffer) n_chans = len(self.dataBuffer[0]) @@ -228,7 +228,7 @@ def paintEvent(self, event): self.dataBuffer = None if self.markerBuffer is not None: - painter.setPen(QPen(Qt.red)) + painter.setPen(QPen(Qt.GlobalColor.red)) for px, mrk in self.markerBuffer: painter.drawLine(px, 0, px, self.height()) painter.drawText(px - 2 * self.px_per_samp, 0.95 * self.height(), mrk) diff --git a/src/sigvisualizer/ui_sigvisualizer.py b/src/sigvisualizer/ui_sigvisualizer.py index 342f03f..d725549 100644 --- a/src/sigvisualizer/ui_sigvisualizer.py +++ b/src/sigvisualizer/ui_sigvisualizer.py @@ -6,7 +6,7 @@ # # WARNING! All changes made in this file will be lost! -from PyQt5 import QtCore, QtGui, QtWidgets +from qtpy import QtCore, QtGui, QtWidgets class Ui_MainWindow(object): def setupUi(self, MainWindow): @@ -17,7 +17,7 @@ def setupUi(self, MainWindow): self.gridLayout = QtWidgets.QGridLayout(self.centralwidget) self.gridLayout.setObjectName("gridLayout") self.toggleButton = QtWidgets.QPushButton(self.centralwidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.toggleButton.sizePolicy().hasHeightForWidth()) @@ -27,7 +27,7 @@ def setupUi(self, MainWindow): self.toggleButton.setObjectName("toggleButton") self.gridLayout.addWidget(self.toggleButton, 0, 0, 2, 1) self.treeWidget = QtWidgets.QTreeWidget(self.centralwidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.treeWidget.sizePolicy().hasHeightForWidth()) @@ -68,5 +68,5 @@ def retranslateUi(self, MainWindow): ui = Ui_MainWindow() ui.setupUi(MainWindow) MainWindow.show() - sys.exit(app.exec_()) + sys.exit(app.exec()) From d5a9907d5e88b9294c413d0fc6f697057528813d Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:35:20 -0400 Subject: [PATCH 06/14] feat: automatically load UI at runtime --- src/sigvisualizer/app.py | 71 ++++++++++-------- .../resources/ui_sigvisualizer.ui | 10 +-- src/sigvisualizer/ui_sigvisualizer.py | 72 ------------------- 3 files changed, 43 insertions(+), 110 deletions(-) delete mode 100644 src/sigvisualizer/ui_sigvisualizer.py diff --git a/src/sigvisualizer/app.py b/src/sigvisualizer/app.py index 8e580fa..0adda8d 100644 --- a/src/sigvisualizer/app.py +++ b/src/sigvisualizer/app.py @@ -3,12 +3,12 @@ from contextlib import ExitStack from importlib import resources +from qtpy import uic from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QIcon -from qtpy.QtWidgets import (QApplication, QMainWindow, QStatusBar, - QTreeWidgetItem) +from qtpy.QtWidgets import QApplication, QMainWindow, QStatusBar, QTreeWidgetItem -from .ui_sigvisualizer import Ui_MainWindow +from .paintwidget import PaintWidget _resource_stack = ExitStack() atexit.register(_resource_stack.close) @@ -21,42 +21,55 @@ def _resource_path(*parts): class SigVisualizer(QMainWindow): stream_expanded = Signal(str) - + def __init__(self): super().__init__() - self.ui = Ui_MainWindow() - self.ui.setupUi(self) + uic.loadUi(_resource_path("ui_sigvisualizer.ui"), self) + self._install_paint_widget() + self.setWindowTitle('Real Time Signal Visualizer') - self.ui.treeWidget.setHeaderLabel('Streams') + self.treeWidget.setHeaderLabel('Streams') self.setWindowIcon(QIcon(_resource_path("sigvisualizer.ico"))) self.statusBar = QStatusBar() self.setStatusBar(self.statusBar) - self.ui.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_left.svg"))) - self.ui.toggleButton.setIconSize(QSize(30, 30)) + self.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_left.svg"))) + self.toggleButton.setIconSize(QSize(30, 30)) - self.ui.toggleButton.clicked.connect(self.toggle_panel) - self.ui.updateButton.clicked.connect( - self.ui.widget.dataTr.update_streams) - self.ui.widget.dataTr.updateStreamNames.connect( - self.update_metadata_widget) + self.toggleButton.clicked.connect(self.toggle_panel) + self.updateButton.clicked.connect(self.widget.dataTr.update_streams) + self.widget.dataTr.updateStreamNames.connect(self.update_metadata_widget) self.panelHidden = False - self.ui.treeWidget.itemExpanded.connect(self.tree_item_expanded) - self.stream_expanded.connect(self.ui.widget.dataTr.handle_stream_expanded) + self.treeWidget.itemExpanded.connect(self.tree_item_expanded) + self.stream_expanded.connect(self.widget.dataTr.handle_stream_expanded) + + def _install_paint_widget(self): + """Replace the plain QWidget placeholder loaded from the .ui file with a real + PaintWidget. Kept as a placeholder in the .ui file (rather than a Designer + "promoted widget") since promoted-widget resolution isn't portable across + PyQt5/PyQt6/PySide2/PySide6.""" + layout = self.centralwidget.layout() + index = layout.indexOf(self.widget) + row, col, rowspan, colspan = layout.getItemPosition(index) + layout.removeWidget(self.widget) + self.widget.deleteLater() + self.widget = PaintWidget(self.centralwidget) + self.widget.setObjectName("widget") + layout.addWidget(self.widget, row, col, rowspan, colspan) def tree_item_expanded(self, widget_item): name = widget_item.text(0) - for it_ix in range(self.ui.treeWidget.topLevelItemCount()): - item = self.ui.treeWidget.topLevelItem(it_ix) + for it_ix in range(self.treeWidget.topLevelItemCount()): + item = self.treeWidget.topLevelItem(it_ix) if item.text(0) != name: item.setExpanded(False) self.stream_expanded.emit(name) def update_metadata_widget(self, metadata, default_idx): for s_ix, s_meta in enumerate(metadata): - item = QTreeWidgetItem(self.ui.treeWidget) + item = QTreeWidgetItem(self.treeWidget) item.setText(0, s_meta["name"]) for m in range(s_meta["ch_count"]): @@ -65,25 +78,25 @@ def update_metadata_widget(self, metadata, default_idx): channel_item.setCheckState(0, Qt.CheckState.Checked) item.setExpanded(True if s_ix == default_idx else False) - self.ui.treeWidget.addTopLevelItem(item) + self.treeWidget.addTopLevelItem(item) - self.ui.treeWidget.setAnimated(True) + self.treeWidget.setAnimated(True) self.statusBar.showMessage( "Sampling rate: {}Hz".format(metadata[default_idx]["srate"])) def toggle_panel(self): if self.panelHidden: self.panelHidden = False - self.ui.treeWidget.show() - self.ui.updateButton.show() - self.ui.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_left.svg"))) - self.ui.toggleButton.setIconSize(QSize(30, 30)) + self.treeWidget.show() + self.updateButton.show() + self.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_left.svg"))) + self.toggleButton.setIconSize(QSize(30, 30)) else: self.panelHidden = True - self.ui.treeWidget.hide() - self.ui.updateButton.hide() - self.ui.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_right.svg"))) - self.ui.toggleButton.setIconSize(QSize(30, 30)) + self.treeWidget.hide() + self.updateButton.hide() + self.toggleButton.setIcon(QIcon(_resource_path("icons", "chevron_right.svg"))) + self.toggleButton.setIconSize(QSize(30, 30)) def main(): diff --git a/src/sigvisualizer/resources/ui_sigvisualizer.ui b/src/sigvisualizer/resources/ui_sigvisualizer.ui index 095ae28..aeb7be1 100644 --- a/src/sigvisualizer/resources/ui_sigvisualizer.ui +++ b/src/sigvisualizer/resources/ui_sigvisualizer.ui @@ -56,7 +56,7 @@ - + @@ -79,14 +79,6 @@ - - - PaintWidget - QWidget -
paintwidget.h
- 1 -
-
diff --git a/src/sigvisualizer/ui_sigvisualizer.py b/src/sigvisualizer/ui_sigvisualizer.py deleted file mode 100644 index d725549..0000000 --- a/src/sigvisualizer/ui_sigvisualizer.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'ui_sigvisualizer.ui' -# -# Created by: PyQt5 UI code generator 5.11.2 -# -# WARNING! All changes made in this file will be lost! - -from qtpy import QtCore, QtGui, QtWidgets - -class Ui_MainWindow(object): - def setupUi(self, MainWindow): - MainWindow.setObjectName("MainWindow") - MainWindow.resize(1123, 759) - self.centralwidget = QtWidgets.QWidget(MainWindow) - self.centralwidget.setObjectName("centralwidget") - self.gridLayout = QtWidgets.QGridLayout(self.centralwidget) - self.gridLayout.setObjectName("gridLayout") - self.toggleButton = QtWidgets.QPushButton(self.centralwidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.toggleButton.sizePolicy().hasHeightForWidth()) - self.toggleButton.setSizePolicy(sizePolicy) - self.toggleButton.setMaximumSize(QtCore.QSize(20, 16777215)) - self.toggleButton.setText("") - self.toggleButton.setObjectName("toggleButton") - self.gridLayout.addWidget(self.toggleButton, 0, 0, 2, 1) - self.treeWidget = QtWidgets.QTreeWidget(self.centralwidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.treeWidget.sizePolicy().hasHeightForWidth()) - self.treeWidget.setSizePolicy(sizePolicy) - self.treeWidget.setMaximumSize(QtCore.QSize(180, 16777215)) - self.treeWidget.setObjectName("treeWidget") - self.treeWidget.headerItem().setText(0, "1") - self.gridLayout.addWidget(self.treeWidget, 0, 1, 1, 1) - self.widget = PaintWidget(self.centralwidget) - self.widget.setObjectName("widget") - self.gridLayout.addWidget(self.widget, 0, 2, 2, 1) - self.updateButton = QtWidgets.QPushButton(self.centralwidget) - self.updateButton.setObjectName("updateButton") - self.gridLayout.addWidget(self.updateButton, 1, 1, 1, 1) - MainWindow.setCentralWidget(self.centralwidget) - self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1123, 26)) - self.menubar.setObjectName("menubar") - MainWindow.setMenuBar(self.menubar) - self.statusbar = QtWidgets.QStatusBar(MainWindow) - self.statusbar.setObjectName("statusbar") - MainWindow.setStatusBar(self.statusbar) - - self.retranslateUi(MainWindow) - QtCore.QMetaObject.connectSlotsByName(MainWindow) - - def retranslateUi(self, MainWindow): - _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) - self.updateButton.setText(_translate("MainWindow", "Update Streams")) - -from .paintwidget import PaintWidget - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - MainWindow = QtWidgets.QMainWindow() - ui = Ui_MainWindow() - ui.setupUi(MainWindow) - MainWindow.show() - sys.exit(app.exec()) - From 9a8d78d4ceac74b356525b6cb40160c19fd743c3 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:49:29 -0400 Subject: [PATCH 07/14] fix: handle stops gracefully --- src/sigvisualizer/app.py | 5 +++++ src/sigvisualizer/paintwidget.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/sigvisualizer/app.py b/src/sigvisualizer/app.py index 0adda8d..19eb5d9 100644 --- a/src/sigvisualizer/app.py +++ b/src/sigvisualizer/app.py @@ -45,6 +45,11 @@ def __init__(self): self.treeWidget.itemExpanded.connect(self.tree_item_expanded) self.stream_expanded.connect(self.widget.dataTr.handle_stream_expanded) + def closeEvent(self, event): + self.widget.dataTr.request_stop() + self.widget.dataTr.wait(2000) + super().closeEvent(event) + def _install_paint_widget(self): """Replace the plain QWidget placeholder loaded from the .ui file with a real PaintWidget. Kept as a placeholder in the .ui file (rather than a Designer diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index b95d859..eb7c676 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -25,6 +25,10 @@ def __init__(self, parent): self.streams = [] self.stream_params = [] self.sig_strm_idx = -1 + self._stop_requested = False + + def request_stop(self): + self._stop_requested = True def handle_stream_expanded(self, name): stream_names = [_['metadata']['name'] for _ in self.stream_params] @@ -68,7 +72,7 @@ def update_streams(self): def run(self): if self.streams: - while True: + while not self._stop_requested: send_ts, send_data = [], [] if self.sig_strm_idx >= 0: params = self.stream_params[self.sig_strm_idx] From 9d526ab740bbc1cb3ae44fd7c8d8a46414209558 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 16:55:40 -0400 Subject: [PATCH 08/14] fix: handle status when no streams --- src/sigvisualizer/app.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/sigvisualizer/app.py b/src/sigvisualizer/app.py index 19eb5d9..2c7aa01 100644 --- a/src/sigvisualizer/app.py +++ b/src/sigvisualizer/app.py @@ -86,8 +86,11 @@ def update_metadata_widget(self, metadata, default_idx): self.treeWidget.addTopLevelItem(item) self.treeWidget.setAnimated(True) - self.statusBar.showMessage( - "Sampling rate: {}Hz".format(metadata[default_idx]["srate"])) + if default_idx >= 0: + self.statusBar.showMessage( + "Sampling rate: {}Hz".format(metadata[default_idx]["srate"])) + else: + self.statusBar.showMessage("No signal streams found") def toggle_panel(self): if self.panelHidden: From efb5c70060d06afa4556252b51effe65fb1803d3 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 18:01:17 -0400 Subject: [PATCH 09/14] fix: downsample timestamps alongside data pull_chunk can return fewer than chunkSize samples, and the averaging produced a shorter data buffer while leaving the timestamps untouched. Build a matching downsampled timestamp per averaged sample so the two stay length-aligned, and drop the aliased pre-allocated downSamplingBuffer (every row referenced the same list). --- src/sigvisualizer/paintwidget.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index eb7c676..24170d5 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -15,7 +15,7 @@ class DataThread(QThread): changedStream = Signal() def_stream_parms = {'chunk_idx': 0, 'metadata': {}, 'srate': None, 'chunkSize': None, - 'downSampling': None, 'downSamplingFactor': None, 'downSamplingBuffer': None, + 'downSampling': None, 'downSamplingFactor': None, 'inlet': None, 'stream_idx': None, 'is_marker': False} def __init__(self, parent): @@ -63,8 +63,6 @@ def update_streams(self): stream_params['chunkSize'] = round(srate / self.chunksPerScreen * self.seconds_per_screen) if stream_params['downSampling']: stream_params['downSamplingFactor'] = round(srate / 1000) - n_buff = round(stream_params['chunkSize'] / stream_params['downSamplingFactor']) - stream_params['downSamplingBuffer'] = [[0] * int(stream.channel_count())] * n_buff self.stream_params.append(stream_params) self.updateStreamNames.emit([_['metadata'] for _ in self.stream_params], self.sig_strm_idx) @@ -82,12 +80,18 @@ def run(self): pull_kwargs['max_samples'] = params['chunkSize'] send_data, send_ts = inlet.pull_chunk(**pull_kwargs) if send_ts and params['downSampling']: - for m in range(round(params['chunkSize'] / params['downSamplingFactor'])): - end_idx = min((m + 1) * params['downSamplingFactor'], len(send_data)) - for ch_idx in range(int(self.streams[self.sig_strm_idx].channel_count())): - buf = [send_data[n][ch_idx] for n in range(m * params['downSamplingFactor'], end_idx)] - params['downSamplingBuffer'][m][ch_idx] = sum(buf) / len(buf) - send_data = params['downSamplingBuffer'] + factor = params['downSamplingFactor'] + n_chans = int(self.streams[self.sig_strm_idx].channel_count()) + ds_data, ds_ts = [], [] + for start in range(0, len(send_data), factor): + end_idx = min(start + factor, len(send_data)) + ds_data.append([ + sum(send_data[n][ch_idx] for n in range(start, end_idx)) / (end_idx - start) + for ch_idx in range(n_chans) + ]) + # Keep a timestamp per averaged sample so the plot stays time-aligned. + ds_ts.append(send_ts[start]) + send_data, send_ts = ds_data, ds_ts send_mrk_ts, send_mrk_data = [], [] is_marker = [_['is_marker'] for _ in self.stream_params] if any(is_marker): From 429c1f871bd010a11ba283812e7502fd72626ea7 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 18:02:31 -0400 Subject: [PATCH 10/14] fix: plot samples by timestamp instead of chunk slot The x-position was derived from a per-chunk slot counter, which assumes every chunk holds the same number of samples. pull_chunk can return variable counts, so slots drifted and produced disconnected segments. Position each sample by its own timestamp relative to the sweep origin, track the actual last-drawn x for the connecting line, and start a new sweep once a full screen of time has elapsed. --- src/sigvisualizer/paintwidget.py | 35 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index 24170d5..24fa762 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -124,8 +124,10 @@ def reset(self): self.channelHeight = 0 self.px_per_samp = 0 self.dataBuffer = None + self.tsBuffer = None self.markerBuffer = None self.lastY = [] + self.lastX = None self.scaling = [] self.mean = [] self.t0 = 0 @@ -139,12 +141,20 @@ def get_data(self, sig_ts, sig_buffer, marker_ts, marker_buffer): if not self.mean: self.mean = [0 for _ in range(len(sig_buffer[0]))] self.scaling = [1 for _ in range(len(sig_buffer[0]))] - if self.chunk_idx == 0: + # Start a new sweep once a full screen of time has elapsed. + if self.chunk_idx == 0 or (sig_ts[0] - self.t0) >= self.dataTr.seconds_per_screen: self.t0 = sig_ts[0] + self.chunk_idx = 0 + self.lastX = None + self.lastY = [] self.dataBuffer = copy.deepcopy(sig_buffer) - px_per_chunk = self.width() / self.dataTr.chunksPerScreen - update_x0 = self.chunk_idx * px_per_chunk - update_width = px_per_chunk + self.tsBuffer = list(sig_ts) + px_per_sec = self.width() / self.dataTr.seconds_per_screen + x_start = (sig_ts[0] - self.t0) * px_per_sec + x_end = (sig_ts[-1] - self.t0) * px_per_sec + # Start the repaint region at the previous sample so the connecting line is included. + update_x0 = self.lastX if self.lastX is not None else x_start + update_width = x_end - update_x0 if any(marker_ts): px_out = [] @@ -169,9 +179,6 @@ def get_data(self, sig_ts, sig_buffer, marker_ts, marker_buffer): update_x0 = min(update_x0, min(px_out)) update_width = max(update_width, max([_ - update_x0 for _ in px_out])) - if any(sig_ts) and update_x0 == sig_ts[0]: - update_x0 -= self.px_per_samp # Offset to connect with previous sample - # Repaint only the region of the screen containing this data chunk. if update_width > 0: self.update(int(update_x0), 0, int(update_width + 1), self.height()) @@ -185,7 +192,8 @@ def paintEvent(self, event): n_chans = len(self.dataBuffer[0]) self.channelHeight = self.height() / n_chans - self.px_per_samp = self.width() / self.dataTr.chunksPerScreen / n_samps + px_per_sec = self.width() / self.dataTr.seconds_per_screen + xs = [(ts - self.t0) * px_per_sec for ts in self.tsBuffer] # ====================================================================================================== # Calculate Trend and Scaling @@ -212,27 +220,26 @@ def paintEvent(self, event): # ====================================================================================================== # Plot # ====================================================================================================== - px_per_chunk = self.width() / self.dataTr.chunksPerScreen - x0 = self.chunk_idx * px_per_chunk for ch_idx in range(n_chans): chan_offset = (ch_idx + 0.5) * self.channelHeight if self.lastY: if not math.isnan(self.lastY[ch_idx]) and not math.isnan(self.dataBuffer[0][ch_idx]): - painter.drawLine(x0 - self.px_per_samp, + painter.drawLine(self.lastX, -self.lastY[ch_idx] + chan_offset, - x0, + xs[0], -self.dataBuffer[0][ch_idx] + chan_offset) for m in range(n_samps - 1): if not math.isnan(self.dataBuffer[m][ch_idx]) and not math.isnan(self.dataBuffer[m+1][ch_idx]): - painter.drawLine(x0 + m * self.px_per_samp, + painter.drawLine(xs[m], -self.dataBuffer[m][ch_idx] + chan_offset, - x0 + (m + 1) * self.px_per_samp, + xs[m + 1], -self.dataBuffer[m+1][ch_idx] + chan_offset) # Reset for next iteration self.chunk_idx = (self.chunk_idx + 1) % self.dataTr.chunksPerScreen # For next iteration self.lastY = self.dataBuffer[-1] + self.lastX = xs[-1] self.dataBuffer = None if self.markerBuffer is not None: From b5464d9c7ec1099af7da35a6f5afb1b1b02f7aed Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 18:05:02 -0400 Subject: [PATCH 11/14] refactor: per-stream sweep state keyed by stream index Move t0, chunk_idx, lastX, lastY, mean and scaling out of single widget attributes into a per-stream dict. Streams at the same nominal rate can arrive out of phase, so each stream must track its own sweep origin and position; switching the plotted stream now resumes that stream's own timeline instead of jumping. --- src/sigvisualizer/paintwidget.py | 75 ++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index 24fa762..6c15a06 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -120,40 +120,48 @@ def __init__(self, widget): self.dataTr.changedStream.connect(self.reset) def reset(self): - self.chunk_idx = 0 self.channelHeight = 0 self.px_per_samp = 0 self.dataBuffer = None self.tsBuffer = None self.markerBuffer = None - self.lastY = [] - self.lastX = None - self.scaling = [] - self.mean = [] - self.t0 = 0 + # Per-stream sweep/timestamp state, keyed by stream index. Streams at the + # same rate can arrive out of phase, so each keeps its own t0 and sweep + # position rather than sharing a single timeline. + self.stream_states = {} + self._paint_state = None + + def _stream_state(self, idx): + st = self.stream_states.get(idx) + if st is None: + st = {'t0': 0, 'chunk_idx': 0, 'lastX': None, 'lastY': [], 'mean': [], 'scaling': []} + self.stream_states[idx] = st + return st def get_data(self, sig_ts, sig_buffer, marker_ts, marker_buffer): update_x0 = float(self.width()) update_width = 0. + st = self._stream_state(self.dataTr.sig_strm_idx) # buffer should have exactly self.dataTr.chunkSize samples or be empty if any(sig_ts): - if not self.mean: - self.mean = [0 for _ in range(len(sig_buffer[0]))] - self.scaling = [1 for _ in range(len(sig_buffer[0]))] + if not st['mean']: + st['mean'] = [0 for _ in range(len(sig_buffer[0]))] + st['scaling'] = [1 for _ in range(len(sig_buffer[0]))] # Start a new sweep once a full screen of time has elapsed. - if self.chunk_idx == 0 or (sig_ts[0] - self.t0) >= self.dataTr.seconds_per_screen: - self.t0 = sig_ts[0] - self.chunk_idx = 0 - self.lastX = None - self.lastY = [] + if st['chunk_idx'] == 0 or (sig_ts[0] - st['t0']) >= self.dataTr.seconds_per_screen: + st['t0'] = sig_ts[0] + st['chunk_idx'] = 0 + st['lastX'] = None + st['lastY'] = [] self.dataBuffer = copy.deepcopy(sig_buffer) self.tsBuffer = list(sig_ts) + self._paint_state = st px_per_sec = self.width() / self.dataTr.seconds_per_screen - x_start = (sig_ts[0] - self.t0) * px_per_sec - x_end = (sig_ts[-1] - self.t0) * px_per_sec + x_start = (sig_ts[0] - st['t0']) * px_per_sec + x_end = (sig_ts[-1] - st['t0']) * px_per_sec # Start the repaint region at the previous sample so the connecting line is included. - update_x0 = self.lastX if self.lastX is not None else x_start + update_x0 = st['lastX'] if st['lastX'] is not None else x_start update_width = x_end - update_x0 if any(marker_ts): @@ -170,8 +178,8 @@ def get_data(self, sig_ts, sig_buffer, marker_ts, marker_buffer): # TODO: Check samples vs pixels for both data stream and marker stream. # I think there is some rounding error. - if self.t0 <= ts <= (self.t0 + self.dataTr.seconds_per_screen): - px_out.append((ts - self.t0) * px_per_sec) + if st['t0'] <= ts <= (st['t0'] + self.dataTr.seconds_per_screen): + px_out.append((ts - st['t0']) * px_per_sec) ms_out.append(','.join(ms)) if any(px_out): # Sometimes the marker might happen just off screen so we lose it. @@ -187,45 +195,46 @@ def paintEvent(self, event): painter = QPainter(self) if self.dataBuffer is not None: painter.setPen(QPen(Qt.GlobalColor.blue)) + st = self._paint_state n_samps = len(self.dataBuffer) n_chans = len(self.dataBuffer[0]) self.channelHeight = self.height() / n_chans px_per_sec = self.width() / self.dataTr.seconds_per_screen - xs = [(ts - self.t0) * px_per_sec for ts in self.tsBuffer] + xs = [(ts - st['t0']) * px_per_sec for ts in self.tsBuffer] # ====================================================================================================== # Calculate Trend and Scaling # ====================================================================================================== - if self.chunk_idx == 0 or not self.mean: + if st['chunk_idx'] == 0 or not st['mean']: for chan_idx in range(n_chans): samps_for_chan = [frame[chan_idx] for frame in self.dataBuffer] - self.mean[chan_idx] = sum(samps_for_chan) / len(samps_for_chan) + st['mean'][chan_idx] = sum(samps_for_chan) / len(samps_for_chan) for m in range(len(samps_for_chan)): - samps_for_chan[m] -= self.mean[chan_idx] + samps_for_chan[m] -= st['mean'][chan_idx] data_range = (max(samps_for_chan) - min(samps_for_chan) + 0.0000000000001) - self.scaling[chan_idx] = self.channelHeight * CHANNEL_Y_FILL / data_range + st['scaling'][chan_idx] = self.channelHeight * CHANNEL_Y_FILL / data_range # ====================================================================================================== # Trend Removal and Scaling # ====================================================================================================== for samp_idx in range(n_samps): for chan_idx in range(n_chans): - self.dataBuffer[samp_idx][chan_idx] -= self.mean[chan_idx] - self.dataBuffer[samp_idx][chan_idx] *= self.scaling[chan_idx] + self.dataBuffer[samp_idx][chan_idx] -= st['mean'][chan_idx] + self.dataBuffer[samp_idx][chan_idx] *= st['scaling'][chan_idx] # ====================================================================================================== # Plot # ====================================================================================================== for ch_idx in range(n_chans): chan_offset = (ch_idx + 0.5) * self.channelHeight - if self.lastY: - if not math.isnan(self.lastY[ch_idx]) and not math.isnan(self.dataBuffer[0][ch_idx]): - painter.drawLine(self.lastX, - -self.lastY[ch_idx] + chan_offset, + if st['lastY']: + if not math.isnan(st['lastY'][ch_idx]) and not math.isnan(self.dataBuffer[0][ch_idx]): + painter.drawLine(st['lastX'], + -st['lastY'][ch_idx] + chan_offset, xs[0], -self.dataBuffer[0][ch_idx] + chan_offset) @@ -237,9 +246,9 @@ def paintEvent(self, event): -self.dataBuffer[m+1][ch_idx] + chan_offset) # Reset for next iteration - self.chunk_idx = (self.chunk_idx + 1) % self.dataTr.chunksPerScreen # For next iteration - self.lastY = self.dataBuffer[-1] - self.lastX = xs[-1] + st['chunk_idx'] = (st['chunk_idx'] + 1) % self.dataTr.chunksPerScreen # For next iteration + st['lastY'] = self.dataBuffer[-1] + st['lastX'] = xs[-1] self.dataBuffer = None if self.markerBuffer is not None: From 72af5be18645153afaff421141b3529ac34693cd Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 18:05:31 -0400 Subject: [PATCH 12/14] fix: clamp data range for values near zero A flat channel (routine with HD-EEG reference/disconnected channels) had its range padded by a tiny epsilon, yielding a ~1e12 scaling factor that overflowed drawLine's int32 coordinate as soon as the channel carried a real value. Scale a zero-range channel to 0 so it draws centered. Fixes #18, #20. --- src/sigvisualizer/paintwidget.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index 6c15a06..31b56a2 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -215,8 +215,16 @@ def paintEvent(self, event): for m in range(len(samps_for_chan)): samps_for_chan[m] -= st['mean'][chan_idx] - data_range = (max(samps_for_chan) - min(samps_for_chan) + 0.0000000000001) - st['scaling'][chan_idx] = self.channelHeight * CHANNEL_Y_FILL / data_range + # A flat channel (common with HD-EEG reference/disconnected channels) + # has ~zero range; scale it to 0 so it draws centered instead of + # exploding to a huge coordinate and overflowing drawLine (int32). + # See https://github.com/labstreaminglayer/App-SigVisualizer/issues/18, + # https://github.com/labstreaminglayer/App-SigVisualizer/issues/20 + data_range = max(samps_for_chan) - min(samps_for_chan) + if data_range > 0: + st['scaling'][chan_idx] = self.channelHeight * CHANNEL_Y_FILL / data_range + else: + st['scaling'][chan_idx] = 0 # ====================================================================================================== # Trend Removal and Scaling From bfcaa90937d8c37c623309928f62ca8071c8206e Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 18:07:15 -0400 Subject: [PATCH 13/14] refactor: drop downsampling Downsampling only decimated streams above 1 kHz to ~1 kHz, a negligible saving given the canvas is far narrower than even a 1 kHz sweep, while adding real complexity and (until recently) bugs. Always emit the raw pull_chunk output and let QPainter draw it. --- src/sigvisualizer/paintwidget.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index 31b56a2..d56f720 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -15,7 +15,6 @@ class DataThread(QThread): changedStream = Signal() def_stream_parms = {'chunk_idx': 0, 'metadata': {}, 'srate': None, 'chunkSize': None, - 'downSampling': None, 'downSamplingFactor': None, 'inlet': None, 'stream_idx': None, 'is_marker': False} def __init__(self, parent): @@ -59,10 +58,7 @@ def update_streams(self): if self.sig_strm_idx < 0: self.sig_strm_idx = k srate = stream.nominal_srate() - stream_params['downSampling'] = srate > 1000 stream_params['chunkSize'] = round(srate / self.chunksPerScreen * self.seconds_per_screen) - if stream_params['downSampling']: - stream_params['downSamplingFactor'] = round(srate / 1000) self.stream_params.append(stream_params) self.updateStreamNames.emit([_['metadata'] for _ in self.stream_params], self.sig_strm_idx) @@ -79,19 +75,6 @@ def run(self): if params['chunkSize']: pull_kwargs['max_samples'] = params['chunkSize'] send_data, send_ts = inlet.pull_chunk(**pull_kwargs) - if send_ts and params['downSampling']: - factor = params['downSamplingFactor'] - n_chans = int(self.streams[self.sig_strm_idx].channel_count()) - ds_data, ds_ts = [], [] - for start in range(0, len(send_data), factor): - end_idx = min(start + factor, len(send_data)) - ds_data.append([ - sum(send_data[n][ch_idx] for n in range(start, end_idx)) / (end_idx - start) - for ch_idx in range(n_chans) - ]) - # Keep a timestamp per averaged sample so the plot stays time-aligned. - ds_ts.append(send_ts[start]) - send_data, send_ts = ds_data, ds_ts send_mrk_ts, send_mrk_data = [], [] is_marker = [_['is_marker'] for _ in self.stream_params] if any(is_marker): From 679d7944e37c483f30483b5ff39d73cbce89cc04 Mon Sep 17 00:00:00 2001 From: Gavin John Date: Tue, 7 Jul 2026 18:19:28 -0400 Subject: [PATCH 14/14] feat: follow user's Qt theme --- src/sigvisualizer/paintwidget.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/sigvisualizer/paintwidget.py b/src/sigvisualizer/paintwidget.py index d56f720..1820deb 100644 --- a/src/sigvisualizer/paintwidget.py +++ b/src/sigvisualizer/paintwidget.py @@ -93,15 +93,26 @@ class PaintWidget(QWidget): def __init__(self, widget): super().__init__() self.reset() - pal = QPalette() - pal.setColor(QPalette.ColorRole.Window, Qt.GlobalColor.white) + pal = self.palette() self.setAutoFillBackground(True) + self.setBackgroundRole(QPalette.ColorRole.Base) self.setPalette(pal) self.dataTr = DataThread(self) self.dataTr.sendData.connect(self.get_data) self.dataTr.changedStream.connect(self.reset) + def _theme_colors(self): + pal = self.palette() + signal_color = pal.color(QPalette.ColorRole.Highlight) + marker_color = pal.color(QPalette.ColorRole.Accent) + if not marker_color.isValid(): + marker_color = signal_color + return { + 'signal': signal_color, + 'marker': marker_color, + } + def reset(self): self.channelHeight = 0 self.px_per_samp = 0 @@ -176,8 +187,9 @@ def get_data(self, sig_ts, sig_buffer, marker_ts, marker_buffer): def paintEvent(self, event): painter = QPainter(self) + colors = self._theme_colors() if self.dataBuffer is not None: - painter.setPen(QPen(Qt.GlobalColor.blue)) + painter.setPen(QPen(colors['signal'])) st = self._paint_state n_samps = len(self.dataBuffer) @@ -243,7 +255,7 @@ def paintEvent(self, event): self.dataBuffer = None if self.markerBuffer is not None: - painter.setPen(QPen(Qt.GlobalColor.red)) + painter.setPen(QPen(colors['marker'])) for px, mrk in self.markerBuffer: painter.drawLine(px, 0, px, self.height()) painter.drawText(px - 2 * self.px_per_samp, 0.95 * self.height(), mrk)