Spaces:
Running
Running
| import os | |
| from pathlib import Path | |
| import platform | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import weakref | |
| import numpy as np | |
| import pytest | |
| import matplotlib as mpl | |
| from matplotlib import pyplot as plt | |
| from matplotlib import animation | |
| from matplotlib.animation import PillowWriter | |
| from matplotlib.testing.decorators import check_figures_equal | |
| def anim(request): | |
| """Create a simple animation (with options).""" | |
| fig, ax = plt.subplots() | |
| line, = ax.plot([], []) | |
| ax.set_xlim(0, 10) | |
| ax.set_ylim(-1, 1) | |
| def init(): | |
| line.set_data([], []) | |
| return line, | |
| def animate(i): | |
| x = np.linspace(0, 10, 100) | |
| y = np.sin(x + i) | |
| line.set_data(x, y) | |
| return line, | |
| # "klass" can be passed to determine the class returned by the fixture | |
| kwargs = dict(getattr(request, 'param', {})) # make a copy | |
| klass = kwargs.pop('klass', animation.FuncAnimation) | |
| if 'frames' not in kwargs: | |
| kwargs['frames'] = 5 | |
| return klass(fig=fig, func=animate, init_func=init, **kwargs) | |
| class NullMovieWriter(animation.AbstractMovieWriter): | |
| """ | |
| A minimal MovieWriter. It doesn't actually write anything. | |
| It just saves the arguments that were given to the setup() and | |
| grab_frame() methods as attributes, and counts how many times | |
| grab_frame() is called. | |
| This class doesn't have an __init__ method with the appropriate | |
| signature, and it doesn't define an isAvailable() method, so | |
| it cannot be added to the 'writers' registry. | |
| """ | |
| def setup(self, fig, outfile, dpi, *args): | |
| self.fig = fig | |
| self.outfile = outfile | |
| self.dpi = dpi | |
| self.args = args | |
| self._count = 0 | |
| def grab_frame(self, **savefig_kwargs): | |
| from matplotlib.animation import _validate_grabframe_kwargs | |
| _validate_grabframe_kwargs(savefig_kwargs) | |
| self.savefig_kwargs = savefig_kwargs | |
| self._count += 1 | |
| def finish(self): | |
| pass | |
| def test_null_movie_writer(anim): | |
| # Test running an animation with NullMovieWriter. | |
| plt.rcParams["savefig.facecolor"] = "auto" | |
| filename = "unused.null" | |
| dpi = 50 | |
| savefig_kwargs = dict(foo=0) | |
| writer = NullMovieWriter() | |
| anim.save(filename, dpi=dpi, writer=writer, | |
| savefig_kwargs=savefig_kwargs) | |
| assert writer.fig == plt.figure(1) # The figure used by anim fixture | |
| assert writer.outfile == filename | |
| assert writer.dpi == dpi | |
| assert writer.args == () | |
| # we enrich the savefig kwargs to ensure we composite transparent | |
| # output to an opaque background | |
| for k, v in savefig_kwargs.items(): | |
| assert writer.savefig_kwargs[k] == v | |
| assert writer._count == anim._save_count | |
| def test_animation_delete(anim): | |
| if platform.python_implementation() == 'PyPy': | |
| # Something in the test setup fixture lingers around into the test and | |
| # breaks pytest.warns on PyPy. This garbage collection fixes it. | |
| # https://foss.heptapod.net/pypy/pypy/-/issues/3536 | |
| np.testing.break_cycles() | |
| anim = animation.FuncAnimation(**anim) | |
| with pytest.warns(Warning, match='Animation was deleted'): | |
| del anim | |
| np.testing.break_cycles() | |
| def test_movie_writer_dpi_default(): | |
| class DummyMovieWriter(animation.MovieWriter): | |
| def _run(self): | |
| pass | |
| # Test setting up movie writer with figure.dpi default. | |
| fig = plt.figure() | |
| filename = "unused.null" | |
| fps = 5 | |
| codec = "unused" | |
| bitrate = 1 | |
| extra_args = ["unused"] | |
| writer = DummyMovieWriter(fps, codec, bitrate, extra_args) | |
| writer.setup(fig, filename) | |
| assert writer.dpi == fig.dpi | |
| class RegisteredNullMovieWriter(NullMovieWriter): | |
| # To be able to add NullMovieWriter to the 'writers' registry, | |
| # we must define an __init__ method with a specific signature, | |
| # and we must define the class method isAvailable(). | |
| # (These methods are not actually required to use an instance | |
| # of this class as the 'writer' argument of Animation.save().) | |
| def __init__(self, fps=None, codec=None, bitrate=None, | |
| extra_args=None, metadata=None): | |
| pass | |
| def isAvailable(cls): | |
| return True | |
| WRITER_OUTPUT = [ | |
| ('ffmpeg', 'movie.mp4'), | |
| ('ffmpeg_file', 'movie.mp4'), | |
| ('imagemagick', 'movie.gif'), | |
| ('imagemagick_file', 'movie.gif'), | |
| ('pillow', 'movie.gif'), | |
| ('html', 'movie.html'), | |
| ('null', 'movie.null') | |
| ] | |
| def gen_writers(): | |
| for writer, output in WRITER_OUTPUT: | |
| if not animation.writers.is_available(writer): | |
| mark = pytest.mark.skip( | |
| f"writer '{writer}' not available on this system") | |
| yield pytest.param(writer, None, output, marks=[mark]) | |
| yield pytest.param(writer, None, Path(output), marks=[mark]) | |
| continue | |
| writer_class = animation.writers[writer] | |
| for frame_format in getattr(writer_class, 'supported_formats', [None]): | |
| yield writer, frame_format, output | |
| yield writer, frame_format, Path(output) | |
| # Smoke test for saving animations. In the future, we should probably | |
| # design more sophisticated tests which compare resulting frames a-la | |
| # matplotlib.testing.image_comparison | |
| def test_save_animation_smoketest(tmpdir, writer, frame_format, output, anim): | |
| if frame_format is not None: | |
| plt.rcParams["animation.frame_format"] = frame_format | |
| anim = animation.FuncAnimation(**anim) | |
| dpi = None | |
| codec = None | |
| if writer == 'ffmpeg': | |
| # Issue #8253 | |
| anim._fig.set_size_inches((10.85, 9.21)) | |
| dpi = 100. | |
| codec = 'h264' | |
| # Use temporary directory for the file-based writers, which produce a file | |
| # per frame with known names. | |
| with tmpdir.as_cwd(): | |
| anim.save(output, fps=30, writer=writer, bitrate=500, dpi=dpi, | |
| codec=codec) | |
| del anim | |
| def test_grabframe(tmpdir, writer, frame_format, output): | |
| WriterClass = animation.writers[writer] | |
| if frame_format is not None: | |
| plt.rcParams["animation.frame_format"] = frame_format | |
| fig, ax = plt.subplots() | |
| dpi = None | |
| codec = None | |
| if writer == 'ffmpeg': | |
| # Issue #8253 | |
| fig.set_size_inches((10.85, 9.21)) | |
| dpi = 100. | |
| codec = 'h264' | |
| test_writer = WriterClass() | |
| # Use temporary directory for the file-based writers, which produce a file | |
| # per frame with known names. | |
| with tmpdir.as_cwd(): | |
| with test_writer.saving(fig, output, dpi): | |
| # smoke test it works | |
| test_writer.grab_frame() | |
| for k in {'dpi', 'bbox_inches', 'format'}: | |
| with pytest.raises( | |
| TypeError, | |
| match=f"grab_frame got an unexpected keyword argument {k!r}" | |
| ): | |
| test_writer.grab_frame(**{k: object()}) | |
| def test_animation_repr_html(writer, html, want, anim): | |
| if platform.python_implementation() == 'PyPy': | |
| # Something in the test setup fixture lingers around into the test and | |
| # breaks pytest.warns on PyPy. This garbage collection fixes it. | |
| # https://foss.heptapod.net/pypy/pypy/-/issues/3536 | |
| np.testing.break_cycles() | |
| if (writer == 'imagemagick' and html == 'html5' | |
| # ImageMagick delegates to ffmpeg for this format. | |
| and not animation.FFMpegWriter.isAvailable()): | |
| pytest.skip('Requires FFMpeg') | |
| # create here rather than in the fixture otherwise we get __del__ warnings | |
| # about producing no output | |
| anim = animation.FuncAnimation(**anim) | |
| with plt.rc_context({'animation.writer': writer, | |
| 'animation.html': html}): | |
| html = anim._repr_html_() | |
| if want is None: | |
| assert html is None | |
| with pytest.warns(UserWarning): | |
| del anim # Animation was never run, so will warn on cleanup. | |
| np.testing.break_cycles() | |
| else: | |
| assert want in html | |
| def test_no_length_frames(anim): | |
| anim.save('unused.null', writer=NullMovieWriter()) | |
| def test_movie_writer_registry(): | |
| assert len(animation.writers._registered) > 0 | |
| mpl.rcParams['animation.ffmpeg_path'] = "not_available_ever_xxxx" | |
| assert not animation.writers.is_available("ffmpeg") | |
| # something guaranteed to be available in path and exits immediately | |
| bin = "true" if sys.platform != 'win32' else "where" | |
| mpl.rcParams['animation.ffmpeg_path'] = bin | |
| assert animation.writers.is_available("ffmpeg") | |
| def test_embed_limit(method_name, caplog, tmpdir, anim): | |
| caplog.set_level("WARNING") | |
| with tmpdir.as_cwd(): | |
| with mpl.rc_context({"animation.embed_limit": 1e-6}): # ~1 byte. | |
| getattr(anim, method_name)() | |
| assert len(caplog.records) == 1 | |
| record, = caplog.records | |
| assert (record.name == "matplotlib.animation" | |
| and record.levelname == "WARNING") | |
| def test_cleanup_temporaries(method_name, tmpdir, anim): | |
| with tmpdir.as_cwd(): | |
| getattr(anim, method_name)() | |
| assert list(Path(str(tmpdir)).iterdir()) == [] | |
| def test_failing_ffmpeg(tmpdir, monkeypatch, anim): | |
| """ | |
| Test that we correctly raise a CalledProcessError when ffmpeg fails. | |
| To do so, mock ffmpeg using a simple executable shell script that | |
| succeeds when called with no arguments (so that it gets registered by | |
| `isAvailable`), but fails otherwise, and add it to the $PATH. | |
| """ | |
| with tmpdir.as_cwd(): | |
| monkeypatch.setenv("PATH", ".:" + os.environ["PATH"]) | |
| exe_path = Path(str(tmpdir), "ffmpeg") | |
| exe_path.write_bytes(b"#!/bin/sh\n[[ $@ -eq 0 ]]\n") | |
| os.chmod(exe_path, 0o755) | |
| with pytest.raises(subprocess.CalledProcessError): | |
| anim.save("test.mpeg") | |
| def test_funcanimation_cache_frame_data(cache_frame_data): | |
| fig, ax = plt.subplots() | |
| line, = ax.plot([], []) | |
| class Frame(dict): | |
| # this subclassing enables to use weakref.ref() | |
| pass | |
| def init(): | |
| line.set_data([], []) | |
| return line, | |
| def animate(frame): | |
| line.set_data(frame['x'], frame['y']) | |
| return line, | |
| frames_generated = [] | |
| def frames_generator(): | |
| for _ in range(5): | |
| x = np.linspace(0, 10, 100) | |
| y = np.random.rand(100) | |
| frame = Frame(x=x, y=y) | |
| # collect weak references to frames | |
| # to validate their references later | |
| frames_generated.append(weakref.ref(frame)) | |
| yield frame | |
| MAX_FRAMES = 100 | |
| anim = animation.FuncAnimation(fig, animate, init_func=init, | |
| frames=frames_generator, | |
| cache_frame_data=cache_frame_data, | |
| save_count=MAX_FRAMES) | |
| writer = NullMovieWriter() | |
| anim.save('unused.null', writer=writer) | |
| assert len(frames_generated) == 5 | |
| np.testing.break_cycles() | |
| for f in frames_generated: | |
| # If cache_frame_data is True, then the weakref should be alive; | |
| # if cache_frame_data is False, then the weakref should be dead (None). | |
| assert (f() is None) != cache_frame_data | |
| def test_draw_frame(return_value): | |
| # test _draw_frame method | |
| fig, ax = plt.subplots() | |
| line, = ax.plot([]) | |
| def animate(i): | |
| # general update func | |
| line.set_data([0, 1], [0, i]) | |
| if return_value == 'artist': | |
| # *not* a sequence | |
| return line | |
| else: | |
| return return_value | |
| with pytest.raises(RuntimeError): | |
| animation.FuncAnimation( | |
| fig, animate, blit=True, cache_frame_data=False | |
| ) | |
| def test_exhausted_animation(tmpdir): | |
| fig, ax = plt.subplots() | |
| def update(frame): | |
| return [] | |
| anim = animation.FuncAnimation( | |
| fig, update, frames=iter(range(10)), repeat=False, | |
| cache_frame_data=False | |
| ) | |
| with tmpdir.as_cwd(): | |
| anim.save("test.gif", writer='pillow') | |
| with pytest.warns(UserWarning, match="exhausted"): | |
| anim._start() | |
| def test_no_frame_warning(tmpdir): | |
| fig, ax = plt.subplots() | |
| def update(frame): | |
| return [] | |
| anim = animation.FuncAnimation( | |
| fig, update, frames=[], repeat=False, | |
| cache_frame_data=False | |
| ) | |
| with pytest.warns(UserWarning, match="exhausted"): | |
| anim._start() | |
| def test_animation_frame(tmpdir, fig_test, fig_ref): | |
| # Test the expected image after iterating through a few frames | |
| # we save the animation to get the iteration because we are not | |
| # in an interactive framework. | |
| ax = fig_test.add_subplot() | |
| ax.set_xlim(0, 2 * np.pi) | |
| ax.set_ylim(-1, 1) | |
| x = np.linspace(0, 2 * np.pi, 100) | |
| line, = ax.plot([], []) | |
| def init(): | |
| line.set_data([], []) | |
| return line, | |
| def animate(i): | |
| line.set_data(x, np.sin(x + i / 100)) | |
| return line, | |
| anim = animation.FuncAnimation( | |
| fig_test, animate, init_func=init, frames=5, | |
| blit=True, repeat=False) | |
| with tmpdir.as_cwd(): | |
| anim.save("test.gif") | |
| # Reference figure without animation | |
| ax = fig_ref.add_subplot() | |
| ax.set_xlim(0, 2 * np.pi) | |
| ax.set_ylim(-1, 1) | |
| # 5th frame's data | |
| ax.plot(x, np.sin(x + 4 / 100)) | |
| def test_save_count_override_warnings_has_length(anim): | |
| save_count = 5 | |
| frames = list(range(2)) | |
| match_target = ( | |
| f'You passed in an explicit {save_count=} ' | |
| "which is being ignored in favor of " | |
| f"{len(frames)=}." | |
| ) | |
| with pytest.warns(UserWarning, match=re.escape(match_target)): | |
| anim = animation.FuncAnimation( | |
| **{**anim, 'frames': frames, 'save_count': save_count} | |
| ) | |
| assert anim._save_count == len(frames) | |
| anim._init_draw() | |
| def test_save_count_override_warnings_scaler(anim): | |
| save_count = 5 | |
| frames = 7 | |
| match_target = ( | |
| f'You passed in an explicit {save_count=} ' + | |
| "which is being ignored in favor of " + | |
| f"{frames=}." | |
| ) | |
| with pytest.warns(UserWarning, match=re.escape(match_target)): | |
| anim = animation.FuncAnimation( | |
| **{**anim, 'frames': frames, 'save_count': save_count} | |
| ) | |
| assert anim._save_count == frames | |
| anim._init_draw() | |
| def test_disable_cache_warning(anim): | |
| cache_frame_data = True | |
| frames = iter(range(5)) | |
| match_target = ( | |
| f"{frames=!r} which we can infer the length of, " | |
| "did not pass an explicit *save_count* " | |
| f"and passed {cache_frame_data=}. To avoid a possibly " | |
| "unbounded cache, frame data caching has been disabled. " | |
| "To suppress this warning either pass " | |
| "`cache_frame_data=False` or `save_count=MAX_FRAMES`." | |
| ) | |
| with pytest.warns(UserWarning, match=re.escape(match_target)): | |
| anim = animation.FuncAnimation( | |
| **{**anim, 'cache_frame_data': cache_frame_data, 'frames': frames} | |
| ) | |
| assert anim._cache_frame_data is False | |
| anim._init_draw() | |
| def test_movie_writer_invalid_path(anim): | |
| if sys.platform == "win32": | |
| match_str = r"\[WinError 3] .*'\\\\foo\\\\bar\\\\aardvark'" | |
| else: | |
| match_str = r"\[Errno 2] .*'/foo" | |
| with pytest.raises(FileNotFoundError, match=match_str): | |
| anim.save("/foo/bar/aardvark/thiscannotreallyexist.mp4", | |
| writer=animation.FFMpegFileWriter()) | |
| def test_animation_with_transparency(): | |
| """Test animation exhaustion with transparency using PillowWriter directly""" | |
| fig, ax = plt.subplots() | |
| rect = plt.Rectangle((0, 0), 1, 1, color='red', alpha=0.5) | |
| ax.add_patch(rect) | |
| ax.set_xlim(0, 1) | |
| ax.set_ylim(0, 1) | |
| writer = PillowWriter(fps=30) | |
| writer.setup(fig, 'unused.gif', dpi=100) | |
| writer.grab_frame(transparent=True) | |
| frame = writer._frames[-1] | |
| # Check that the alpha channel is not 255, so frame has transparency | |
| assert frame.getextrema()[3][0] < 255 | |
| plt.close(fig) | |