Search code examples
pythonmatplotlibuser-interfacepyqt5embed

How to make a matplotlib plot interactive in pyqt5


Background

I'm currently working on a project where I want to embed a matplotlib plot into a pyqt5 GUI. The plot is interactive and allows for the drawing of shaded rectangles.

Problem

The problem is that the plot is not interactive when it is embedded in the pyqt window. When I run the program, line 147 in the below code (plt.show() in the mplWidget class) displays the matplotlib plot, and I can then draw a rectangle as you can see here:

matplotlib plot

However, when this window closes and the plot is embedded in the pyqt window, it becomes uneditable

pyqt uneditable plot

I want the GUI plot to function as the matplotlib figure does.

Proposed solution / Question

I know that this has to do with the fact that I have to provide the pyqt functionality with connect() statements, but I don't know where those go / how they would fit in to this program.
I don't know how to connect to a matplotlib. Do I just use connect() statements to the mplWidget class functions?
Any help is appreciated!
(I realize that I will need to take line 147 out (plt.show()) so that the figure frame does not pop up before the gui, but I just had it temporarily to show that the mpl class still functions as intended, and the problem is that it becomes "static" upon embedding)

Code

import numpy as np
import matplotlib.pyplot as plt
from PyQt5 import QtCore, QtGui, QtWidgets
from matplotlib.backends.backend_qt5agg import (FigureCanvasQTAgg, NavigationToolbar2QT as NavigationToolbar)

class topLevelWindow(QtWidgets.QMainWindow):

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

        # Add a central widget to the main window and lay it out in a grid
        self.centralwidget = QtWidgets.QWidget(self)
        self.centralwidget.setObjectName("centralwidget")
        self.gridLayout_5 = QtWidgets.QGridLayout(self.centralwidget)
        self.gridLayout_5.setObjectName("gridLayout_5")

        # Create mainTabs object as well as the displayFrame and displaySettingsFrame that go in the central widget
        # Display Frame and Display settings frames
        self.displayFrame = QtWidgets.QFrame(self.centralwidget)
        self.verticalLayout_22 = QtWidgets.QVBoxLayout(self.displayFrame)
        self.verticalLayout_22.setObjectName("verticalLayout_22")

        self.gridLayout_5.addWidget(self.displayFrame, 1, 0, 1, 1)

        self.devConstructor = mplWidget()
        self.dynamic_canvas = FigureCanvasQTAgg(self.devConstructor.fig)
        self.verticalLayout_22.addWidget(self.dynamic_canvas)

        self._dynamic_ax = self.devConstructor.ax
        self.setCentralWidget(self.centralwidget)


        # Perform final windows setup (set buddies, translate, tab order, initial tabs, etc)
        _translate = QtCore.QCoreApplication.translate
        self.setWindowTitle(_translate("MainWindow", "MainWindow"))  # Was self.setWindowTitle

        QtCore.QMetaObject.connectSlotsByName(self)

        self.show()


