Managing dynamic plotting in matplotlib Animation

2019-01-18 17:15发布

问题:

I would like to have an iteratively plotted graph that allows for skipping to the next frame, stopping it and coming back to a previous frame.

I have looked at matplotlib Animation module which would be perfect if there was a way to implement the previous frame functionality (like run Animation backwards for a few frames when a key is pressed)

It would be nice to something like this:

def update_frame(i, data):
    fig.set_data(data[i])

but in a way that I could explicitly manage whether the i iterator increases or decreases.

Is there a way to do that in matplotlib? Should I look for a different python module?

回答1:

The FuncAnimation class allows to supply a generator function to the frames argument. This function would be expected to yield a value that is supplied to the updating function for each step of the animantion.

The FuncAnimation doc states:

frames : iterable, int, generator function, or None, optional [..]
If a generator function, then must have the signature
def gen_function() -> obj:
In all of these cases, the values in frames is simply passed through to the user-supplied func and thus can be of any type.

We can now create a generator function which yields integers either in forward or in backward direction such that the animation runs forwards or backwards . To steer the animation, we might use matplotlib.widgets.Buttons and also create a one-step forward or backward functionality. This is similar to my answer to the question about looping through a set of images.

The following is a class called Player which subclasses FuncAnimation and incoorporates all of this, allowing to start and stop the animation. It can be instantiated similarly to FuncAnimation,

ani = Player(fig, update, mini=0, maxi=10)

where update would be an updating function, expecting an integer as input, and mini and maxi denote the minimal and maximal number that the function could use. This class stores the value of the current index (self.i), such that if the animation is stopped or reverted it will restart at the current frame.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
import mpl_toolkits.axes_grid1
import matplotlib.widgets

class Player(FuncAnimation):
    def __init__(self, fig, func, frames=None, init_func=None, fargs=None,
                 save_count=None, mini=0, maxi=100, pos=(0.125, 0.92), **kwargs):
        self.i = 0
        self.min=mini
        self.max=maxi
        self.runs = True
        self.forwards = True
        self.fig = fig
        self.func = func
        self.setup(pos)
        FuncAnimation.__init__(self,self.fig, self.func, frames=self.play(), 
                                           init_func=init_func, fargs=fargs,
                                           save_count=save_count, **kwargs )    

    def play(self):
        while self.runs:
            self.i = self.i+self.forwards-(not self.forwards)
            if self.i > self.min and self.i < self.max:
                yield self.i
            else:
                self.stop()
                yield self.i

    def start(self):
        self.runs=True
        self.event_source.start()

    def stop(self, event=None):
        self.runs = False
        self.event_source.stop()

    def forward(self, event=None):
        self.forwards = True
        self.start()
    def backward(self, event=None):
        self.forwards = False
        self.start()
    def oneforward(self, event=None):
        self.forwards = True
        self.onestep()
    def onebackward(self, event=None):
        self.forwards = False
        self.onestep()

    def onestep(self):
        if self.i > self.min and self.i < self.max:
            self.i = self.i+self.forwards-(not self.forwards)
        elif self.i == self.min and self.forwards:
            self.i+=1
        elif self.i == self.max and not self.forwards:
            self.i-=1
        self.func(self.i)
        self.fig.canvas.draw_idle()

    def setup(self, pos):
        playerax = self.fig.add_axes([pos[0],pos[1], 0.22, 0.04])
        divider = mpl_toolkits.axes_grid1.make_axes_locatable(playerax)
        bax = divider.append_axes("right", size="80%", pad=0.05)
        sax = divider.append_axes("right", size="80%", pad=0.05)
        fax = divider.append_axes("right", size="80%", pad=0.05)
        ofax = divider.append_axes("right", size="100%", pad=0.05)
        self.button_oneback = matplotlib.widgets.Button(playerax, label=ur'$\u29CF$')
        self.button_back = matplotlib.widgets.Button(bax, label=ur'$\u25C0$')
        self.button_stop = matplotlib.widgets.Button(sax, label=ur'$\u25A0$')
        self.button_forward = matplotlib.widgets.Button(fax, label=ur'$\u25B6$')
        self.button_oneforward = matplotlib.widgets.Button(ofax, label=ur'$\u29D0$')
        self.button_oneback.on_clicked(self.onebackward)
        self.button_back.on_clicked(self.backward)
        self.button_stop.on_clicked(self.stop)
        self.button_forward.on_clicked(self.forward)
        self.button_oneforward.on_clicked(self.oneforward)

### using this class is as easy as using FuncAnimation:            

fig, ax = plt.subplots()
x = np.linspace(0,6*np.pi, num=100)
y = np.sin(x)

