Skip to content

Commit 2a6c314

Browse files
Adding example notebook with interactivity in animations
Showing one way to use a separate thread for an animation loop.
1 parent 30ccca2 commit 2a6c314

File tree

1 file changed

+380
-0
lines changed

1 file changed

+380
-0
lines changed
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Interaction during ipycanvas Animations\n",
8+
"\n",
9+
"It can be fun to modify animations as they run, but reacting to user input during a standard animation loop can be difficult. This notebook shows one way to get around this by having the animation loop run in a separate thread."
10+
]
11+
},
12+
{
13+
"cell_type": "code",
14+
"execution_count": 1,
15+
"metadata": {},
16+
"outputs": [],
17+
"source": [
18+
"import numpy as np\n",
19+
"from time import sleep\n",
20+
"from threading import Event, Thread\n",
21+
"from ipycanvas import Canvas, hold_canvas\n",
22+
"from ipywidgets import Label, HTML, Button, HBox"
23+
]
24+
},
25+
{
26+
"cell_type": "markdown",
27+
"metadata": {},
28+
"source": [
29+
"For this example, we will simulate a number of particles, re-drawing them all each frame with the `fill_rects` function. "
30+
]
31+
},
32+
{
33+
"cell_type": "code",
34+
"execution_count": 2,
35+
"metadata": {},
36+
"outputs": [],
37+
"source": [
38+
"#Create our particles\n",
39+
"n_particles = 4100\n",
40+
"x = np.array(np.random.rayleigh(250, n_particles))\n",
41+
"y = np.array(np.random.rayleigh(250, n_particles))\n",
42+
"size = np.random.randint(1, 3, n_particles)"
43+
]
44+
},
45+
{
46+
"cell_type": "code",
47+
"execution_count": 3,
48+
"metadata": {},
49+
"outputs": [],
50+
"source": [
51+
"# Create the canvas\n",
52+
"canvas = Canvas(width=500, height=200)"
53+
]
54+
},
55+
{
56+
"cell_type": "code",
57+
"execution_count": 4,
58+
"metadata": {},
59+
"outputs": [],
60+
"source": [
61+
"# Some useful functions that will be used in our animation loop:\n",
62+
"\n",
63+
"# Draw the particles to the canvas\n",
64+
"def draw_particles():\n",
65+
" with hold_canvas(canvas):\n",
66+
" canvas.clear() # Clear the old animation step \n",
67+
" canvas.fill_style = 'green' \n",
68+
" canvas.fill_rects(x, y, size) # Draw the new frame \n",
69+
"\n",
70+
"# Calculate new locations for the particles\n",
71+
"def update_particle_locations():\n",
72+
" global x, y\n",
73+
" x = (x+1)%500\n",
74+
" y = (y +np.cos(x/100))%200"
75+
]
76+
},
77+
{
78+
"cell_type": "markdown",
79+
"metadata": {},
80+
"source": [
81+
"The key goal here is to enable interactivity. In this case this is done by creating a function that adds some new particles around a location, and setting this function as a callback that will be called when we click on the canvas."
82+
]
83+
},
84+
{
85+
"cell_type": "code",
86+
"execution_count": 5,
87+
"metadata": {},
88+
"outputs": [],
89+
"source": [
90+
"def handle_mouse_down(xpos, ypos):\n",
91+
" global x, y\n",
92+
" x_new = np.array(np.random.rayleigh(30, 100))+xpos-15\n",
93+
" y_new = np.array(np.random.rayleigh(30, 100))+ypos-15\n",
94+
" x = np.concatenate([x[-4000:], x_new])\n",
95+
" y = np.concatenate([y[-4000:], y_new])\n",
96+
" size = np.random.randint(1, 3, len(x))\n",
97+
" draw_particles()\n",
98+
"\n",
99+
"# Register mouse click callback\n",
100+
"canvas.on_mouse_down(handle_mouse_down)"
101+
]
102+
},
103+
{
104+
"cell_type": "markdown",
105+
"metadata": {},
106+
"source": [
107+
"With the setup out of the way, we can draw the first frame to the canvas and display it. Try clicking on the canvas to see the `handle_mouse_down` function's effect."
108+
]
109+
},
110+
{
111+
"cell_type": "code",
112+
"execution_count": 6,
113+
"metadata": {},
114+
"outputs": [
115+
{
116+
"data": {
117+
"application/vnd.jupyter.widget-view+json": {
118+
"model_id": "69eea7ee3ce24b7c8c40ae2334fdf6fd",
119+
"version_major": 2,
120+
"version_minor": 0
121+
},
122+
"text/plain": [
123+
"Canvas(height=200, width=500)"
124+
]
125+
},
126+
"metadata": {},
127+
"output_type": "display_data"
128+
}
129+
],
130+
"source": [
131+
"draw_particles() # Running once to draw the first frame\n",
132+
"display(canvas)"
133+
]
134+
},
135+
{
136+
"cell_type": "markdown",
137+
"metadata": {},
138+
"source": [
139+
"We get 'interactivity' as hoped - every click results in some new particles being added at that location. However, the issue comes when we try to interact while animating. Run the following loop and try to click on the canvas as it runs:"
140+
]
141+
},
142+
{
143+
"cell_type": "code",
144+
"execution_count": 7,
145+
"metadata": {},
146+
"outputs": [],
147+
"source": [
148+
"for i in range(200):\n",
149+
" update_particle_locations()\n",
150+
" draw_particles()\n",
151+
" sleep(0.02)"
152+
]
153+
},
154+
{
155+
"cell_type": "markdown",
156+
"metadata": {},
157+
"source": [
158+
"Nothing happens in response to clicks while the animation is running. It is only once the loop finishes that we see flash of activity as some of the callbacks are belatedly run all at once. Now run the following cell and try interacting again:"
159+
]
160+
},
161+
{
162+
"cell_type": "code",
163+
"execution_count": 8,
164+
"metadata": {},
165+
"outputs": [],
166+
"source": [
167+
"stopped = Event()\n",
168+
"def loop():\n",
169+
" while not stopped.wait(0.02): # 0.02 secs -> ~50fps\n",
170+
" update_particle_locations()\n",
171+
" draw_particles()\n",
172+
"Thread(target=loop).start() "
173+
]
174+
},
175+
{
176+
"cell_type": "markdown",
177+
"metadata": {},
178+
"source": [
179+
"And to stop the animation:"
180+
]
181+
},
182+
{
183+
"cell_type": "code",
184+
"execution_count": 9,
185+
"metadata": {},
186+
"outputs": [],
187+
"source": [
188+
"stopped.set()"
189+
]
190+
},
191+
{
192+
"cell_type": "markdown",
193+
"metadata": {},
194+
"source": [
195+
"### Explanation\n",
196+
"\n",
197+
"In the first animation attempt, everything is running in a single thread. This means we cannot run callbacks (or any other code for that matter) while the animation loop runs. To fix this, we create and run a new Thread to run the animation loop (`Thread(target=loop).start() `). This frees the main thread to do other things, such as handle interaction. \n",
198+
"\n",
199+
"An `Event` has `set` and `clear` methods that allow for safe communication between threads. In this case our `loop` function that is running in its own thread will terminate if we run `stopped.set()`, allowing us to stop the animation. "
200+
]
201+
},
202+
{
203+
"cell_type": "markdown",
204+
"metadata": {},
205+
"source": [
206+
"# Worked Example\n",
207+
"\n",
208+
"For this example, as before, we will be updating some particles and adding new ones on mouse clicks. We use perlin noise to determine particle direction. The following cell results in a `perlin` function that takes in an x, y coordinate and returns a value - see [here](https://johnowhitaker.github.io/days_of_code/Playing_with_Perlin.html) for a more readable implementation."
209+
]
210+
},
211+
{
212+
"cell_type": "code",
213+
"execution_count": 10,
214+
"metadata": {},
215+
"outputs": [
216+
{
217+
"data": {
218+
"text/plain": [
219+
"0.3321607704321994"
220+
]
221+
},
222+
"execution_count": 10,
223+
"metadata": {},
224+
"output_type": "execute_result"
225+
}
226+
],
227+
"source": [
228+
"# Quick and dirty perlin noise function\n",
229+
"def dgg(ix, iy, x, y):\n",
230+
" random = 2920 * np.sin(ix * 21942 + iy * 171324 + 8912) * np.cos(ix * 23157 * iy * 217832 + 9758)\n",
231+
" return ((x - ix)*np.cos(random) + (y - iy)*np.sin(random));\n",
232+
"def perlin(x,y):\n",
233+
" x0, y0 = np.array(x).astype(int),np.array(y).astype(int)\n",
234+
" n0, n1 = dgg(x0, y0, x, y), dgg((x0 + 1), y0, x, y)\n",
235+
" ix0 = (n1 - n0) * (x - x0) + n0\n",
236+
" n0, n1 = dgg(x0, (y0 + 1), x, y), dgg((x0 + 1), (y0 + 1), x, y)\n",
237+
" return (((n1 - n0) * (x - x0) + n0) - ix0) * (y-y0) + ix0\n",
238+
"perlin(0.3, 0.2) # Given x and y coords it generates a value"
239+
]
240+
},
241+
{
242+
"cell_type": "markdown",
243+
"metadata": {},
244+
"source": [
245+
"Now, as before, we set up our initial particle locations, define functions to update their positions (this time based on the perlin function) and to draw the particles to the canvas, register a callback to handle mouse clicks and start an animation thread. In addition, we add buttons to stop the animation, to restart it and to randomize the particle locations. Notice that when the start button is clicked it checks that the thread is not already running - removing this check will allow you to start several threads, all updating the animation, which results in a higher and higher framerate."
246+
]
247+
},
248+
{
249+
"cell_type": "code",
250+
"execution_count": 11,
251+
"metadata": {},
252+
"outputs": [
253+
{
254+
"data": {
255+
"application/vnd.jupyter.widget-view+json": {
256+
"model_id": "77d8c2b6293f4e5c8dbbbad5f9a4a994",
257+
"version_major": 2,
258+
"version_minor": 0
259+
},
260+
"text/plain": [
261+
"Canvas(width=500)"
262+
]
263+
},
264+
"metadata": {},
265+
"output_type": "display_data"
266+
},
267+
{
268+
"data": {
269+
"application/vnd.jupyter.widget-view+json": {
270+
"model_id": "13fca3feb1de4cc0b2079f8bf55bd782",
271+
"version_major": 2,
272+
"version_minor": 0
273+
},
274+
"text/plain": [
275+
"HBox(children=(Button(description='Start', style=ButtonStyle()), Button(description='Stop', style=ButtonStyle(…"
276+
]
277+
},
278+
"metadata": {},
279+
"output_type": "display_data"
280+
}
281+
],
282+
"source": [
283+
"# Setting up the canvas\n",
284+
"canvas2 = Canvas(width=500, height=500) \n",
285+
"\n",
286+
"#Some particles (as before)\n",
287+
"n_particles = 4100\n",
288+
"x = np.array(np.random.rayleigh(250, n_particles))\n",
289+
"y = np.array(np.random.rayleigh(250, n_particles))\n",
290+
"size = np.random.randint(1, 3, n_particles)\n",
291+
"\n",
292+
"\n",
293+
"def handle_mouse_down(xpos, ypos):\n",
294+
" global x, y\n",
295+
" x_new = np.array(np.random.rayleigh(30, 100))+xpos-15\n",
296+
" y_new = np.array(np.random.rayleigh(30, 100))+ypos-15\n",
297+
" x = np.concatenate([x[-4000:], x_new])\n",
298+
" y = np.concatenate([y[-4000:], y_new])\n",
299+
" size = np.random.randint(1, 3, len(x))\n",
300+
" draw_particles()\n",
301+
"\n",
302+
"# Register mouse click callback\n",
303+
"canvas2.on_mouse_down(handle_mouse_down)\n",
304+
"\n",
305+
"def draw_particles():\n",
306+
" with hold_canvas(canvas2):\n",
307+
" canvas2.clear() # Clear the old animation step \n",
308+
" canvas2.fill_style = 'green' \n",
309+
" canvas2.fill_rects(x, y, size) # Draw the new frame \n",
310+
" \n",
311+
"def update_particle_locations():\n",
312+
" global x, y\n",
313+
" angles = perlin(x/35, y/35) * 3\n",
314+
" x += np.sin(angles)*0.3\n",
315+
" y += np.cos(angles)*0.3\n",
316+
"\n",
317+
"stopped = Event()\n",
318+
"def loop():\n",
319+
" while not stopped.wait(0.02): # the first call is in `interval` secs\n",
320+
" update_particle_locations()\n",
321+
" draw_particles() \n",
322+
"Thread(target=loop).start() # Start it by default\n",
323+
"\n",
324+
"start_btn = Button(description='Start')\n",
325+
"def start(btn):\n",
326+
" if stopped.isSet():\n",
327+
" stopped.clear()\n",
328+
" Thread(target=loop).start() \n",
329+
"start_btn.on_click(start)\n",
330+
"\n",
331+
"stop_btn = Button(description='Stop')\n",
332+
"def stop(btn):\n",
333+
" if not stopped.isSet():\n",
334+
" stopped.set()\n",
335+
"stop_btn.on_click(stop)\n",
336+
"\n",
337+
"reset_btn = Button(description='Randomize Particles')\n",
338+
"def reset(btn):\n",
339+
" global x, y, size\n",
340+
" x = np.array(np.random.rayleigh(250, n_particles))\n",
341+
" y = np.array(np.random.rayleigh(250, n_particles))\n",
342+
" size = np.random.randint(1, 3, n_particles)\n",
343+
" draw_particles()\n",
344+
"reset_btn.on_click(reset)\n",
345+
"\n",
346+
"display(canvas2, HBox([start_btn, stop_btn, reset_btn]))"
347+
]
348+
},
349+
{
350+
"cell_type": "markdown",
351+
"metadata": {},
352+
"source": [
353+
"# Conclusions\n",
354+
"\n",
355+
"Using threads allows us to create interactive animations that don't block our main thread from executing. This notebook shows one possible way to achieve this - there are alternatives such as the advanced python scheduler or the use of threading.Timer as in [this StackOverflow answer](https://stackoverflow.com/a/13151299). The latter was used in [the notebook](https://johnowhitaker.github.io/days_of_code/Interaction_with_ipycanvas.html) from which this example is derived. Whatever the specifics of the implementation, the core idea is to offload the animation loop to its own thread in some way."
356+
]
357+
}
358+
],
359+
"metadata": {
360+
"kernelspec": {
361+
"display_name": "Python 3",
362+
"language": "python",
363+
"name": "python3"
364+
},
365+
"language_info": {
366+
"codemirror_mode": {
367+
"name": "ipython",
368+
"version": 3
369+
},
370+
"file_extension": ".py",
371+
"mimetype": "text/x-python",
372+
"name": "python",
373+
"nbconvert_exporter": "python",
374+
"pygments_lexer": "ipython3",
375+
"version": "3.7.6"
376+
}
377+
},
378+
"nbformat": 4,
379+
"nbformat_minor": 4
380+
}

0 commit comments

Comments
 (0)