Integration of blitting with PyQt5

This post is related to a recent topic where I asked help to use blit when operating with more than one interactor.

At this point I wanted to integrate the solution with PyQt5, since I am writing an interactive software.
In this example I draw two triangles and I can modify their shape by moving one of their vertices.

The code has two implementations.
By running the code with argument ‘0’, the polygon interactors are managed directly by a function.
This version works.

When running the code with argument ‘1’, the interactors are managed by a class.
Obviously, I am missing something important, since this version does not work.

Any help is welcome.

This is the code:

#!/usr/bin/env python
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtCore import QObject
import numpy as np
import sys
from matplotlib.figure import Figure
from matplotlib.lines import Line2D
from matplotlib.artist import Artist
from PyQt5.QtWidgets import (QApplication, QMainWindow, QMenu, QVBoxLayout,
                             QSizePolicy, QWidget, QPushButton)
from PyQt5.QtCore import pyqtSlot
from matplotlib.backends.backend_qt5agg import FigureCanvas
from PyQt5 import QtWidgets
from matplotlib.patches import Polygon



class InteractorManager(QObject):
    
    def __init__(self, fig, ax, interactors):
        super().__init__()

        self.interactors = interactors
        self.ax = ax
        self.fig = fig
        self.canvas = self.fig.canvas
        self.canvas.mpl_connect('draw_event', self.draw_callback)
        self.canvas.mpl_connect('motion_notify_event', self.motion_notify_callback)

    def draw_callback(self, event):
        self.background = self.canvas.copy_from_bbox(self.ax.bbox)
        for interactor in self.interactors:
            interactor.draw_callback(event)
                
    def motion_notify_callback(self, event):
        if event.inaxes is None:
            return
        elif event.button != 1:
            return
        else:
            self.canvas.restore_region(self.background)        
            for interactor in self.interactors:
                interactor.motion_notify_callback(event)
                interactor.draw_callback(event)
            self.canvas.blit(self.ax.bbox)
   

class PolygonInteractor(QObject):
    """
    A polygon editor.

    """

    showverts = True
    epsilon = 5  # max pixel distance to count as a vertex hit

    def __init__(self, ax, poly):
        super().__init__()
        self.ax = ax
        canvas = ax.figure.canvas
        self.poly = poly
        self.ax.add_patch(self.poly)
        x, y = zip(*self.poly.xy)
        color = poly.get_ec()
        self.line = Line2D(x, y,
                           marker='o', markerfacecolor=color,
                           color=color, 
                           animated=True)
        self.ax.add_line(self.line)

        self.cid = self.poly.add_callback(self.poly_changed)
        self._ind = None  # the active vert

        canvas.mpl_connect('button_press_event', self.button_press_callback)
        canvas.mpl_connect('button_release_event', self.button_release_callback)
        self.canvas = canvas
        self.artists = [self.poly, self.line]

    def poly_changed(self, poly):
        'this method is called whenever the polygon object is called'
        # only copy the artist props to the line (except visibility)
        vis = self.line.get_visible()
        Artist.update_from(self.line, poly)
        self.line.set_visible(vis)  # don't use the poly visibility state

    def get_ind_under_point(self, event):
        'get the index of the vertex under point if within epsilon tolerance'

        # display coords
        xy = np.asarray(self.poly.xy)
        xyt = self.poly.get_transform().transform(xy)
        xt, yt = xyt[:, 0], xyt[:, 1]
        d = np.hypot(xt - event.x, yt - event.y)
        indseq, = np.nonzero(d == d.min())
        ind = indseq[0]

        if d[ind] >= self.epsilon:
            ind = None

        return ind

    def draw_callback(self, event):
        self.ax.draw_artist(self.poly)
        self.ax.draw_artist(self.line)

    def button_press_callback(self, event):
        'whenever a mouse button is pressed'
        if not self.showverts:
            return
        if event.inaxes is None:
            return
        if event.button != 1:
            return
        self._ind = self.get_ind_under_point(event)

    def button_release_callback(self, event):
        'whenever a mouse button is released'
        if not self.showverts:
            return
        if event.button != 1:
            return
        self._ind = None

    def motion_notify_callback(self, event):
        'on mouse movement'
        if not self.showverts:
            return
        elif self._ind is None:
            return
        elif event.inaxes is None:
            return
        elif event.button != 1:
            return
        else:
            x, y = event.xdata, event.ydata
            self.poly.xy[self._ind] = x, y
            if self._ind == 0:
                self.poly.xy[-1] = x, y
            elif self._ind == len(self.poly.xy) - 1:
                self.poly.xy[0] = x, y
            self.line.set_data(zip(*self.poly.xy))


