How to process data and display it simultaneously in PyQt5 and matplotlib

I am trying to expand the example presented here on how to integrate a matplotlib figure into a PyQt5 window and update it as the application is running.

What I have changed with respect to the code linked above is the condition for when the plot updates, i.e. instead of a timer, I added a button to the application that is linked to a function called simulate and runs a for-loop. Inside the loop, we call the update_plot function, everything else stayed the same.

def simulate(self, ydata):
    for k in trange(0, 500, 10):
        tmp = np.random.uniform(0, 1, size=(2, 1000))
        self.xdata = tmp[0]
        self.ydata = tmp[1]
        self.update_plot()

The full code is

import sys
from PyQt5.QtWidgets import QVBoxLayout, QSizePolicy, QWidget, QPushButton, QHBoxLayout, QSpacerItem
from PyQt5 import QtWidgets

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import random

import numpy as np
from time import sleep

import sys
import matplotlib
matplotlib.use('Qt5Agg')

from matplotlib.figure import Figure


class MplCanvas(FigureCanvas):

    def __init__(self, parent=None, width=5, height=4, dpi=100):
        fig = Figure(figsize=(width, height), dpi=dpi)
        self.axes = fig.add_subplot(111)
        super(MplCanvas, self).__init__(fig)

class MainWindow(QtWidgets.QMainWindow):

    def __init__(self):
        super(MainWindow, self).__init__()

        widget =  QWidget(self)
        self.setCentralWidget(widget)
        vlay = QVBoxLayout(widget)
        hlay = QHBoxLayout()
        vlay.addLayout(hlay)


        pybutton = QPushButton('Fit!', self)
        pybutton.clicked.connect(self.simulate)
        hlay2 = QHBoxLayout()
        hlay2.addWidget(pybutton)
        hlay2.addItem(QSpacerItem(1000, 10, QSizePolicy.Expanding))
        vlay.addLayout(hlay2)

        self.canvas = MplCanvas(self, width=5, height=4, dpi=100)
        vlay.addWidget(self.canvas)
        
        self.n_data = 999
        self.xdata = list(range(self.n_data))
        self.ydata = [random.uniform(0, 1) for i in range(self.n_data)]

        # We need to store a reference to the plotted line
        # somewhere, so we can apply the new data to it.
        self._plot_ref = None
        self.update_plot()

        self.show()


    def simulate(self):
        for k in range(0, 5):
            print(k)
            tmp = np.random.uniform(0, 1, size=(2,self.n_data))
            self.xdata = tmp[0]
            self.ydata = tmp[1]
            self.update_plot()
            sleep(0.5)
            

    def update_plot(self):
        # Drop off the first y element, append a new one.
        #self.ydata = self.ydata[1:] + [random.randint(0, 10)]

        # Note: we no longer need to clear the axis.
        if self._plot_ref is None:
            plot_refs = self.canvas.axes.plot(self.xdata, self.ydata, 'r')
            self._plot_ref = plot_refs[0]
        else:
            self._plot_ref.set_ydata(self.ydata)

        # Trigger the canvas to update and redraw.
        self.canvas.draw()


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    app.exec_()

What happens now is that whenever you click the button self.simulate() gets called and thus the for-loop starts... And as you can see in the function, I call self.update_plot every time, but the displayed plot only gets updated once the loop is over, i.e. the function finishes...

Can somebody explain what is going on?


I think Qt only refreshes the display when the main event loop is idle. So while you're running the simulate method, it cannot refresh. The quick fix for this is to add app.processEvents() right after update_plot().

If you have several GUI items you need to update frequently, then the processEvents trick won't work very well and you'll have to start looking at threads. Specifically QThreads, since they are design to play nice with Qt.

[Edit: Using QThread:] Code using QThread follows. I borrowed the approach from this answer and this answer. There is a lot of overhead in setting up threads. And you have to be VERY careful about handling data. The preferred way to pass data to and from threads and workers is using slots and signals. There is also a semaphore system, which I've never used.

Although this code is tedious, it gives a performant GUI. You can move the window around your screen while simulate is running and the plot will continue to update.

import sys
from PyQt5.QtWidgets import (QVBoxLayout, QSizePolicy, QWidget, QPushButton,
                             QHBoxLayout, QSpacerItem)
from PyQt5 import QtWidgets
from PyQt5.QtCore import QThread, QObject, pyqtSignal, pyqtSlot

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import random

import numpy as np
from time import sleep

import sys
import matplotlib
matplotlib.use('Qt5Agg')

from matplotlib.figure import Figure

class Worker(QObject):
    dataReady = pyqtSignal(object)
    finished  = pyqtSignal()

    def __init__(self, n_data):
        super().__init__()
        self.n_data = n_data

    def simulate(self):
        for k in range(0, 5):
            print(k)
            tmp = np.random.uniform(0, 1, size=(2,self.n_data))
            self.dataReady.emit(tmp)
            sleep(0.5)
        self.finished.emit()

class MplCanvas(FigureCanvas):

    def __init__(self, parent=None, width=5, height=4, dpi=100):
        fig = Figure(figsize=(width, height), dpi=dpi)
        self.axes = fig.add_subplot(111)
        super(MplCanvas, self).__init__(fig)

class MainWindow(QtWidgets.QMainWindow):

    def __init__(self):
        super(MainWindow, self).__init__()

        widget =  QWidget(self)
        self.setCentralWidget(widget)
        vlay = QVBoxLayout(widget)
        hlay = QHBoxLayout()
        vlay.addLayout(hlay)


        pybutton = QPushButton('Fit!', self)
        pybutton.clicked.connect(self.simulate)
        hlay2 = QHBoxLayout()
        hlay2.addWidget(pybutton)
        hlay2.addItem(QSpacerItem(1000, 10, QSizePolicy.Expanding))
        vlay.addLayout(hlay2)

        self.canvas = MplCanvas(self, width=5, height=4, dpi=100)
        vlay.addWidget(self.canvas)

        self.n_data = 999
        self.xdata = list(range(self.n_data))
        self.ydata = [random.uniform(0, 1) for i in range(self.n_data)]

        # We need to store a reference to the plotted line
        # somewhere, so we can apply the new data to it.
        self._plot_ref = None
        self.update_plot([self.xdata, self.ydata])

        self.show()

    def simulate(self):
        # now simulate from MainWindow will call run simulate in Worker

        self.thread = QThread()
        self.worker = Worker(n_data=self.n_data)
        self.worker.dataReady.connect(self.update_plot)
        self.worker.moveToThread(self.thread)
        self.worker.finished.connect(self.thread.quit)
        self.thread.started.connect(self.worker.simulate)
        self.thread.start()



    @pyqtSlot(object)
    def update_plot(self, data):
        # Drop off the first y element, append a new one.
        #self.ydata = self.ydata[1:] + [random.randint(0, 10)]
        print('got new data')
        xdata = data[0]
        ydata = data[1]

        # Note: we no longer need to clear the axis.
        if self._plot_ref is None:
            plot_refs = self.canvas.axes.plot(xdata, ydata, 'r')
            self._plot_ref = plot_refs[0]
        else:
            self._plot_ref.set_ydata(ydata)

        # Trigger the canvas to update and redraw.
        self.canvas.draw()


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    app.exec_()