class mplWidget(QtWidgets.QWidget): # n.b. changed this from Object to QWidget and added a super()

    def setSnapBase(self, base):
        return lambda value: int(base*round(float(value)/base))

    def onclick(self, event):
        if self.plotSnap is False:
            self.bottomLeftX = event.xdata
            self.bottomLeftY = event.ydata
        else:
            self.calculateSnapCoordinates = self.setSnapBase(self.plotSnap)
            self.bottomLeftX = self.calculateSnapCoordinates(event.xdata)
            self.bottomLeftY = self.calculateSnapCoordinates(event.ydata)

        try:
            self.aspan.remove()
        except:
            pass

        self.moving = True

    def onrelease(self, event):
        if self.plotSnap is False:
            self.topRightX = event.xdata
            self.topRightY = event.ydata
        else:
            try:
                calculateSnapCoordinates = self.setSnapBase(self.plotSnap)
                self.topRightX = calculateSnapCoordinates(event.xdata)
                self.topRightY = calculateSnapCoordinates(event.ydata)
            except:
                pass

        self.x = np.array([self.bottomLeftX, self.bottomLeftX, self.topRightX, self.topRightX, self.bottomLeftX])
        self.y = np.array([self.bottomLeftY, self.topRightY, self.topRightY, self.bottomLeftY, self.bottomLeftY])

        self.myPlot.set_xdata(self.x)
        self.myPlot.set_ydata(self.y)
        # ax.fill_between(x, y, color=defaultColors[0, :], alpha=.25)
        ylimDiff = self.ax.get_ylim()[1] - self.ax.get_ylim()[0]
        self.aspan = self.ax.axvspan(self.bottomLeftX, self.topRightX,
                                     (self.bottomLeftY-self.ax.get_ylim()[0])/ylimDiff,
                                    (self.topRightY-self.ax.get_ylim()[0])/ylimDiff,
                                     color=self.defaultColors[0, :], alpha=.25)

        self.moving = False

        self.fig.canvas.draw()

    def onmotion(self, event):
        if self.moving is False:
            return
        if event.inaxes is None:
            return
        if event.button != 1:
            return

        if self.plotSnap is False:
            self.topRightX = event.xdata
            self.topRightY = event.ydata
        else:
            self.calculateSnapCoordinates = self.setSnapBase(self.plotSnap)
            self.topRightX = self.calculateSnapCoordinates(event.xdata)
            self.topRightY = self.calculateSnapCoordinates(event.ydata)

        self.x = np.array([self.bottomLeftX, self.bottomLeftX, self.topRightX, self.topRightX, self.bottomLeftX])
        self.y = np.array([self.bottomLeftY, self.topRightY, self.topRightY, self.bottomLeftY, self.bottomLeftY])

        self.myPlot.set_xdata(self.x)
        self.myPlot.set_ydata(self.y)

        self.fig.canvas.draw()

    def __init__(self):
        super(mplWidget, self).__init__()
        # Set default colors array
        self.defaultColors = np.array([[0, 0.4470, 0.7410], [0.8500, 0.3250, 0.0980], [0.9290, 0.6940, 0.1250],
                                  [0.4660, 0.6740, 0.1880], [0.6350, 0.0780, 0.1840], [0.4940, 0.1840, 0.5560],
                                  [0.3010, 0.7450, 0.9330]])

        # Create a figure with axes
        self.fig = plt.figure()
        self.ax = self.fig.gca()

        # Form the plot and shading
        self.bottomLeftX = 0; self.bottomLeftY = 0; self.topRightX = 0; self.topRightY = 0
        self.x = np.array([self.bottomLeftX, self.bottomLeftX, self.topRightX, self.topRightX, self.bottomLeftX])
        self.y = np.array([self.bottomLeftY, self.topRightY, self.topRightY, self.bottomLeftY, self.bottomLeftY])

        self.myPlot, = self.ax.plot(self.x, self.y, color=self.defaultColors[0, :])
        self.aspan = self.ax.axvspan(self.bottomLeftX, self.topRightX, color= self.defaultColors[0, :], alpha=0)

        # Set moving flag false (determines if mouse is being clicked and dragged inside plot). Set graph snap
        self.moving = False
        self.plotSnap = 5

        # Set up connectivity
        self.cid = self.fig.canvas.mpl_connect('button_press_event', self.onclick)
        self.cid = self.fig.canvas.mpl_connect('button_release_event', self.onrelease)
        self.cid = self.fig.canvas.mpl_connect('motion_notify_event', self.onmotion)


        # Set plot limits and show it
        plt.ylim((-100, 100))
        plt.xlim((-100, 100))
        plt.show()

if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)

    MainWindow = topLevelWindow()

    sys.exit(app.exec_())

