How to use a Qthread to update a Matplotlib figure with PyQt?

5.2k views Asked by At

I'm really having a hard time to understand how to use Threads in PyQt. I made a simple example of what I would like to do in my UI. In the code you can see below, I want the user to enter a stock ticker (you can enter "bby", "goog" or "v" for example) and plot the value of the stock over a certain period. The thing is in more complex Ui or for a long period of time the UI freeze while the plot is beeing updated. So I made a "Plotter" class that update the plot when it receives a certain signal(overriding Qthread.run apparently was not the right way you're doing it wrong). I'd like to make this "Plotter" run in another thread than the main.

As soon as I uncomment the thread lines the program stops working. I've tried to move the launch of the new thread and also the "connect" but nothing is working. I think I'm not understanding well how Qthread works even after reading the documentation and looking at the examples on the Qt website.

If any af you know how to do this it would help a lot! (I'm working with Python 3.5 and PyQt5)

from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
from matplotlib.axes._subplots import Axes
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import sys
from datetime import datetime, timedelta
import time
import quandl


class MyMplCanvas(FigureCanvas):
    """Ultimately, this is a QWidget (as well as a FigureCanvasAgg, etc.)."""
    send_fig = pyqtSignal(Axes, str, name="send_fig")

    def __init__(self, parent=None):
        self.fig = Figure()
        self.axes = self.fig.add_subplot(111)

        # We want the axes cleared every time plot() is called
        self.axes.hold(False)

        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)

        FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)

    def update_plot(self, axes):
        self.axes = axes
        self.draw()

class MainWindow(QMainWindow):
    send_fig = pyqtSignal(Axes, str, name="send_fig")

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

        self.main_widget = QWidget(self)
        self.myplot = MyMplCanvas(self.main_widget)
        self.editor = QLineEdit()
        self.display = QLabel("Vide")

        self.layout = QGridLayout(self.main_widget)
        self.layout.addWidget(self.editor)
        self.layout.addWidget(self.display)
        self.layout.addWidget(self.myplot)

        self.main_widget.setFocus()
        self.setCentralWidget(self.main_widget)

        self.move(500, 500)
        self.show()

        self.editor.returnPressed.connect(self.updatePlot)

        self.plotter = Plotter()
        self.send_fig.connect(self.plotter.replot)

        self.plotter.return_fig.connect(self.myplot.update_plot)


    def updatePlot(self):
        ticker = self.editor.text()
        self.editor.clear()
        self.display.setText(ticker)

        # thread = QThread()
        # self.plotter.moveToThread(thread)

        self.send_fig.emit(self.myplot.axes, ticker)

        # thread.start()


class Plotter(QObject):
    return_fig = pyqtSignal(Axes)

    @pyqtSlot(Axes, str)
    def replot(self, axes, ticker):  # A slot takes no params
        print(ticker)
        d = datetime.today() - timedelta(weeks=52)  # data from 1week ago
        data = quandl.get("YAHOO/"+ticker+".6", start_date=d.strftime("%d-%m-%Y"), end_date=time.strftime("%d-%m-%Y"))
        axes.plot(data)
        self.return_fig.emit(axes)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MainWindow()
    sys.exit(app.exec_())
1

There are 1 answers

6
ImportanceOfBeingErnest On BEST ANSWER

The first problem is that you lose the reference to thread once it's started. To keep a reference use a class variable, i.e. self.thread instead of thread.

Next, the thread has to be started before doing anything. So you need to put self.thread.start() in front of the signal emission.

Now, it would work already, but a next problem occurs once you want to start a new thread. So, you need to first kill the old one. Since the old Plotter would then be homeless, a solution is to create a new Plotter as well as a new thread each time you want to plot. This is the way the solution below works.
Alternatively, you could also always use the same plotter and thread. The only thing to remember is that there is always exactly one worker (plotter) and one thread, if you delete one of them, the other is sad.

In order to test it, I needed to change some small things, like using PyQt4 instead of 5 and replace the data generation. Here is the working code.

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from matplotlib.axes._subplots import Axes
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
import sys
from datetime import datetime, timedelta
import numpy as np