class GUI(QMainWindow):

    def __init__(self):
        super().__init__()
        if len(sys.argv) > 1:
            self.arg = sys.argv[1]
        else:
            self.arg = '0'
        self.left = 10
        self.top = 10
        self.title = 'PyQt5 matplotlib example with two polygons'
        self.width = 640
        self.height = 500
        self.initUI()

    def initUI(self):
        self.setWindowTitle(self.title)
        self.setGeometry(self.left, self.top, self.width, self.height)

        wid = QWidget()
        self.setCentralWidget(wid)
        mainLayout = QVBoxLayout(wid)
        

        button = QPushButton('Quit', self)
        button.setToolTip('Quit button')
        mainLayout.addWidget(button)
        button.resize(100,100)
        button.clicked.connect(self.fileQuit)

        mainplot = PlotCanvas(self, width=5, height=4, arg=self.arg)
        mainLayout.addWidget(mainplot)
        self.show()

    @pyqtSlot()
    def fileQuit(self):
        """ Quitting the program """
        self.close()


class PlotCanvas(FigureCanvas):

    def __init__(self, parent=None, width=5, height=4, dpi=100, arg=0):
        self.fig = Figure(figsize=(width,height), dpi=dpi)
        self.arg = arg
        self.ax = self.fig.add_subplot(111)
        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)
        
        FigureCanvas.setSizePolicy(self,
                                   QtWidgets.QSizePolicy.Expanding,
                                   QtWidgets.QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)
        self.plot()


        
    def plot(self):

        theta = np.arange(0, 2*np.pi, 2*np.pi/3.)
        r = 1.5
        xs = r * np.cos(theta)
        ys = r * np.sin(theta)
        poly1 = Polygon(np.column_stack([xs, ys]), animated=True,
                       color='blue', fill=False)

        theta += np.pi/3
        xs = r * np.cos(theta)
        ys = r * np.sin(theta)
        poly2 = Polygon(np.column_stack([xs, ys]), animated=True,
                        color='red', fill=False)

        p1 = PolygonInteractor(self.ax, poly1)
        p2 = PolygonInteractor(self.ax, poly2)
        interactors = [p1, p2]

        if self.arg == '0':
            # Version with function
            print('Version with function')
            self.poly_manager(interactors)
        else:
            # Version with class 
            print('Version with class')
            intManager = InteractorManager(self.fig, self.ax, interactors)

        self.ax.set_title('Click and drag a point to move it')
        self.ax.set_xlim((-2, 2))
        self.ax.set_ylim((-2, 2))
        self.ax.grid(True)
        self.draw()

    def poly_manager(self, interactors):
        self.interactors = interactors
        self.fig.canvas.mpl_connect('draw_event', self.poly_draw_callback)
        self.fig.canvas.mpl_connect('motion_notify_event', self.poly_motion_notify_callback)
      
    def poly_draw_callback(self, event):
        self.fig.background = self.fig.canvas.copy_from_bbox(self.ax.bbox)
        for interactor in self.interactors:
            interactor.draw_callback(event)
              
    def poly_motion_notify_callback(self, event):
        if event.inaxes is None:
            return
        elif event.button != 1:
            return
        else:
            self.fig.canvas.restore_region(self.fig.background)        
            for interactor in self.interactors:
                interactor.motion_notify_callback(event)
                interactor.draw_callback(event)
            self.fig.canvas.blit(self.ax.bbox)
       

if __name__ == '__main__':

    app = QApplication(sys.argv)
    ex = GUI()
    sys.exit(app.exec_())

It looks like you do not keep a reference to the interaction manager class instance so it is getting garbage collected which in turn means your callbacks don’t run.

Matplotilb’s callback registry only keeps weak-references to objects you pass in. There is some discussion to change this, but currently you must hold hard references to all of the objects you want to participate in the callbacks.

Thanks Thomas for your wise words.
It turns out that simply defining self.intManager instead of intManager makes the reference hard and the program works like a charm.
For reference, this is the working code with only the class defined.
Cheers,
Dario

#!/usr/bin/env python
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtCore import QObject
import numpy as np
import sys
from matplotlib.figure import Figure
from matplotlib.lines import Line2D
from matplotlib.artist import Artist
from PyQt5.QtWidgets import (QApplication, QMainWindow, QMenu, QVBoxLayout,
                             QSizePolicy, QWidget, QPushButton)
from PyQt5.QtCore import pyqtSlot
from matplotlib.backends.backend_qt5agg import FigureCanvas
from PyQt5 import QtWidgets
from matplotlib.patches import Polygon



class InteractorManager(QObject):
    
    def __init__(self, fig, ax, interactors):
        super().__init__()

        self.interactors = interactors
        self.ax = ax
        self.fig = fig
        self.canvas = self.fig.canvas
        self.canvas.mpl_connect('draw_event', self.draw_callback)
        self.canvas.mpl_connect('motion_notify_event', self.motion_notify_callback)

    def draw_callback(self, event):
        self.background = self.canvas.copy_from_bbox(self.ax.bbox)
        for interactor in self.interactors:
            interactor.draw_callback(event)
                
    def motion_notify_callback(self, event):
        if event.inaxes is None:
            return
        elif event.button != 1:
            return
        else:
            self.canvas.restore_region(self.background)        
            for interactor in self.interactors:
                interactor.motion_notify_callback(event)
                interactor.draw_callback(event)
            self.canvas.blit(self.ax.bbox)
   

