Tuesday, November 10, 2020

Learn PyQt: Plotting with Matplotlib — Create PyQt5 plots with the popular Python plotting library

In a previous tutorial we covered plotting in PyQt5 using PyQtGraph. PyQtGraph uses the Qt vector-based QGraphicsScene to draw plots and provides a great interface for interactive and high performance plotting.

However, there is another plotting library for Python which is used far more widely, and which offers a richer assortment of plots — Matplotlib. If you're migrating an existing data analysis tool to a PyQt GUI, or if you simply want to have access to the array of plot abilities that Matplotlib offers, then you'll want to know how to include Matplotlib plots within your application.

In this tutorial we'll cover how to embed Matplotlib plots in your PyQt applications

Many other Python libraries — such as seaborn and pandas— make use of the Matplotlib backend for plotting. These plots can be embedded in PyQt5 in the same way shown here, and the reference to the axes passed when plotting. There is a pandas example at the end of this tutorial.

Installing Matplotlib

The following examples assume you have Matplotlib installed. If not you can install it as normal using Pip, with the following —

bash
pip install matplotlib

A simple example

The following minimal example sets up a Matplotlib canvas FigureCanvasQTAgg which creates the Figure and adds a single set of axes to it. This canvas object is also a QWidget and so can be embedded straight into an application as any other Qt widget.

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

from PyQt5 import QtCore, QtWidgets

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure


class MplCanvas(FigureCanvasQTAgg):

    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, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        # Create the maptlotlib FigureCanvas object, 
        # which defines a single set of axes as self.axes.
        sc = MplCanvas(self, width=5, height=4, dpi=100)
        sc.axes.plot([0,1,2,3,4], [10,1,20,3,40])
        self.setCentralWidget(sc)

        self.show()


app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
app.exec_()

In this case we're adding our MplCanvas widget as the central widget on the window with .setCentralWidget(). This means it will take up the entirety of the window and resize together with it. The plotted data [0,1,2,3,4], [10,1,20,3,40] is provided as two lists of numbers (x and y respectively) as required by the .plot method.

Basic plot with embedded Matplotlib Basic plot with embedded Matplotlib

Plot controls

Plots from Matplotlib displayed in PyQt5 are actually rendered as simple (bitmap) images by the Agg backend. The FigureCanvasQTAgg class wraps this backend and displays the resulting image on a Qt widget. The effect of this architecture is that Qt is unaware of the positions of lines and other plot elements — only the x, y coordinates of any clicks and mouse movements over the widget.

However, support for handling Qt mouse events and transforming them into interactions on the plot is built into Matplotlib. This can be controlled through a custom toolbar which can be added to your applications alongside the plot. In this section we'll look at adding these controls so we can zoom, pan and get data from embedded Matplotlib plots.

The complete code, importing the toolbar widget NavigationToolbar2QT and adding it to the interface within a QVBoxLayout, is shown below —

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

from PyQt5 import QtCore, QtGui, QtWidgets

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg, NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure


class MplCanvas(FigureCanvasQTAgg):

    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, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        sc = MplCanvas(self, width=5, height=4, dpi=100)
        sc.axes.plot([0,1,2,3,4], [10,1,20,3,40])

        # Create toolbar, passing canvas as first parament, parent (self, the MainWindow) as second.
        toolbar = NavigationToolbar(sc, self)

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(toolbar)
        layout.addWidget(sc)

        # Create a placeholder widget to hold our toolbar and canvas.
        widget = QtWidgets.QWidget()
        widget.setLayout(layout)
        self.setCentralWidget(widget)

        self.show()


app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
app.exec_()

We'll step through the changes.

First we import the toolbar widget from matplotlib.backends.backend_qt5agg.NavigationToolbar2QT renaming it with the simpler name NavigationToolbar. We create an instance of the toolbar by calling NavigationToolbar with two parameters, first the canvas object sc and then the parent for the toolbar, in this case our MainWindow object self. Passing in the canvas links the created toolbar to it, allowing it to be controlled. The resulting toolbar object is stored in the variable toolbar.

We need to add two widgets to the window, one above the other, so we use a QVBoxLayout. First we add our toolbar widget toolbar and then the canvas widget sc to this layout. Finally, we set this layout onto our simple widget layout container which is set as the central widget for the window.

Running the above code will produce the following window layout, showing the plot at the bottom and the controls on top as a toolbar.

Matplotlib plot with Toolbar Matplotlib plot with Toolbar