ax.plot(x,y)
point, = ax.plot([],[], marker="o", color="crimson", ms=15)

def update(i):
    point.set_data(x[i],y[i])

ani = Player(fig, update, maxi=len(y)-1)

plt.show()



回答2:

For a proper working answer with the Animation module see the answer of ImportanceOfBeingErnest

I have multiple issues with your intended functionality. How would the animation's progress work together with reversing? Would there be a video, but pressing a button starts playing it back? Or should there be individual steps of frames? I'm not sure I understand how an animation could be coupled to this reversal feature; I picture matplotlib animations to be essentially movies.

My other issue is a technical one: I'm not sure this can be done with matplotlib animations. The docs explain that a FuncAnimation superficially performs

for d in frames:
   artists = func(d, *fargs)
   fig.canvas.draw_idle()
   plt.pause(interval)

where frames is essentially an iterable. It doesn't seem straightforward to me to dynamically adjust frames during the animation, so this is a technical obstacle.

Actually, the functionality you described works much better in my head in a widget-based approach. Buttons could propagate the "animation", or you could have a check button that modifies whether the next step goes forward or backward. Here's a simple proof of concept of what I mean:

import matplotlib.pyplot as plt
from matplotlib.widgets import Button
import numpy as np # just for dummy data generation

# generate dummy data
ndat = 20
x = np.linspace(0,1,ndat)
phi = np.linspace(0,2*np.pi,100,endpoint=False)
dat = np.transpose([x[:,None]*np.cos(phi),x[:,None]*np.sin(phi)],(1,2,0))

# create figure and axes
fig = plt.figure()
ax_pl = plt.subplot2grid((5,5),(0,0),colspan=5,rowspan=3)  # axes_plot
ax_bl = plt.subplot2grid((5,5),(4,0),colspan=2,rowspan=1)  # axes_button_left
ax_br = plt.subplot2grid((5,5),(4,3),colspan=2,rowspan=1)  # axes_button_right

# create forward/backward buttons
butt_l = Button(ax_bl, '\N{leftwards arrow}') # or u'' on python 2
butt_r = Button(ax_br, '\N{rightwards arrow}') # or u'' on python 2

# create initial plot
# store index of data and handle to plot as axes property because why not
ax_pl.idat = 0
hplot = ax_pl.scatter(*dat[ax_pl.idat].T)
ax_pl.hpl = hplot
ax_pl.axis('scaled')
ax_pl.axis([dat[...,0].min(),dat[...,0].max(),
            dat[...,1].min(),dat[...,1].max()])
ax_pl.set_autoscale_on(False)
ax_pl.set_title('{}/{}'.format(ax_pl.idat,dat.shape[0]-1))

# define and hook callback for buttons
def replot_data(ax_pl,dat):
    '''replot data after button push, assumes constant data shape'''
    ax_pl.hpl.set_offsets(dat[ax_pl.idat])
    ax_pl.set_title('{}/{}'.format(ax_pl.idat,dat.shape[0]-1))
    ax_pl.get_figure().canvas.draw()

def left_onclicked(event,ax=ax_pl,dat=dat):
    '''try to decrement data index, replot if success'''
    if ax.idat > 0:
        ax.idat -= 1
        replot_data(ax,dat)

def right_onclicked(event,ax=ax_pl,dat=dat):
    '''try to increment data index, replot if success'''
    if ax.idat < dat.shape[0]-1:
        ax.idat += 1
        replot_data(ax,dat)

butt_l.on_clicked(left_onclicked)
butt_r.on_clicked(right_onclicked)

plt.show()

Note that I'm not really experienced with matplotlib widgets or GUIs in general, so don't expect the above to conform with best practices in the subject. I also added some additional parameters to be passed here and there, because I have an aversion to using global names, but this might be somewhat superstitious in this context; I honestly can't tell. Also, if you're defining these objects inside a class or a function, make sure to keep a reference to the widgets otherwise they might become unresponsive when accidentally garbage-collected.

The resulting figure has an axes for plotting the scatter plots, and there are two buttons to increment the slicing index. The data is shaped (ndat,100,2), where the trailing indices define 100 points in 2d space. A specific state:

(It doesn't have to be this ugly, I just didn't want to fiddle with the design.)

I could even imagine a setup where a timer automatically updates the plot, and the direction of the update can be set with a widget. I'm not sure how this could be done properly, but I would try to pursue this path for the kind of visualization you seem to be after.

Also note that the above approach is entirely missing blitting and other optimizations that FuncAnimation would do, but this will hopefully not interfere with your visualization.