class PolygonInteractor(QObject):
    """
    A polygon editor.

    """

    showverts = True
    epsilon = 5  # max pixel distance to count as a vertex hit

    def __init__(self, ax, poly):
        super().__init__()
        self.ax = ax
        canvas = ax.figure.canvas
        self.poly = poly
        self.ax.add_patch(self.poly)
        x, y = zip(*self.poly.xy)
        color = poly.get_ec()
        self.line = Line2D(x, y,
                           marker='o', markerfacecolor=color,
                           color=color, 
                           animated=True)
        self.ax.add_line(self.line)

        self.cid = self.poly.add_callback(self.poly_changed)
        self._ind = None  # the active vert

        canvas.mpl_connect('button_press_event', self.button_press_callback)
        canvas.mpl_connect('button_release_event', self.button_release_callback)
        self.canvas = canvas
        self.artists = [self.poly, self.line]

    def poly_changed(self, poly):
        'this method is called whenever the polygon object is called'
        # only copy the artist props to the line (except visibility)
        vis = self.line.get_visible()
        Artist.update_from(self.line, poly)
        self.line.set_visible(vis)  # don't use the poly visibility state

    def get_ind_under_point(self, event):
        'get the index of the vertex under point if within epsilon tolerance'

        # display coords
        xy = np.asarray(self.poly.xy)
        xyt = self.poly.get_transform().transform(xy)
        xt, yt = xyt[:, 0], xyt[:, 1]
        d = np.hypot(xt - event.x, yt - event.y)
        indseq, = np.nonzero(d == d.min())
        ind = indseq[0]

        if d[ind] >= self.epsilon:
            ind = None

        return ind

    def draw_callback(self, event):
        self.ax.draw_artist(self.poly)
        self.ax.draw_artist(self.line)

    def button_press_callback(self, event):
        'whenever a mouse button is pressed'
        if not self.showverts:
            return
        if event.inaxes is None:
            return
        if event.button != 1:
            return
        self._ind = self.get_ind_under_point(event)

    def button_release_callback(self, event):
        'whenever a mouse button is released'
        if not self.showverts:
            return
        if event.button != 1:
            return
        self._ind = None

    def motion_notify_callback(self, event):
        'on mouse movement'
        if not self.showverts:
            return
        elif self._ind is None:
            return
        elif event.inaxes is None:
            return
        elif event.button != 1:
            return
        else:
            x, y = event.xdata, event.ydata
            self.poly.xy[self._ind] = x, y
            if self._ind == 0:
                self.poly.xy[-1] = x, y
            elif self._ind == len(self.poly.xy) - 1:
                self.poly.xy[0] = x, y
            self.line.set_data(zip(*self.poly.xy))


class GUI(QMainWindow):

    def __init__(self):
        super().__init__()
        if len(sys.argv) > 1:
            self.arg = sys.argv[1]
        else:
            self.arg = '0'
        self.left = 10
        self.top = 10
        self.title = 'PyQt5 matplotlib example with two polygons'
        self.width = 640
        self.height = 500
        self.initUI()

    def initUI(self):
        self.setWindowTitle(self.title)
        self.setGeometry(self.left, self.top, self.width, self.height)

        wid = QWidget()
        self.setCentralWidget(wid)
        mainLayout = QVBoxLayout(wid)
        

        button = QPushButton('Quit', self)
        button.setToolTip('Quit button')
        mainLayout.addWidget(button)
        button.resize(100,100)
        button.clicked.connect(self.fileQuit)

        mainplot = PlotCanvas(self, width=5, height=4, arg=self.arg)
        mainLayout.addWidget(mainplot)
        self.show()

    @pyqtSlot()
    def fileQuit(self):
        """ Quitting the program """
        self.close()


class PlotCanvas(FigureCanvas):

    def __init__(self, parent=None, width=5, height=4, dpi=100, arg=0):
        self.fig = Figure(figsize=(width,height), dpi=dpi)
        self.arg = arg
        self.ax = self.fig.add_subplot(111)
        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)
        
        FigureCanvas.setSizePolicy(self,
                                   QtWidgets.QSizePolicy.Expanding,
                                   QtWidgets.QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)
        self.plot()


        
    def plot(self):

        theta = np.arange(0, 2*np.pi, 2*np.pi/3.)
        r = 1.5
        xs = r * np.cos(theta)
        ys = r * np.sin(theta)
        poly1 = Polygon(np.column_stack([xs, ys]), animated=True,
                       color='blue', fill=False)

        theta += np.pi/3
        xs = r * np.cos(theta)
        ys = r * np.sin(theta)
        poly2 = Polygon(np.column_stack([xs, ys]), animated=True,
                        color='red', fill=False)

        p1 = PolygonInteractor(self.ax, poly1)
        p2 = PolygonInteractor(self.ax, poly2)
        interactors = [p1, p2]

        # self is used to have a hard reference, otherwise the
        # manager is garbaged collected and does not work
        self.intManager = InteractorManager(self.fig, self.ax, interactors)

        self.ax.set_title('Click and drag a point to move it')
        self.ax.set_xlim((-2, 2))
        self.ax.set_ylim((-2, 2))
        self.ax.grid(True)
        self.draw()

        
if __name__ == '__main__':

    app = QApplication(sys.argv)
    ex = GUI()
    sys.exit(app.exec_())