The buttons provided by NavigationToolbar2QT allow the following actions —

  • Home, Back/Forward, Pan & Zoom which are used to navigate through the plots. The Back/Forward buttons can step backwards and forwards through navigation steps, for example zooming in and then clicking Back will return to the previous zoom. Home returns to the initial state of the plot.
  • Plot margin/position configuration which can adjust the plot within the window.
  • Axis/curve style editor, where you can modify plot titles and axes scales, along with setting plot line colours and line styles. The colour selection uses the platform-default colour picker, allowing any available colours to be selected.
  • Save, to save the resulting figure as an image (all Matplotlib supported formats).

A few of these configuration settings are shown below.

Matplotlib figure options Matplotlib figure options

Matplotlib curves figure options Matplotlib curves figure options

For more information on navigating and configuring Matplotlib plots, take a look at the official Matplotlib toolbar documentation.

Updating plots

Quite often in applications you'll want to update the data shown in plots, whether in response to input from the user or updated data from an API. There are two ways to update plots in Matplotlib, either

  1. clearing and redrawing the canvas (simpler, but slower) or,
  2. by keeping a reference to the plotted line and updating the data.

If performance is important to your app it is recommended you do the latter, but the first is simpler.

Clear and redraw

We start with the simple clear-and-redraw method first below —

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

from PyQt5 import QtCore, QtWidgets

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
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, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

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

        n_data = 50
        self.xdata = list(range(n_data))
        self.ydata = [random.randint(0, 10) for i in range(n_data)]
        self.update_plot()

        self.show()

        # Setup a timer to trigger the redraw by calling update_plot.
        self.timer = QtCore.QTimer()
        self.timer.setInterval(100)
        self.timer.timeout.connect(self.update_plot)
        self.timer.start()

    def update_plot(self):
        # Drop off the first y element, append a new one.
        self.ydata = self.ydata[1:] + [random.randint(0, 10)]
        self.canvas.axes.cla()  # Clear the canvas.
        self.canvas.axes.plot(self.xdata, self.ydata, 'r')
        # Trigger the canvas to update and redraw.
        self.canvas.draw()


app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
app.exec_()

In this example we've moved the plotting to a update_plot method to keep it self-contained. In this method we take our ydata array and drop off the first value with [1:] then append a new random integer between 0 and 10. This has the effect of scrolling the data to the left.

To redraw we simply call axes.cla() to clear the axes (the entire canvas) and the axes.plot(…) to re-plot the data, including the updated values. The resulting canvas is then redrawn to the widget by calling canvas.draw().

The update_plot method is called every 100 msec using a QTimer. The clear-and-refresh method is fast enough to keep a plot updated at this rate, but as we'll see shortly, falters as the speed increases.

In-place redraw

The changes required to update the plotted lines in-place are fairly minimal, requiring only an addition variable to store and retrieve the reference to the plotted line. The updated MainWindow code is shown below.

