How to prevent FuncAnimation looping a single time after save

For context: I am making lots of matplotlib plots that are controlled by ipywidgets sliders using mpl-interactions. So that all the plot updating machinery is hidden away and the easiest way to update the plot is change the value of the sliders. On occasion I succeed in making a plot that provides insight into my data :open_mouth: - in these case I want to save an animation over all the values of the slider.

I’ve tried to accomplish this using FuncAnimation but even with repeat=False the animation always plays in a loop exactly once after anim.save finishes. Is there any way to prevent this?

%matplotlib ipympl
import ipywidgets as widgets
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import animation
N = 100
slider = widgets.IntSlider(value=0, min=0, max=N)
tau = np.linspace(0.1, 2, N)
fig, ax = plt.subplots()

x = np.linspace(0, 20, 500)
lines = ax.plot(x, np.sin(tau[0] * x))


def update_lines(change):
    lines[0].set_data(x, np.sin(tau[change["new"]] * x))
slider.observe(update_lines, names="value")
display(slider)
def animate(i):
    update_lines({"new": i})
    return []

anim = animation.FuncAnimation(fig, animate, frames=N, interval=20, repeat=False)
anim.save("anim.gif")
# neither of the below stop the extra loop :(
fig.canvas.flush_events()
anim.event_source.stop()

If I extract the contents of matplotlib.animation.Animation.save and modify them a bit to make a save function then I can get the behavior I want. However, this is a real bummer because:

  1. Makes use of private cbook methods
  2. I can’t return an animation object
  3. I’ll probably mess something up by accident

But fwiw just this save funciton works great (until some of the private methods change ofc):

import matplotlib as mpl
from matplotlib import cbook
from matplotlib.animation import writers


def save(
    filename,
    fig,
    update_func,
    frames=None,
    writer=None,
    interval=20,
    fps=None,
    codec=None,
    bitrate=None,
    extra_args=None,
    metadata=None,
    savefig_kwargs=None,
    dpi=None,
):
    if writer is None:
        writer = mpl.rcParams["animation.writer"]
    elif not isinstance(writer, str) and any(
        arg is not None for arg in (fps, codec, bitrate, extra_args, metadata)
    ):
        raise RuntimeError(
            "Passing in values for arguments "
            "fps, codec, bitrate, extra_args, or metadata "
            "is not supported when writer is an existing "
            "MovieWriter instance. These should instead be "
            "passed as arguments when creating the "
            "MovieWriter instance."
        )

    if savefig_kwargs is None:
        savefig_kwargs = {}

    if fps is None:
        fps = 1000.0 / interval

    # Re-use the savefig DPI for ours if none is given
    if dpi is None:
        dpi = mpl.rcParams["savefig.dpi"]
    if dpi == "figure":
        dpi = fig.dpi

    writer_kwargs = {}
    if codec is not None:
        writer_kwargs["codec"] = codec
    if bitrate is not None:
        writer_kwargs["bitrate"] = bitrate
    if extra_args is not None:
        writer_kwargs["extra_args"] = extra_args
    if metadata is not None:
        writer_kwargs["metadata"] = metadata

    # If we have the name of a writer, instantiate an instance of the
    # registered class.
    if isinstance(writer, str):
        try:
            writer_cls = writers[writer]
        except RuntimeError:  # Raised if not available.
            writer_cls = PillowWriter  # Always available.
        writer = writer_cls(fps, **writer_kwargs)

    if "bbox_inches" in savefig_kwargs:
        savefig_kwargs.pop("bbox_inches")

    with mpl.rc_context({"savefig.bbox": None}), writer.saving(
        fig, filename, dpi
    ), cbook._setattr_cm(fig.canvas, _is_saving=True, manager=None):
        frame_number = 0
        total_frames = frames
        for i in range(frames):
            update_func(i)
            writer.grab_frame(**savefig_kwargs)

Ok I dug around a bit and I think it is due to this line in the Animation init:

If I comment out that line then I get the behavior I expect. I suppose that animation is assuming that it is created before the figure has had it’s first draw. Whereas in my case the first draw had already happened, then the animation was re-triggered once it finished saving.

So I can work around this by forcing a draw and then immediately stopping the event loop.

anim = animation.FuncAnimation(fig, animate, frames=N, interval=20, repeat=False)
fig.canvas.draw()
anim.event_source.stop()
anim.save("anim.gif")

but it would maybe be a nice improvement if Animation checked once if the figure had ever been drawn and only attached the callback to the draw event if no draw had happened? A related thought is that it would be nice to have a more general way to create an animation without starting it.

Great detective work. This seems like a bug in the animation code. Can you open an issue on GitHub?