Search code examples
python-3.xmultithreadinguser-interfacepyqtgraphpyside6

Unexpected freezing of graph while GUI still works and without error message | Pyside6 and pyqtgraph with multi-threading (Python 3.11.4)


Goal: I'm creating a GUI that will be used for displaying input from a sensor on a graph. To make this repeatable and intuitive I've replaced any input from the sensor with a simple sinus function. The program, when run, displays one window with two buttons. One for starting and one for paus/resume.

Method: The GUI is built using PySide6 and the graphical widget is created using pyqtgraph. The generation and plotting of data is done in a separate 'Worker'-thread while everything else (pressing buttons, setting up widgets, etc) is done in the main thread.

Problem: Eventually the graph will suddenly freeze. This might take 10-20 min. Does anyone have any idea as to what could be wrong?

Reproducibility: The graph might freeze faster if the GUI is interacted with. Like pressing buttons. Testing this on a RaspberryPi 4b (Python 3.9) gave even faster freezes. Printing the data in the terminal reveals that it keeps updating the data set but it's the graph that isn't updating accordingly. I have to close the application and run the code again for it to work again. When closing the application no error is reported.

Here are the two classes for signals and the 'Worker'-thread:

import sys
import traceback
import numpy as np
import time
from PySide6.QtCore import (Qt, QObject, QRunnable, QThreadPool, Slot, Signal)
from PySide6.QtWidgets import (QMainWindow, QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QHBoxLayout, QWidget, QGraphicsView, QStackedLayout)
import pyqtgraph as pg
from qtrangeslider._labeled import QLabeledSlider

###___Signals for communication between the two threads___###
class WorkerSignals(QObject):
    error = Signal(tuple)


###___The Worker-Thread, running seperately from the Main-Thread___###
class Worker(QRunnable):
    def __init__(self, interval_range, env_curve, env_plot):
        super().__init__()

        #Setting arguments and signals#
        self.signals = WorkerSignals()
        self.interval_range = interval_range
        self.env_curve = env_curve
        self.env_plot = env_plot

        #Further setting to the plotting#
        self.start = self.interval_range[0]
        self.length = self.interval_range[1]-self.interval_range[0]  
        self.num_depths = 2000       #number of points on the length axis#
        self.depths = np.linspace(self.start, self.start + self.length, self.num_depths)
        
        self.pause = False
        self.stop = False
        self.go = True
        self.n = 0
    
    #Specified a Slot for the run-function, enabling it to run on a seperate thread#
    @Slot()                          
    def run(self):

        while self.go == True:
            self.n += 1

            #Enables us to freeze and start the plot via toggling self.pause (function bellow in this thread)#
            while self.pause == True: 
                time.sleep(0.01)
            
            #This try-block generates data and updates the plots, over and over#
            try:                       
                data = np.sin(2*np.pi*(self.depths+0.001*self.n))
                self.env_curve.setData(self.depths, data)
                self.env_plot.setYRange(-1, 1)

            #If an error occurs this block helps display it in the terminal#                  
            except:                    
                traceback.print_exc()
                exctype, value = sys.exc_info()[:2]
                self.signals.error.emit((exctype, value, traceback.format_exc()))
                self.go = False

    #This function is accessed if the 'start/stop'-button (specified in the main thread) is toggeled. After completion the while-loop above will halt or take off accordingly#            
    def start_stop_worker(self):              
        if self.pause == False:        
            self.pause = True
        elif self.pause == True:
            self.pause = False

Here is the main thread where the GUI is built


###___Main-Thread___###
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        
        #Window name and dimensions#
        self.setWindowTitle("My App")
        self.setFixedWidth(800)
        self.setFixedHeight(480)

        #Plotting grid apperance#
        pg.setConfigOption("background", "w")
        pg.setConfigOption("foreground", "k")
        pg.setConfigOptions(antialias=True)

        self.interval_range = [0,2*np.pi]

        central_widget = QWidget()
        plot_lay = QVBoxLayout(central_widget)
        self.win = pg.GraphicsLayoutWidget()
        plot_lay.addWidget(self.win)
        btn_1 = QPushButton('Start')
        btn_2 = QPushButton('Pause/Resume')
        btn_lay = QHBoxLayout(central_widget)
        plot_lay.addWidget(btn_1)
        plot_lay.addWidget(btn_2)
        plot_lay.addLayout(btn_lay)

        self.env_plot = self.win.addPlot()
        self.env_plot.showGrid(x=True, y=True)
        self.env_plot.setLabel("bottom", "Depth (m)")
        self.env_plot.setLabel("left", "Amplitude")
        self.env_curve = self.env_plot.plot(pen=pg.mkPen("k", width=2))
        
        #The connection for each button to one or several functions#
        btn_1.pressed.connect(self.start_live_plot)
        btn_2.pressed.connect(self.start_stop)
        
        #Finally creating and setting a central widget that will contain everything we have created#
        central_widget.setLayout(plot_lay)
        self.setCentralWidget(central_widget)
        

    #Goes to the live plot-tab and starts the Worker-thread#
    def start_live_plot(self):
        self.threadpool = QThreadPool()
        self.worker = Worker(self.interval_range, self.env_curve, self.env_plot)
        self.threadpool.start(self.worker)

    #Accesses the start stop function in the worker thread#
    def start_stop(self):
        self.worker.start_stop_worker()

app = QApplication([])
window = MainWindow()
window.show()
app.exec()

Edit: I simplified the program and scaled down the text after feedback


Solution

  • I've resolved my own problem. Apparently it's troublesome to update the plot within a separate thread. In a updated version I've moved the lines

    self.env_curve.setData(self.depths, data)
    self.env_plot.setYRange(-1, 1)
    

    to a separate function in the main thread. Also a new signal result = Signal(object) is created to emit the self.data from the Worker thread to the main thread for every new set of data points.