Skip to content

Implement save_gif function #301

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 15 additions & 149 deletions examples/animation.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
"cells": [
{
"cell_type": "code",
"execution_count": null,
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"from ipycanvas import Canvas, hold_canvas"
"from ipycanvas import Canvas, hold_canvas, save_gif"
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -20,7 +20,7 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 3,
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -29,36 +29,7 @@
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def draw(canvas, t):\n",
" size = 1000\n",
" step = 20\n",
" t1 = t / 1000.0\n",
"\n",
" x = 0\n",
" while x < size + step:\n",
" y = 0\n",
" while y < size + step:\n",
" x_angle = y_angle = 2 * pi\n",
"\n",
" angle = x_angle * (x / size) + y_angle * (y / size)\n",
"\n",
" particle_x = x + 20 * cos(2 * pi * t1 + angle)\n",
" particle_y = y + 20 * sin(2 * pi * t1 + angle)\n",
"\n",
" canvas.fill_circle(particle_x, particle_y, 6)\n",
"\n",
" y = y + step\n",
"\n",
" x = x + step"
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 4,
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -84,138 +55,33 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
"size = 1000\n",
"canvas = Canvas(width=size, height=size)\n",
"canvas.fill_style = \"#fcba03\"\n",
"canvas"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 1: Using `from time import sleep` and `fill_circle`\n",
"\n",
"**Worst approach: Slow locally, slow using a remote server (MyBinder)**"
"canvas.fill_style = \"#fcba03\""
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 6,
"metadata": {},
"outputs": [],
"source": [
"from time import sleep\n",
"\n",
"for i in range(200):\n",
" with hold_canvas():\n",
" canvas.clear()\n",
"\n",
" draw(canvas, i * 20.0)\n",
"# 50Hz\n",
"frequency = 20\n",
"\n",
" sleep(20 / 1000.0)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 2: Using `canvas.sleep` and `fill_circle`\n",
"\n",
"This caches the entire animation before sending it to the front-end. This results in a slow execution (caching), but it ensure a smooth animation on the front-end whichever the context (local or remote server).\n",
"\n",
"**Slow to execute, smooth animation**"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"with hold_canvas():\n",
" for i in range(200):\n",
"with save_gif(canvas, \"test.gif\", frequency=frequency):\n",
" for i in range(50):\n",
" canvas.clear()\n",
"\n",
" draw(canvas, i * 20.0)\n",
"\n",
" canvas.sleep(20)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"canvas"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 3: Using `from time import sleep` and the vectorized `fill_circles`\n",
"\n",
"**Super fast locally, can be fast on a remote server if the latency is correct**"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from time import sleep\n",
"\n",
"for i in range(200):\n",
" with hold_canvas():\n",
" canvas.clear()\n",
"\n",
" fast_draw(canvas, i * 20.0)\n",
"\n",
" sleep(20 / 1000.0)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4: Using `canvas.sleep` and the vectorized `fill_circles`\n",
"\n",
"**Best approach: Super fast locally, super fast on a remote server**"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"with hold_canvas():\n",
" for i in range(200):\n",
" canvas.clear()\n",
"\n",
" fast_draw(canvas, i * 20.0)\n",
"\n",
" canvas.sleep(20)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Conclusion\n",
"\n",
"Always use `hold_canvas`!\n",
"\n",
"As much as possible, try to use the vectorized version of the base methods if you want to exectute them multiple times (`fill_circles`, `fill_rects` etc).\n",
" fast_draw(canvas, i * frequency)\n",
"\n",
"If you can, make use of `canvas.sleep` instead of `from time import sleep` so that the entire animation is sent at once to the front-end, making a smoother animation whatever the server latency."
" canvas.frame()"
]
}
],
Expand All @@ -235,7 +101,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.0"
"version": "3.10.6"
}
},
"nbformat": 4,
Expand Down
1 change: 1 addition & 0 deletions ipycanvas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
MultiCanvas,
MultiRoughCanvas,
hold_canvas,
save_gif,
) # noqa
from ._version import __version__ # noqa

Expand Down
82 changes: 82 additions & 0 deletions ipycanvas/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
# Distributed under the terms of the Modified BSD License.

import warnings
from io import BytesIO
from contextlib import contextmanager

from PIL import Image as PILImage

import numpy as np

