From 021fd338b397f41f9ccd4dff37eeb523b3060155 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Wed, 26 Aug 2026 10:40:20 +1000 Subject: [PATCH 1/2] fix(pyplot): robot.plot(..., movie=...) crashes on save Two real, independent bugs in the movie-recording path, both surfaced by real reproduction of #405: - BaseRobot.plot() referenced PyPlot in an isinstance check (`isinstance(env, PyPlot)`) with no import of PyPlot anywhere in the module -- immediate NameError the instant movie= was used, on every platform. - PyPlot.getframe() called canvas.tostring_rgb(), removed from matplotlib's Agg canvas in a later release (the w_xaxis-era API this issue's traceback also shows). Replaced with the modern canvas.buffer_rgba() (RGBA -> converted to RGB for GIF saving). Verified end-to-end: a real trajectory now saves a valid multi-frame GIF via robot.plot(qt.q, backend="pyplot", movie=path). Co-Authored-By: Claude Sonnet 5 --- src/roboticstoolbox/backends/PyPlot/PyPlot.py | 3 +- src/roboticstoolbox/robot/BaseRobot.py | 3 ++ tests/test_PyPlot.py | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/roboticstoolbox/backends/PyPlot/PyPlot.py b/src/roboticstoolbox/backends/PyPlot/PyPlot.py index 3d4f042ed..1e6e34b97 100644 --- a/src/roboticstoolbox/backends/PyPlot/PyPlot.py +++ b/src/roboticstoolbox/backends/PyPlot/PyPlot.py @@ -512,7 +512,8 @@ def getframe(self): # render the frame and save as a PIL image in the list canvas = self.fig.canvas - return _pil("RGB", canvas.get_width_height(), canvas.tostring_rgb()) + image = _pil("RGBA", canvas.get_width_height(), bytes(canvas.buffer_rgba())) + return image.convert("RGB") def _push_inline_frame(self): # Push a snapshot into notebook output for inline animation. diff --git a/src/roboticstoolbox/robot/BaseRobot.py b/src/roboticstoolbox/robot/BaseRobot.py index 97008c378..b732f5c50 100644 --- a/src/roboticstoolbox/robot/BaseRobot.py +++ b/src/roboticstoolbox/robot/BaseRobot.py @@ -2058,6 +2058,9 @@ def plot( env = self._get_graphical_backend(backend) + if movie is not None: + from roboticstoolbox.backends.PyPlot import PyPlot + launch_kwargs = {} for key in ("render_mode", "inline_every_n", "inline_format", "inline_dpi"): if key in kwargs: diff --git a/tests/test_PyPlot.py b/tests/test_PyPlot.py index 80ab01729..4a1a3f8b1 100644 --- a/tests/test_PyPlot.py +++ b/tests/test_PyPlot.py @@ -69,6 +69,36 @@ def test_launch_rejects_2d_axes(self): env.launch(fig=fig, ax=ax) plt.close(fig) + def test_plot_movie(self): + # robot.plot(..., movie=...) used to crash outright: BaseRobot.plot() + # referenced PyPlot in an isinstance check with no import anywhere in + # the module (NameError), and getframe() called the long-removed + # matplotlib Agg canvas method tostring_rgb() (AttributeError on + # modern matplotlib). Covers both bugs end-to-end via a real saved + # GIF, not just "no exception raised". + import tempfile + import os + from PIL import Image + + panda = rp.models.Panda() + qt = rp.jtraj(panda.qr, panda.qz, 3) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "movie.gif") + env = panda.plot(qt.q, backend="pyplot", movie=path) + env.close() + + self.assertTrue(os.path.exists(path)) + img = Image.open(path) + n_frames = 0 + try: + while True: + img.seek(n_frames) + n_frames += 1 + except EOFError: + pass + self.assertEqual(n_frames, 3) + if __name__ == "__main__": unittest.main() From 7273a652712aead37871930d6c9d723d483c55da Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Wed, 26 Aug 2026 21:01:59 +1000 Subject: [PATCH 2/2] fix(tests): close PIL Image handle before tempdir cleanup in test_plot_movie Image.open() keeps a lazy file handle open on multi-frame images to support seek(). Windows refuses to delete a file while a handle to it is still open, so tempfile.TemporaryDirectory()'s cleanup raised PermissionError [WinError 32] on Windows CI. macOS/Linux allow unlinking an open file (POSIX semantics), which is why this only failed on windows-latest. Co-Authored-By: Claude Sonnet 5 --- tests/test_PyPlot.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_PyPlot.py b/tests/test_PyPlot.py index 4a1a3f8b1..81ea95d72 100644 --- a/tests/test_PyPlot.py +++ b/tests/test_PyPlot.py @@ -89,15 +89,15 @@ def test_plot_movie(self): env.close() self.assertTrue(os.path.exists(path)) - img = Image.open(path) - n_frames = 0 - try: - while True: - img.seek(n_frames) - n_frames += 1 - except EOFError: - pass - self.assertEqual(n_frames, 3) + with Image.open(path) as img: + n_frames = 0 + try: + while True: + img.seek(n_frames) + n_frames += 1 + except EOFError: + pass + self.assertEqual(n_frames, 3) if __name__ == "__main__":