18

I'm using Matplotlib to allow the user to select interesting data points with mouseclicks, using a very similar method to this answer.

Effectively, a scatter plot is displayed over a heatmap image and mouse clicks can add or remove scatter points.

My data is drawn in the background using pcolormesh(), so when I update the canvas using axis.figure.canvas.draw() both the scatter points and the background heatmap are redrawn. Given the size of the heatmap, this is too slow for a usable interface.

Is there a way of selectively redrawing just the scatter points without redrawing the background?

Example Code:

points = []  # Holds a list of (x,y) scatter points 

def onclick(event):
    # Click event handler to add points
    points.append( (event.x, event.y) )
    ax.figure.canvas.draw()

fig = plt.figure()
ax = plt.figure()
# Plot the background
ax.pcolormesh(heatmap_data)

# Add the listener to handle clicks
cid = fig.canvas.mpl_connect("button_press_event", onclick)
plt.show()
Jordan
  • 183
  • 1
  • 1
  • 6
  • 2
    You want to use blitting (which means you need to use one of the Agg based backends). See http://stackoverflow.com/questions/8955869/why-is-plotting-with-matplotlib-so-slow for a start. – tacaswell Mar 26 '15 at 13:05
  • Also, have a look at this gist, if it helps. It's a somewhat more full-featured example. https://gist.github.com/joferkington/6e5bdf8600be2cf4ac79 I'll try to write up a complete answer later if Tom or someone else doesn't beat me to it. – Joe Kington Mar 26 '15 at 13:48

1 Answers1

29

Sure! What you want is blitting. If you weren't writing a gui, you could simplify some of this by using matplotlib.animation, but you'll need to handle it directly if you want things to be interactive.

In matplotlib terms, you want a combination of fig.canvas.copy_from_bbox, and then alternately call fig.canvas.restore_region(background), ax.draw_artist(what_you_want_to_draw) and fig.canvas.blit:

background = fig.canvas.copy_from_bbox(ax.bbox)

for x, y in user_interactions:
    fig.canvas.restore_region(background)
    points.append([x, y])
    scatter.set_offsets(points)
    ax.draw_artist(scatter)
    fig.canvas.blit(ax.bbox)

Simple Blitting Example: Adding Points

In your case, if you're only adding points, you can actually skip saving and restoring the background. If you go that route, though, you'll wind up with some subtle changes to the plot due to antialiased points being repeatedly redrawn on top of each other.

At any rate, here's the simplest possible example of the type of thing you're wanting. This only deals with adding points, and skips saving and restoring the background as I mentioned above:

import matplotlib.pyplot as plt
import numpy as np

def main():
    fig, ax = plt.subplots()
    ax.pcolormesh(np.random.random((100, 100)), cmap='gray')

    ClickToDrawPoints(ax).show()

class ClickToDrawPoints(object):
    def __init__(self, ax):
        self.ax = ax
        self.fig = ax.figure
        self.xy = []
        self.points = ax.scatter([], [], s=200, color='red', picker=20)
        self.fig.canvas.mpl_connect('button_press_event', self.on_click)

    def on_click(self, event):
        if event.inaxes is None:
            return
        self.xy.append([event.xdata, event.ydata])
        self.points.set_offsets(self.xy)
        self.ax.draw_artist(self.points)
        self.fig.canvas.blit(self.ax.bbox)

    def show(self):
        plt.show()

main()

Sometimes Simple is Too Simple

However, let's say we wanted to make right-clicks delete a point.

In that case, we need to be able to restore the background without redrawing it.

Ok, all well and good. We'll use something similar to the pseudocode snippet I mentioned at the top of the answer.

However, there's a caveat: If the figure is resized, we need to update the background. Similarly, if the axes is interactively zoomed/panned, we need to update the background. Basically, you need to update the background anytime the plot is drawn.

Pretty soon you need to get fairly complex.


More Complex: Adding/Dragging/Deleting Points

Here's a general example of the kind of "scaffolding" you wind up putting in place.

This is somewhat inefficient, as the plot gets drawn twice. (e.g. panning will be slow). It is possible to get around that, but I'll leave those examples for another time.

This implements adding points, dragging points, and deleting points. To add/drag a point after interactively zooming/panning, click to zoom/pan tool on the toolbar again to disable them.

This is a fairly complex example, but hopefully it gives a sense of the type of framework that one would typically build to interactively draw/drag/edit/delete matplotlib artists without redrawing the entire plot.

import numpy as np
import matplotlib.pyplot as plt