class MyMplCanvas(FigureCanvas):
    """Ultimately, this is a QWidget (as well as a FigureCanvasAgg, etc.)."""
    send_fig = pyqtSignal(Axes, str, name="send_fig")

    def __init__(self, parent=None):
        self.fig = Figure()
        self.axes = self.fig.add_subplot(111)

        # We want the axes cleared every time plot() is called
        self.axes.hold(False)

        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)

        FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)

    def update_plot(self, axes):
        self.axes = axes
        self.draw()

class MainWindow(QMainWindow):
    send_fig = pyqtSignal(Axes, str, name="send_fig")

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

        self.main_widget = QWidget(self)
        self.myplot = MyMplCanvas(self.main_widget)
        self.editor = QLineEdit()
        self.display = QLabel("Vide")

        self.layout = QGridLayout(self.main_widget)
        self.layout.addWidget(self.editor)
        self.layout.addWidget(self.display)
        self.layout.addWidget(self.myplot)

        self.main_widget.setFocus()
        self.setCentralWidget(self.main_widget)

        self.move(500, 500)
        self.show()

        self.editor.returnPressed.connect(self.updatePlot)

        # plotter and thread are none at the beginning
        self.plotter = None 
        self.thread = None



    def updatePlot(self):
        ticker = self.editor.text()
        self.editor.clear()
        self.display.setText(ticker)

        # if there is already a thread running, kill it first
        if self.thread != None and self.thread.isRunning():
            self.thread.terminate()

        # initialize plotter and thread
        # since each plotter needs its own thread
        self.plotter = Plotter()
        self.thread = QThread()
        # connect signals
        self.send_fig.connect(self.plotter.replot)
        self.plotter.return_fig.connect(self.myplot.update_plot)
        #move to thread and start
        self.plotter.moveToThread(self.thread)
        self.thread.start()
        # start the plotting
        self.send_fig.emit(self.myplot.axes, ticker)



class Plotter(QObject):
    return_fig = pyqtSignal(Axes)

    @pyqtSlot(Axes, str)
    def replot(self, axes, ticker):  # A slot takes no params
        print(ticker)
        d = datetime.today() - timedelta(weeks=52)  # data from 1week ago
        # do some random task
        data = np.random.rand(10000,10000)
        axes.plot(data.mean(axis=1))
        self.return_fig.emit(axes)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MainWindow()
    sys.exit(app.exec_())

Here is a solution for the second option mentionned, i.e. create a single worker and a thread and use those throughout the program's runtime.

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
import sys
import numpy as np



class MyMplCanvas(FigureCanvas):

    def __init__(self, parent=None):
        self.fig = Figure()
        self.axes = self.fig.add_subplot(111)
        # plot empty line 
        self.line, = self.axes.plot([],[], color="orange")

        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)

        FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)


class MainWindow(QMainWindow):
    send_fig = pyqtSignal(str)

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

        self.main_widget = QWidget(self)
        self.myplot = MyMplCanvas(self.main_widget)
        self.editor = QLineEdit()
        self.display = QLabel("Vide")

        self.layout = QGridLayout(self.main_widget)
        self.layout.addWidget(self.editor)
        self.layout.addWidget(self.display)
        self.layout.addWidget(self.myplot)

        self.main_widget.setFocus()
        self.setCentralWidget(self.main_widget)
        self.show()

        # plotter and thread are none at the beginning
        self.plotter = Plotter()
        self.thread = QThread()

        # connect signals
        self.editor.returnPressed.connect(self.start_update)
        self.send_fig.connect(self.plotter.replot)
        self.plotter.return_fig.connect(self.plot)
        #move to thread and start
        self.plotter.moveToThread(self.thread)
        self.thread.start()

    def start_update(self):
        ticker = self.editor.text()
        self.editor.clear()
        self.display.setText(ticker)
        # start the plotting
        self.send_fig.emit(ticker)


    # Slot receives data and plots it
    def plot(self, data):
        # plot data
        self.myplot.line.set_data([np.arange(len(data)), data])
        # adjust axes
        self.myplot.axes.set_xlim([0,len(data) ])
        self.myplot.axes.set_ylim([ data.min(),data.max() ])
        self.myplot.draw()


class Plotter(QObject):
    return_fig = pyqtSignal(object)

    @pyqtSlot(str)
    def replot(self, ticker):
        print(ticker)
        # do some random task
        data = np.random.rand(10000,10000)
        data = data.mean(axis=1)
        self.return_fig.emit(data)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MainWindow()
    sys.exit(app.exec_())