test_animation.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. import os
  2. from pathlib import Path
  3. import platform
  4. import re
  5. import shutil
  6. import subprocess
  7. import sys
  8. import weakref
  9. import numpy as np
  10. import pytest
  11. import matplotlib as mpl
  12. from matplotlib import pyplot as plt
  13. from matplotlib import animation
  14. from matplotlib.testing.decorators import check_figures_equal
  15. @pytest.fixture()
  16. def anim(request):
  17. """Create a simple animation (with options)."""
  18. fig, ax = plt.subplots()
  19. line, = ax.plot([], [])
  20. ax.set_xlim(0, 10)
  21. ax.set_ylim(-1, 1)
  22. def init():
  23. line.set_data([], [])
  24. return line,
  25. def animate(i):
  26. x = np.linspace(0, 10, 100)
  27. y = np.sin(x + i)
  28. line.set_data(x, y)
  29. return line,
  30. # "klass" can be passed to determine the class returned by the fixture
  31. kwargs = dict(getattr(request, 'param', {})) # make a copy
  32. klass = kwargs.pop('klass', animation.FuncAnimation)
  33. if 'frames' not in kwargs:
  34. kwargs['frames'] = 5
  35. return klass(fig=fig, func=animate, init_func=init, **kwargs)
  36. class NullMovieWriter(animation.AbstractMovieWriter):
  37. """
  38. A minimal MovieWriter. It doesn't actually write anything.
  39. It just saves the arguments that were given to the setup() and
  40. grab_frame() methods as attributes, and counts how many times
  41. grab_frame() is called.
  42. This class doesn't have an __init__ method with the appropriate
  43. signature, and it doesn't define an isAvailable() method, so
  44. it cannot be added to the 'writers' registry.
  45. """
  46. def setup(self, fig, outfile, dpi, *args):
  47. self.fig = fig
  48. self.outfile = outfile
  49. self.dpi = dpi
  50. self.args = args
  51. self._count = 0
  52. def grab_frame(self, **savefig_kwargs):
  53. from matplotlib.animation import _validate_grabframe_kwargs
  54. _validate_grabframe_kwargs(savefig_kwargs)
  55. self.savefig_kwargs = savefig_kwargs
  56. self._count += 1
  57. def finish(self):
  58. pass
  59. def test_null_movie_writer(anim):
  60. # Test running an animation with NullMovieWriter.
  61. plt.rcParams["savefig.facecolor"] = "auto"
  62. filename = "unused.null"
  63. dpi = 50
  64. savefig_kwargs = dict(foo=0)
  65. writer = NullMovieWriter()
  66. anim.save(filename, dpi=dpi, writer=writer,
  67. savefig_kwargs=savefig_kwargs)
  68. assert writer.fig == plt.figure(1) # The figure used by anim fixture
  69. assert writer.outfile == filename
  70. assert writer.dpi == dpi
  71. assert writer.args == ()
  72. # we enrich the savefig kwargs to ensure we composite transparent
  73. # output to an opaque background
  74. for k, v in savefig_kwargs.items():
  75. assert writer.savefig_kwargs[k] == v
  76. assert writer._count == anim._save_count
  77. @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
  78. def test_animation_delete(anim):
  79. if platform.python_implementation() == 'PyPy':
  80. # Something in the test setup fixture lingers around into the test and
  81. # breaks pytest.warns on PyPy. This garbage collection fixes it.
  82. # https://foss.heptapod.net/pypy/pypy/-/issues/3536
  83. np.testing.break_cycles()
  84. anim = animation.FuncAnimation(**anim)
  85. with pytest.warns(Warning, match='Animation was deleted'):
  86. del anim
  87. np.testing.break_cycles()
  88. def test_movie_writer_dpi_default():
  89. class DummyMovieWriter(animation.MovieWriter):
  90. def _run(self):
  91. pass
  92. # Test setting up movie writer with figure.dpi default.
  93. fig = plt.figure()
  94. filename = "unused.null"
  95. fps = 5
  96. codec = "unused"
  97. bitrate = 1
  98. extra_args = ["unused"]
  99. writer = DummyMovieWriter(fps, codec, bitrate, extra_args)
  100. writer.setup(fig, filename)
  101. assert writer.dpi == fig.dpi
  102. @animation.writers.register('null')
  103. class RegisteredNullMovieWriter(NullMovieWriter):
  104. # To be able to add NullMovieWriter to the 'writers' registry,
  105. # we must define an __init__ method with a specific signature,
  106. # and we must define the class method isAvailable().
  107. # (These methods are not actually required to use an instance
  108. # of this class as the 'writer' argument of Animation.save().)
  109. def __init__(self, fps=None, codec=None, bitrate=None,
  110. extra_args=None, metadata=None):
  111. pass
  112. @classmethod
  113. def isAvailable(cls):
  114. return True
  115. WRITER_OUTPUT = [
  116. ('ffmpeg', 'movie.mp4'),
  117. ('ffmpeg_file', 'movie.mp4'),
  118. ('imagemagick', 'movie.gif'),
  119. ('imagemagick_file', 'movie.gif'),
  120. ('pillow', 'movie.gif'),
  121. ('html', 'movie.html'),
  122. ('null', 'movie.null')
  123. ]
  124. def gen_writers():
  125. for writer, output in WRITER_OUTPUT:
  126. if not animation.writers.is_available(writer):
  127. mark = pytest.mark.skip(
  128. f"writer '{writer}' not available on this system")
  129. yield pytest.param(writer, None, output, marks=[mark])
  130. yield pytest.param(writer, None, Path(output), marks=[mark])
  131. continue
  132. writer_class = animation.writers[writer]
  133. for frame_format in getattr(writer_class, 'supported_formats', [None]):
  134. yield writer, frame_format, output
  135. yield writer, frame_format, Path(output)
  136. # Smoke test for saving animations. In the future, we should probably
  137. # design more sophisticated tests which compare resulting frames a-la
  138. # matplotlib.testing.image_comparison
  139. @pytest.mark.parametrize('writer, frame_format, output', gen_writers())
  140. @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
  141. def test_save_animation_smoketest(tmpdir, writer, frame_format, output, anim):
  142. if frame_format is not None:
  143. plt.rcParams["animation.frame_format"] = frame_format
  144. anim = animation.FuncAnimation(**anim)
  145. dpi = None
  146. codec = None
  147. if writer == 'ffmpeg':
  148. # Issue #8253
  149. anim._fig.set_size_inches((10.85, 9.21))
  150. dpi = 100.
  151. codec = 'h264'
  152. # Use temporary directory for the file-based writers, which produce a file
  153. # per frame with known names.
  154. with tmpdir.as_cwd():
  155. anim.save(output, fps=30, writer=writer, bitrate=500, dpi=dpi,
  156. codec=codec)
  157. del anim
  158. @pytest.mark.parametrize('writer, frame_format, output', gen_writers())
  159. def test_grabframe(tmpdir, writer, frame_format, output):
  160. WriterClass = animation.writers[writer]
  161. if frame_format is not None:
  162. plt.rcParams["animation.frame_format"] = frame_format
  163. fig, ax = plt.subplots()
  164. dpi = None
  165. codec = None
  166. if writer == 'ffmpeg':
  167. # Issue #8253
  168. fig.set_size_inches((10.85, 9.21))
  169. dpi = 100.
  170. codec = 'h264'
  171. test_writer = WriterClass()
  172. # Use temporary directory for the file-based writers, which produce a file
  173. # per frame with known names.
  174. with tmpdir.as_cwd():
  175. with test_writer.saving(fig, output, dpi):
  176. # smoke test it works
  177. test_writer.grab_frame()
  178. for k in {'dpi', 'bbox_inches', 'format'}:
  179. with pytest.raises(
  180. TypeError,
  181. match=f"grab_frame got an unexpected keyword argument {k!r}"
  182. ):
  183. test_writer.grab_frame(**{k: object()})
  184. @pytest.mark.parametrize('writer', [
  185. pytest.param(
  186. 'ffmpeg', marks=pytest.mark.skipif(
  187. not animation.FFMpegWriter.isAvailable(),
  188. reason='Requires FFMpeg')),
  189. pytest.param(
  190. 'imagemagick', marks=pytest.mark.skipif(
  191. not animation.ImageMagickWriter.isAvailable(),
  192. reason='Requires ImageMagick')),
  193. ])
  194. @pytest.mark.parametrize('html, want', [
  195. ('none', None),
  196. ('html5', '<video width'),
  197. ('jshtml', '<script ')
  198. ])
  199. @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
  200. def test_animation_repr_html(writer, html, want, anim):
  201. if platform.python_implementation() == 'PyPy':
  202. # Something in the test setup fixture lingers around into the test and
  203. # breaks pytest.warns on PyPy. This garbage collection fixes it.
  204. # https://foss.heptapod.net/pypy/pypy/-/issues/3536
  205. np.testing.break_cycles()
  206. if (writer == 'imagemagick' and html == 'html5'
  207. # ImageMagick delegates to ffmpeg for this format.
  208. and not animation.FFMpegWriter.isAvailable()):
  209. pytest.skip('Requires FFMpeg')
  210. # create here rather than in the fixture otherwise we get __del__ warnings
  211. # about producing no output
  212. anim = animation.FuncAnimation(**anim)
  213. with plt.rc_context({'animation.writer': writer,
  214. 'animation.html': html}):
  215. html = anim._repr_html_()
  216. if want is None:
  217. assert html is None
  218. with pytest.warns(UserWarning):
  219. del anim # Animation was never run, so will warn on cleanup.
  220. np.testing.break_cycles()
  221. else:
  222. assert want in html
  223. @pytest.mark.parametrize(
  224. 'anim',
  225. [{'save_count': 10, 'frames': iter(range(5))}],
  226. indirect=['anim']
  227. )
  228. def test_no_length_frames(anim):
  229. anim.save('unused.null', writer=NullMovieWriter())
  230. def test_movie_writer_registry():
  231. assert len(animation.writers._registered) > 0
  232. mpl.rcParams['animation.ffmpeg_path'] = "not_available_ever_xxxx"
  233. assert not animation.writers.is_available("ffmpeg")
  234. # something guaranteed to be available in path and exits immediately
  235. bin = "true" if sys.platform != 'win32' else "where"
  236. mpl.rcParams['animation.ffmpeg_path'] = bin
  237. assert animation.writers.is_available("ffmpeg")
  238. @pytest.mark.parametrize(
  239. "method_name",
  240. [pytest.param("to_html5_video", marks=pytest.mark.skipif(
  241. not animation.writers.is_available(mpl.rcParams["animation.writer"]),
  242. reason="animation writer not installed")),
  243. "to_jshtml"])
  244. @pytest.mark.parametrize('anim', [dict(frames=1)], indirect=['anim'])
  245. def test_embed_limit(method_name, caplog, tmpdir, anim):
  246. caplog.set_level("WARNING")
  247. with tmpdir.as_cwd():
  248. with mpl.rc_context({"animation.embed_limit": 1e-6}): # ~1 byte.
  249. getattr(anim, method_name)()
  250. assert len(caplog.records) == 1
  251. record, = caplog.records
  252. assert (record.name == "matplotlib.animation"
  253. and record.levelname == "WARNING")
  254. @pytest.mark.parametrize(
  255. "method_name",
  256. [pytest.param("to_html5_video", marks=pytest.mark.skipif(
  257. not animation.writers.is_available(mpl.rcParams["animation.writer"]),
  258. reason="animation writer not installed")),
  259. "to_jshtml"])
  260. @pytest.mark.parametrize('anim', [dict(frames=1)], indirect=['anim'])
  261. def test_cleanup_temporaries(method_name, tmpdir, anim):
  262. with tmpdir.as_cwd():
  263. getattr(anim, method_name)()
  264. assert list(Path(str(tmpdir)).iterdir()) == []
  265. @pytest.mark.skipif(shutil.which("/bin/sh") is None, reason="requires a POSIX OS")
  266. def test_failing_ffmpeg(tmpdir, monkeypatch, anim):
  267. """
  268. Test that we correctly raise a CalledProcessError when ffmpeg fails.
  269. To do so, mock ffmpeg using a simple executable shell script that
  270. succeeds when called with no arguments (so that it gets registered by
  271. `isAvailable`), but fails otherwise, and add it to the $PATH.
  272. """
  273. with tmpdir.as_cwd():
  274. monkeypatch.setenv("PATH", ".:" + os.environ["PATH"])
  275. exe_path = Path(str(tmpdir), "ffmpeg")
  276. exe_path.write_bytes(b"#!/bin/sh\n[[ $@ -eq 0 ]]\n")
  277. os.chmod(exe_path, 0o755)
  278. with pytest.raises(subprocess.CalledProcessError):
  279. anim.save("test.mpeg")
  280. @pytest.mark.parametrize("cache_frame_data", [False, True])
  281. def test_funcanimation_cache_frame_data(cache_frame_data):
  282. fig, ax = plt.subplots()
  283. line, = ax.plot([], [])
  284. class Frame(dict):
  285. # this subclassing enables to use weakref.ref()
  286. pass
  287. def init():
  288. line.set_data([], [])
  289. return line,
  290. def animate(frame):
  291. line.set_data(frame['x'], frame['y'])
  292. return line,
  293. frames_generated = []
  294. def frames_generator():
  295. for _ in range(5):
  296. x = np.linspace(0, 10, 100)
  297. y = np.random.rand(100)
  298. frame = Frame(x=x, y=y)
  299. # collect weak references to frames
  300. # to validate their references later
  301. frames_generated.append(weakref.ref(frame))
  302. yield frame
  303. MAX_FRAMES = 100
  304. anim = animation.FuncAnimation(fig, animate, init_func=init,
  305. frames=frames_generator,
  306. cache_frame_data=cache_frame_data,
  307. save_count=MAX_FRAMES)
  308. writer = NullMovieWriter()
  309. anim.save('unused.null', writer=writer)
  310. assert len(frames_generated) == 5
  311. np.testing.break_cycles()
  312. for f in frames_generated:
  313. # If cache_frame_data is True, then the weakref should be alive;
  314. # if cache_frame_data is False, then the weakref should be dead (None).
  315. assert (f() is None) != cache_frame_data
  316. @pytest.mark.parametrize('return_value', [
  317. # User forgot to return (returns None).
  318. None,
  319. # User returned a string.
  320. 'string',
  321. # User returned an int.
  322. 1,
  323. # User returns a sequence of other objects, e.g., string instead of Artist.
  324. ('string', ),
  325. # User forgot to return a sequence (handled in `animate` below.)
  326. 'artist',
  327. ])
  328. def test_draw_frame(return_value):
  329. # test _draw_frame method
  330. fig, ax = plt.subplots()
  331. line, = ax.plot([])
  332. def animate(i):
  333. # general update func
  334. line.set_data([0, 1], [0, i])
  335. if return_value == 'artist':
  336. # *not* a sequence
  337. return line
  338. else:
  339. return return_value
  340. with pytest.raises(RuntimeError):
  341. animation.FuncAnimation(
  342. fig, animate, blit=True, cache_frame_data=False
  343. )
  344. def test_exhausted_animation(tmpdir):
  345. fig, ax = plt.subplots()
  346. def update(frame):
  347. return []
  348. anim = animation.FuncAnimation(
  349. fig, update, frames=iter(range(10)), repeat=False,
  350. cache_frame_data=False
  351. )
  352. with tmpdir.as_cwd():
  353. anim.save("test.gif", writer='pillow')
  354. with pytest.warns(UserWarning, match="exhausted"):
  355. anim._start()
  356. def test_no_frame_warning(tmpdir):
  357. fig, ax = plt.subplots()
  358. def update(frame):
  359. return []
  360. anim = animation.FuncAnimation(
  361. fig, update, frames=[], repeat=False,
  362. cache_frame_data=False
  363. )
  364. with pytest.warns(UserWarning, match="exhausted"):
  365. anim._start()
  366. @check_figures_equal(extensions=["png"])
  367. def test_animation_frame(tmpdir, fig_test, fig_ref):
  368. # Test the expected image after iterating through a few frames
  369. # we save the animation to get the iteration because we are not
  370. # in an interactive framework.
  371. ax = fig_test.add_subplot()
  372. ax.set_xlim(0, 2 * np.pi)
  373. ax.set_ylim(-1, 1)
  374. x = np.linspace(0, 2 * np.pi, 100)
  375. line, = ax.plot([], [])
  376. def init():
  377. line.set_data([], [])
  378. return line,
  379. def animate(i):
  380. line.set_data(x, np.sin(x + i / 100))
  381. return line,
  382. anim = animation.FuncAnimation(
  383. fig_test, animate, init_func=init, frames=5,
  384. blit=True, repeat=False)
  385. with tmpdir.as_cwd():
  386. anim.save("test.gif")
  387. # Reference figure without animation
  388. ax = fig_ref.add_subplot()
  389. ax.set_xlim(0, 2 * np.pi)
  390. ax.set_ylim(-1, 1)
  391. # 5th frame's data
  392. ax.plot(x, np.sin(x + 4 / 100))
  393. @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
  394. def test_save_count_override_warnings_has_length(anim):
  395. save_count = 5
  396. frames = list(range(2))
  397. match_target = (
  398. f'You passed in an explicit {save_count=} '
  399. "which is being ignored in favor of "
  400. f"{len(frames)=}."
  401. )
  402. with pytest.warns(UserWarning, match=re.escape(match_target)):
  403. anim = animation.FuncAnimation(
  404. **{**anim, 'frames': frames, 'save_count': save_count}
  405. )
  406. assert anim._save_count == len(frames)
  407. anim._init_draw()
  408. @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
  409. def test_save_count_override_warnings_scaler(anim):
  410. save_count = 5
  411. frames = 7
  412. match_target = (
  413. f'You passed in an explicit {save_count=} ' +
  414. "which is being ignored in favor of " +
  415. f"{frames=}."
  416. )
  417. with pytest.warns(UserWarning, match=re.escape(match_target)):
  418. anim = animation.FuncAnimation(
  419. **{**anim, 'frames': frames, 'save_count': save_count}
  420. )
  421. assert anim._save_count == frames
  422. anim._init_draw()
  423. @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
  424. def test_disable_cache_warning(anim):
  425. cache_frame_data = True
  426. frames = iter(range(5))
  427. match_target = (
  428. f"{frames=!r} which we can infer the length of, "
  429. "did not pass an explicit *save_count* "
  430. f"and passed {cache_frame_data=}. To avoid a possibly "
  431. "unbounded cache, frame data caching has been disabled. "
  432. "To suppress this warning either pass "
  433. "`cache_frame_data=False` or `save_count=MAX_FRAMES`."
  434. )
  435. with pytest.warns(UserWarning, match=re.escape(match_target)):
  436. anim = animation.FuncAnimation(
  437. **{**anim, 'cache_frame_data': cache_frame_data, 'frames': frames}
  438. )
  439. assert anim._cache_frame_data is False
  440. anim._init_draw()
  441. def test_movie_writer_invalid_path(anim):
  442. if sys.platform == "win32":
  443. match_str = re.escape("[WinError 3] The system cannot find the path specified:")
  444. else:
  445. match_str = re.escape("[Errno 2] No such file or directory: '/foo")
  446. with pytest.raises(FileNotFoundError, match=match_str):
  447. anim.save("/foo/bar/aardvark/thiscannotreallyexist.mp4",
  448. writer=animation.FFMpegFileWriter())