class DrawDragPoints(object):
    """
    Demonstrates a basic example of the "scaffolding" you need to efficiently
    blit drawable/draggable/deleteable artists on top of a background.
    """
    def __init__(self):
        self.fig, self.ax = self.setup_axes()
        self.xy = []
        self.tolerance = 10
        self._num_clicks = 0

        # The artist we'll be modifying...
        self.points = self.ax.scatter([], [], s=200, color='red',
                                      picker=self.tolerance, animated=True)

        connect = self.fig.canvas.mpl_connect
        connect('button_press_event', self.on_click)
        self.draw_cid = connect('draw_event', self.grab_background)

    def setup_axes(self):
        """Setup the figure/axes and plot any background artists."""
        fig, ax = plt.subplots()

        # imshow would be _much_ faster in this case, but let's deliberately
        # use something slow...
        ax.pcolormesh(np.random.random((1000, 1000)), cmap='gray')

        ax.set_title('Left click to add/drag a point\nRight-click to delete')
        return fig, ax

    def on_click(self, event):
        """Decide whether to add, delete, or drag a point."""
        # If we're using a tool on the toolbar, don't add/draw a point...
        if self.fig.canvas.toolbar._active is not None:
            return

        contains, info = self.points.contains(event)
        if contains:
            i = info['ind'][0]
            if event.button == 1:
                self.start_drag(i)
            elif event.button == 3:
                self.delete_point(i)
        else:
            self.add_point(event)

    def update(self):
        """Update the artist for any changes to self.xy."""
        self.points.set_offsets(self.xy)
        self.blit()

    def add_point(self, event):
        self.xy.append([event.xdata, event.ydata])
        self.update()

    def delete_point(self, i):
        self.xy.pop(i)
        self.update()

    def start_drag(self, i):
        """Bind mouse motion to updating a particular point."""
        self.drag_i = i
        connect = self.fig.canvas.mpl_connect
        cid1 = connect('motion_notify_event', self.drag_update)
        cid2 = connect('button_release_event', self.end_drag)
        self.drag_cids = [cid1, cid2]

    def drag_update(self, event):
        """Update a point that's being moved interactively."""
        self.xy[self.drag_i] = [event.xdata, event.ydata]
        self.update()

    def end_drag(self, event):
        """End the binding of mouse motion to a particular point."""
        for cid in self.drag_cids:
            self.fig.canvas.mpl_disconnect(cid)

    def safe_draw(self):
        """Temporarily disconnect the draw_event callback to avoid recursion"""
        canvas = self.fig.canvas
        canvas.mpl_disconnect(self.draw_cid)
        canvas.draw()
        self.draw_cid = canvas.mpl_connect('draw_event', self.grab_background)

    def grab_background(self, event=None):
        """
        When the figure is resized, hide the points, draw everything,
        and update the background.
        """
        self.points.set_visible(False)
        self.safe_draw()

        # With most backends (e.g. TkAgg), we could grab (and refresh, in
        # self.blit) self.ax.bbox instead of self.fig.bbox, but Qt4Agg, and
        # some others, requires us to update the _full_ canvas, instead.
        self.background = self.fig.canvas.copy_from_bbox(self.fig.bbox)

        self.points.set_visible(True)
        self.blit()

    def blit(self):
        """
        Efficiently update the figure, without needing to redraw the
        "background" artists.
        """
        self.fig.canvas.restore_region(self.background)
        self.ax.draw_artist(self.points)
        self.fig.canvas.blit(self.fig.bbox)

    def show(self):
        plt.show()

DrawDragPoints().show()
Joe Kington
  • 275,208
  • 71
  • 604
  • 463
  • Brilliant, thank you! Can I just briefly ask, why would `imshow` would be preferred over `pcolormesh` here? – Jordan Mar 26 '15 at 21:38
  • @Jordan - It's _much_ faster (10x-1000x) to render and scales to large arrays much better. As long as your grid cells are evenly spaced and rectangular, `imshow` (with options to make it equivalent to `pcolor`) is a better choice. The main reasons to choose `pcolormesh` are a) you want edge lines for each cell drawn, b) you're using non-linear scaling on the axes, or c) you have non-rectangular or non-regular cells. – Joe Kington Mar 26 '15 at 21:42
  • 2
    I have a problem with method ```safe_draw```. When running it gives me the error: ```QWidget::repaint: Recursive repaint detected```. When I comment out the line ```self.draw_cid = canvas.mpl_connect('draw_event, self.grab_background)``` the program kind of works ok as long as I do not resize. Any solution to this? – Bruno Vermeulen Feb 20 '19 at 10:09
  • thank you so much for this!!! it's a very nice and clear example that helped me a lot to speed up my annotations! – raphael Sep 29 '21 at 13:31
  • 1
    ... just to mention... there are now also really nice examples in the matplotlib-doc on how to implement proper blitting! https://matplotlib.org/stable/tutorials/advanced/blitting.html#class-based-example – raphael Oct 08 '21 at 16:30