test_animation.py 18 KB

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