Solution

  • In your code there are many so I will only list them:

    • If you are going to use FigureCanvasQTAgg then you should not use pyplot anymore.
    • "mplWidget" is a class whose only task is to redraw the canvas, so does it have to be a QWidget ?.
    • If you are going to compare booleans do not use "is", for example if self.plotSnap is False:, just if not self.plotSnap: also I see it illogical to think that "plotSnap" is False, if you want to disable then set an impossible value, such as 0 or negative.

    Considering the above I have made MplWidget inherit from FigureCanvasQTAgg, I have eliminated the use of pyplot:

    import numpy as np
    from PyQt5 import QtCore, QtGui, QtWidgets
    from matplotlib.backends.backend_qt5agg import (
        FigureCanvasQTAgg,
        NavigationToolbar2QT as NavigationToolbar,
    )
    from matplotlib.figure import Figure
    
    
    class MplWidget(FigureCanvasQTAgg):
        def __init__(self, parent=None):
            fig = Figure()
            super(MplWidget, self).__init__(fig)
            self.setParent(parent)
            # Set default colors array
            self.defaultColors = np.array(
                [
                    [0, 0.4470, 0.7410],
                    [0.8500, 0.3250, 0.0980],
                    [0.9290, 0.6940, 0.1250],
                    [0.4660, 0.6740, 0.1880],
                    [0.6350, 0.0780, 0.1840],
                    [0.4940, 0.1840, 0.5560],
                    [0.3010, 0.7450, 0.9330],
                ]
            )
    
            # Create a figure with axes
    
            self.ax = self.figure.add_subplot(111)
    
            # Form the plot and shading
            self.bottomLeftX = 0
            self.bottomLeftY = 0
            self.topRightX = 0
            self.topRightY = 0
            self.x = np.array(
                [
                    self.bottomLeftX,
                    self.bottomLeftX,
                    self.topRightX,
                    self.topRightX,
                    self.bottomLeftX,
                ]
            )
            self.y = np.array(
                [
                    self.bottomLeftY,
                    self.topRightY,
                    self.topRightY,
                    self.bottomLeftY,
                    self.bottomLeftY,
                ]
            )
    
            (self.myPlot,) = self.ax.plot(self.x, self.y, color=self.defaultColors[0, :])
            self.aspan = self.ax.axvspan(
                self.bottomLeftX, self.topRightX, color=self.defaultColors[0, :], alpha=0
            )
            self.ax.set_xlim((-100, 100))
            self.ax.set_ylim((-100, 100))
    
            # Set moving flag false (determines if mouse is being clicked and dragged inside plot). Set graph snap
            self.moving = False
            self.plotSnap = 5
    
            # Set up connectivity
            self.cid1 = self.mpl_connect("button_press_event", self.onclick)
            self.cid2 = self.mpl_connect("button_release_event", self.onrelease)
            self.cid3 = self.mpl_connect("motion_notify_event", self.onmotion)
    
        def setSnapBase(self, base):
            return lambda value: int(base * round(float(value) / base))
    
        def onclick(self, event):
            if self.plotSnap <= 0:
                self.bottomLeftX = event.xdata
                self.bottomLeftY = event.ydata
            else:
                self.calculateSnapCoordinates = self.setSnapBase(self.plotSnap)
                self.bottomLeftX = self.calculateSnapCoordinates(event.xdata)
                self.bottomLeftY = self.calculateSnapCoordinates(event.ydata)
    
            try:
                self.aspan.remove()
            except:
                pass
    
            self.moving = True
    
        def onrelease(self, event):
            if self.plotSnap <= 0:
                self.topRightX = event.xdata
                self.topRightY = event.ydata
            else:
                try:
                    calculateSnapCoordinates = self.setSnapBase(self.plotSnap)
                    self.topRightX = calculateSnapCoordinates(event.xdata)
                    self.topRightY = calculateSnapCoordinates(event.ydata)
                except:
                    pass
    
            self.x = np.array(
                [
                    self.bottomLeftX,
                    self.bottomLeftX,
                    self.topRightX,
                    self.topRightX,
                    self.bottomLeftX,
                ]
            )
            self.y = np.array(
                [
                    self.bottomLeftY,
                    self.topRightY,
                    self.topRightY,
                    self.bottomLeftY,
                    self.bottomLeftY,
                ]
            )
    
            self.myPlot.set_xdata(self.x)
            self.myPlot.set_ydata(self.y)
            # ax.fill_between(x, y, color=defaultColors[0, :], alpha=.25)
            ylimDiff = self.ax.get_ylim()[1] - self.ax.get_ylim()[0]
            self.aspan = self.ax.axvspan(
                self.bottomLeftX,
                self.topRightX,
                (self.bottomLeftY - self.ax.get_ylim()[0]) / ylimDiff,
                (self.topRightY - self.ax.get_ylim()[0]) / ylimDiff,
                color=self.defaultColors[0, :],
                alpha=0.25,
            )
    
            self.moving = False
            self.draw()
    
        def onmotion(self, event):
            if not self.moving:
                return
            if event.inaxes is None:
                return
            if event.button != 1:
                return
    
            if self.plotSnap <= 0:
                self.topRightX = event.xdata
                self.topRightY = event.ydata
            else:
                self.calculateSnapCoordinates = self.setSnapBase(self.plotSnap)
                self.topRightX = self.calculateSnapCoordinates(event.xdata)
                self.topRightY = self.calculateSnapCoordinates(event.ydata)
    
            self.x = np.array(
                [
                    self.bottomLeftX,
                    self.bottomLeftX,
                    self.topRightX,
                    self.topRightX,
                    self.bottomLeftX,
                ]
            )
            self.y = np.array(
                [
                    self.bottomLeftY,
                    self.topRightY,
                    self.topRightY,
                    self.bottomLeftY,
                    self.bottomLeftY,
                ]
            )
            self.myPlot.set_xdata(self.x)
            self.myPlot.set_ydata(self.y)
    
            self.draw()
    
    
    class TopLevelWindow(QtWidgets.QMainWindow):
        def __init__(self):
            super().__init__()
            self.canvas = MplWidget()
            self.setCentralWidget(self.canvas)
    
    
    if __name__ == "__main__":
        import sys
    
        app = QtWidgets.QApplication(sys.argv)
    
        w = TopLevelWindow()
        w.show()
    
        sys.exit(app.exec_())