python
class MainWindow(QtWidgets.QMainWindow):

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

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

        n_data = 50
        self.xdata = list(range(n_data))
        self.ydata = [random.randint(0, 10) for i in range(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()

        # Setup a timer to trigger the redraw by calling update_plot.
        self.timer = QtCore.QTimer()
        self.timer.setInterval(100)
        self.timer.timeout.connect(self.update_plot)
        self.timer.start()

    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:
            # First time we have no plot reference, so do a normal plot.
            # .plot returns a list of line <reference>s, as we're
            # only getting one we can take the first element.
            plot_refs = self.canvas.axes.plot(self.xdata, self.ydata, 'r')
            self._plot_ref = plot_refs[0]
        else:
            # We have a reference, we can use it to update the data for that line.
            self._plot_ref.set_ydata(self.ydata)

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

First, we need a variable to hold a reference to the plotted line we want to update, which here we're calling _plot_ref. We initialize self._plot_ref with None so we can check its value later to determine if the line has already been drawn — if the value is still None we have not yet drawn the line.

T> If you were drawing multiple lines you would probably want to use a list or dict data structure to store the multiple references and keep track of which is which.

Finally, we update the ydata data as we did before, rotating it to the left and appending a new random value. Then we either —

  1. if self._plotref is None (i.e. we have not yet drawn the line) draw the line and store the reference in self._plot_ref, or
  2. update the line in place by calling self._plot_ref.set_ydata(self.ydata)

We obtain a reference to the plotted when calling .plot. However .plot returns a list (to support cases where a single .plot call can draw more than one line). In our case we're only plotting a single line, so we simply want the first element in that list – a single Line2D object. To get this single value into our variable we can assign to a temporary variable plot_refs and then assign the first element to our self._plot_ref variable.

python
plot_refs = self.canvas.axes.plot(self.xdata, self.ydata, 'r')
self._plot_ref = plot_refs[0]

You could also use tuple-unpacking, picking off the first (and only) element in the list with —

python
self._plot_ref, = self.canvas.axes.plot(self.xdata, self.ydata, 'r')

If you run the resulting code, there will be no noticeable difference in performance between this and the previous method at this speed. However if you attempt to update the plot faster (e.g. down to every 10 msec) you'll start to notice that clearing the plot and re-drawing takes longer, and the updates do not keep up with the timer. We can compare the two versions below —

Both using 100 msec timer, clear-and-redraw on the left, update-in-place on the right.

Both using 10 msec timer, clear-and-redraw on the left, update-in-place on the right.

Whether this performance difference is enough to matter in your application depends on what you're building, and should be weighed against the added complication of keeping and managing the references to plotted lines.

Embedding plots from Pandas

Pandas is a Python package focused on working with table (data frames) and series data structures, which is particularly useful for data analysis workflows. It comes with built-in support for plotting with Matplotlib and here we'll take a quick look at how to embed these plots into PyQt5. With this you will be able to start building PyQt5 data-analysis applications built around Pandas.

Pandas plotting functions are directly accessible from the DataFrame objects. The function signature is quite complex, giving a lot of options to control how the plots will be drawn.

python
DataFrame.plot(
    x=None, y=None, kind='line', ax=None, subplots=False,
    sharex=None, sharey=False, layout=None, figsize=None,
    use_index=True, title=None, grid=None, legend=True, style=None,
    logx=False, logy=False, loglog=False, xticks=None, yticks=None,
    xlim=None, ylim=None, rot=None, fontsize=None, colormap=None,
    table=False, yerr=None, xerr=None, secondary_y=False, 
    sort_columns=False, **kwargs
)

The parameter we're most interested in is ax which allows us to pass in our own matplotlib.Axes instance on which Pandas will plot the DataFrame.

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

from PyQt5 import QtCore, QtWidgets

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure

import pandas as pd


class MplCanvas(FigureCanvasQTAgg):

    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, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        # Create the maptlotlib FigureCanvas object, 
        # which defines a single set of axes as self.axes.
        sc = MplCanvas(self, width=5, height=4, dpi=100)

        # Create our pandas DataFrame with some simple
        # data and headers.
        df = pd.DataFrame([
           [0, 10], [5, 15], [2, 20], [15, 25], [4, 10], 
        ], columns=['A', 'B'])

        # plot the pandas DataFrame, passing in the 
        # matplotlib Canvas axes.
        df.plot(ax=sc.axes)

        self.setCentralWidget(sc)
        self.show()


app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
app.exec_()

The key step here is passing the canvas axes in when calling the plot method on the DataFrameon the line df.plot(ax=sc.axes). You can use this same pattern to update the plot any time, although bear in mind that Pandas clears and redraws the entire canvas, meaning that it is not ideal for high performance plotting.

The resulting plot generated through Pandas is shown below —

Pandas plot embedded in PyQt5 Pandas plot embedded in PyQt5

Just as before, you can add the Matplotlib toolbar and control support to plots generated using Pandas, allowing you to zoom/pan and modify them live. The following code combines our earlier toolbar example with the Pandas example.

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

from PyQt5 import QtCore, QtWidgets

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg, NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure

import pandas as pd


class MplCanvas(FigureCanvasQTAgg):

    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, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        # Create the maptlotlib FigureCanvas object, 
        # which defines a single set of axes as self.axes.
        sc = MplCanvas(self, width=5, height=4, dpi=100)

        # Create our pandas DataFrame with some simple
        # data and headers.
        df = pd.DataFrame([
           [0, 10], [5, 15], [2, 20], [15, 25], [4, 10], 
        ], columns=['A', 'B'])

        # plot the pandas DataFrame, passing in the 
        # matplotlib Canvas axes.
        df.plot(ax=sc.axes)

        # Create toolbar, passing canvas as first parament, parent (self, the MainWindow) as second.
        toolbar = NavigationToolbar(sc, self)

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(toolbar)
        layout.addWidget(sc)

        # Create a placeholder widget to hold our toolbar and canvas.
        widget = QtWidgets.QWidget()
        widget.setLayout(layout)
        self.setCentralWidget(widget)
        self.show()


app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
app.exec_()

Running this you should see the following window, showing a Pandas plot embedded in PyQt5 alongside the Matplotlib toolbar.

Pandas plot with Matplotlib toolbar Pandas plot with Matplotlib toolbar

What's next

In this tutorial we looked at how you can embed Matplotlib plots in your PyQt5 applications. Being able to use Matplotlib plots in your applications allows you to create custom data analysis and visualization tools from Python.

Matplotlib is a huge library and too big to cover in detail here. If you're not familiar with Matplotlib plotting and want to give it a try, take a look at the documentation and example plots to see what is possible. If you are familiar with it you should now be able to put those skills to work in your PyQt5 apps!



from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...