from traitlets import (
Expand Down Expand Up @@ -103,6 +106,7 @@
"strokeStyledPolygons",
"strokeStyledLineSegments",
"switchCanvas",
"requestImageData",
]
COMMANDS = {v: i for i, v in enumerate(_CMD_LIST)}

Expand Down Expand Up @@ -415,6 +419,11 @@ class _CanvasBase(DOMWidget):
sync=True, **bytes_serialization
)

def __init__(self, *args, **kwargs):
self._requested_frames = 0

super(_CanvasBase, self).__init__(*args, **kwargs)

def to_file(self, filename):
"""Save the current Canvas image to a PNG file.

Expand Down Expand Up @@ -582,6 +591,8 @@ class Canvas(_CanvasBase):

_client_ready_callbacks = Instance(CallbackDispatcher, ())

_image_data_callbacks = Instance(CallbackDispatcher, ())

_mouse_move_callbacks = Instance(CallbackDispatcher, ())
_mouse_down_callbacks = Instance(CallbackDispatcher, ())
_mouse_up_callbacks = Instance(CallbackDispatcher, ())
Expand Down Expand Up @@ -633,6 +644,14 @@ def sleep(self, time):
"""Make the Canvas sleep for `time` milliseconds."""
self._canvas_manager.send_draw_command(self, COMMANDS["sleep"], [time])

def frame(self):
"""Request a frame to the Canvas.

TODO
"""
self._requested_frames += 1
self._canvas_manager.send_draw_command(self, COMMANDS["requestImageData"])

# Gradient methods
def create_linear_gradient(self, x0, y0, x1, y1, color_stops):
"""Create a LinearGradient object given the start point, end point, and color stops.
Expand Down Expand Up @@ -1491,6 +1510,12 @@ def on_client_ready(self, callback, remove=False):
"""
self._client_ready_callbacks.register_callback(callback, remove=remove)

def on_new_frames(self, callback, remove=False):
"""
TODO
"""
self._image_data_callbacks.register_callback(callback, remove=remove)

def on_mouse_move(self, callback, remove=False):
"""Register a callback that will be called on mouse move."""
self._mouse_move_callbacks.register_callback(callback, remove=remove)
Expand Down Expand Up @@ -1542,6 +1567,8 @@ def __setattr__(self, name, value):
def _handle_frontend_event(self, _, content, buffers):
if content.get("event", "") == "client_ready":
self._client_ready_callbacks()
if content.get("event", "") == "image_data":
self._image_data_callbacks(buffers[0])

if content.get("event", "") == "mouse_move":
self._mouse_move_callbacks(content["x"], content["y"])
Expand Down Expand Up @@ -1817,3 +1844,58 @@ def hold_canvas(canvas=None):

if not orig_caching:
_CANVAS_MANAGER._caching = False

@contextmanager
def save_gif(
canvas,
filename,
frequency=20,
loop=0,
version="GIF87a",
):
"""Save a GIF file.

TODO example
TODO say that the file is probably not finished creating when this function returns

This uses Pillow under the hood.

Args:
canvas (Canvas or MultiCanvas): The Canvas from which to generate the GIF.
filename (str): The name of the file to save. *e.g.* "test.gif"
frames (list): The list of frames captured by ipycanvas
frequency (int): The time between frames of the GIF, in milliseconds. Defaults to 20 (50Hz).
loop (int): The number of times the GIF should loop. 0 means that it will loop forever. Defaults to 0.
version (str): The GIF version, either "GIF87a" or "GIF89a"
"""
if canvas._requested_frames != 0:
raise RuntimeError("Impossible to save a GIF for this Canvas as a GIF is already being processed.")

received_frames = []

def on_new_frames(frames):
received_frames.extend(frames)

if len(received_frames) >= canvas._requested_frames:
imgs = [PILImage.open(BytesIO(frame)) for frame in received_frames]

imgs[0].save(
filename,
append_images=imgs[1:],
save_all=True,
optimize=True,
loop=loop,
version=version,
duration=frequency,
disposal=2,
)

# Cleanup
# TODO Change this. This approach does not work if the user uses .frame() outside of save_gif
canvas._requested_frames = 0
canvas.on_new_frame(on_new_frames, remove=True)

canvas.on_new_frames(on_new_frames)

with hold_canvas():
yield
Loading