3

I've got the following code that produces a plot that can interactively be modified. Clicking / holding the left mouse button sets the marker position, Holding the right button and moving the mouse moves the plotted data in direction x and using the mouse wheel zooms in/out. Additionally, resizing the window calls figure.tight_layout() so that the size of the axes is adapted to the window size.

# coding=utf-8
from __future__ import division

from Tkinter import *

import matplotlib
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from numpy import arange, sin, pi

matplotlib.use('TkAgg')


class PlotFrame(Frame):
    def __init__(self, master, **ops):
        Frame.__init__(self, master, **ops)

        self.figure = Figure()
        self.axes_main = self.figure.add_subplot(111)
        for i in range(10):
            t = arange(0, 300, 0.01)
            s = sin(0.02 * pi * (t + 10 * i))
            self.axes_main.plot(t, s)

        self.plot = FigureCanvasTkAgg(self.figure, master=self)
        self.plot.show()
        self.plot.get_tk_widget().pack(fill=BOTH, expand=1)

        self.dragging = False
        self.dragging_button = None
        self.mouse_pos = [0, 0]

        self.marker = self.figure.axes[0].plot((0, 0), (-1, 1), 'black', linewidth=3)[0]

        self.plot.mpl_connect('button_press_event', self.on_button_press)
        self.plot.mpl_connect('button_release_event', self.on_button_release)
        self.plot.mpl_connect('motion_notify_event', self.on_mouse_move)
        self.plot.mpl_connect('scroll_event', self.on_mouse_scroll)
        self.plot.mpl_connect("resize_event", self.on_resize)

    def on_resize(self, _):
        self.figure.tight_layout()

    def axes_size(self):
        pos = self.axes_main.get_position()
        bbox = self.figure.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted())
        width, height = bbox.width * self.figure.dpi, bbox.height * self.figure.dpi
        axis_size = [(pos.x1 - pos.x0) * width, (pos.y1 - pos.y0) * height]
        return axis_size

    def on_button_press(self, event):
        # right mouse button clicked
        if not self.dragging and event.button in (1, 3):
            self.dragging = True
            self.dragging_button = event.button
            self.mouse_pos = [event.x, event.y]
        # left mouse button clicked
        if event.button == 1 and event.xdata is not None:
            self.move_marker(event.xdata)

    def on_button_release(self, event):
        if self.dragging and self.dragging_button == event.button:
            self.dragging = False

    def on_mouse_move(self, event):
        if self.dragging and self.dragging_button == 3:
            dx = event.x - self.mouse_pos[0]
            self.mouse_pos = [event.x, event.y]
            x_min, x_max = self.figure.axes[0].get_xlim()
            x_range = x_max - x_min
            x_factor = x_range / self.axes_size()[0]
            self.figure.axes[0].set_xlim([x_min - dx * x_factor, x_max - dx * x_factor])
            self.plot.draw()
        elif self.dragging and self.dragging_button == 1:
            self.move_marker(event.xdata)

    def on_mouse_scroll(self, event):
        if event.xdata is None:
            return
        zoom_direction = -1 if event.button == 'up' else 1
        zoom_factor = 1 + .4 * zoom_direction
        x_min, x_max = self.figure.axes[0].get_xlim()
        min = event.xdata + (x_min - event.xdata) * zoom_factor
        max = event.xdata + (x_max - event.xdata) * zoom_factor
        self.figure.axes[0].set_xlim([min, max])
        self.plot.draw()

    def move_marker(self, x_position):
        y_min, y_max = self.figure.axes[0].get_ylim()
        self.marker.set_data((x_position, x_position), (y_min, y_max))
        self.plot.draw()


if __name__ == '__main__':
    gui = Tk()
    vf = PlotFrame(gui)
    vf.pack(fill=BOTH, expand=1)
    gui.mainloop()

The implementation works fine, but rendering is really slow when displaying a lot of lines. How can I make rendering faster? As you can see in the implementation above, the whole plot is drawn completely every time anything changes which shouldn't be necessary. My thoughts on this:

  • Resizing the window: draw everything
  • Zooming: draw everything
  • Moving the marker: just redraw the marker (one line) instead of drawing everything
  • Moving the plot in x direction: move the pixels currently displayed in the plot left/right and only draw pixels that are moved into the visible area

Drawing everything when resizing/zooming is fine for me, but I really need faster drawing of the latter two modifications. I already looked into matplotlib's animations, but as far as I understood, they won't help in my case. Any help is greatly appreciated, thanks!

Felix
  • 6,131
  • 4
  • 24
  • 44
  • 1
    This code is well written and works fine for me, even when adding 100 curves instead of 10. It becomes slow, when adding 1000 curves; but who would use such a plot? There would surely be an alternative if so many curves are needed (like plotting an image instead). Two answers have considered *blitting*. This is only an option for point 3 (moving the marker), the others cannot be tackled this way (as when part or all of the canvas change, it **needs** to be redrawn). Conlusion: For the usual case this works fine, and Matplotlib is not designed for speed or performance. – ImportanceOfBeingErnest Jan 27 '17 at 15:03
  • Knowing more about the usage case, might help finding better solutions. Stepping away from matplotlib, one might think about pyqtgraph, which builds on PyQt instead of Tkinter. Would that be an option? It's really much faster as it directly uses the qt canvas to draw. (see e.g. here: [Fast Live Plotting...](https://stackoverflow.com/questions/40126176/fast-live-plotting-in-matplotlib-pyplot/). – ImportanceOfBeingErnest Jan 27 '17 at 15:09

2 Answers2

1

The solution seems to be to cache elements that get redrawn as you said:

One major thing that gets redrawn is the background:

    # cache the background
    background = fig.canvas.copy_from_bbox(ax.bbox)

After caching restore it using restore region then just re-draw the points/line at every call you need

        # restore background
        fig.canvas.restore_region(background)

        # redraw just the points
        ax.draw_artist(points)

        # fill in the axes rectangle
        fig.canvas.blit(ax.bbox)
SerialDev
  • 2,777
  • 20
  • 34
  • Blitting is only an option for point 3 - moving the marker. All other command will require to redraw the background as well. Even for the marker case it's also quite complicated because you'd need not only to store the background but also all the curves. This makes me wonder, if it's really worthwile. – ImportanceOfBeingErnest Jan 27 '17 at 15:15
0

To optimize drawing blitting can be used. With it only given artists (those that were changed) will be rendered instead of the whole figure.

Motplotlib uses that technique internally in the animation module. You can use Animation class in it as a reference to implement the same behaviour in your code. Look at the _blit_draw() and several related functions after it in the sources.

wombatonfire
  • 4,585
  • 28
  • 36
  • Blitting fails, when changing the axes limits so it's really only an option for point 3 - moving the marker. I think even the animation module's blitting technique fails, when the axes limits need to be redrawn?! – ImportanceOfBeingErnest Jan 27 '17 at 15:19
  • "Fails" is a too strong word. You can clear the cache on such event and then continue blitting. This is what happens, for example, in ```Animation._handle_resize()```. After all, you do not change limits all the time. – wombatonfire Jan 27 '17 at 15:53