__del__( self )

Eaten by the Python.

Vector Animations With Python

| Comments

I am a big fan of Dave Whyte’s vector animations, like this one:

It was generated using a special animation language called Processing (here is Dave’s code). While it seems powerful, Processing it is not very elegant in my opinion ; this post shows how to do similar animations using two Python libraries, Gizeh (for the graphics) and MoviePy (for the animations).

Gizeh and Moviepy

Gizeh is a Python library I wrote on top of cairocffi ( a binding of the popular Cairo library) to make it more intuitive. To make a picture with Gizeh you create a surface, draw on it, and export it:

1
2
3
4
5
6
7
8
import gizeh
surface = gizeh.Surface(width=320, height=260) # dimensions in pixel
circle = gizeh.circle (r=40, # radius, in pixels
                       xy= [156, 200], # coordinates of the center
                       fill= (1,0,0)) # 'red' in RGB coordinates
circle.draw( surface ) # draw the circle on the surface
surface.get_npimage() # export as a numpy array (we will use that)
surface.write_to_png("my_drawing.png") # export as a PNG

We obtain this magnificent Japanese flag:

To make an animation with MoviePy, you write a function make_frame which, given some time t, returns the video frame at time t:

1
2
3
4
5
6
7
8
9
10
from moviepy.editor import VideoClip

def make_frame(t):
    """ returns a numpy array of the frame at time t """
    # ... here make a frame_for_time_t
    return frame_for_time_t

clip = VideoClip(make_frame, duration=3) # 3-second clip
clip.write_videofile("my_animation.mp4", fps=24) # export as video
clip.write_gif("my_animation.gif", fps=24) # export as GIF

Example 1

We start with an easy one. In make_frame we just draw a red circle, whose radius depends on the time t:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import gizeh
import moviepy.editor as mpy

W,H = 128,128 # width, height, in pixels
duration = 2 # duration of the clip, in seconds

def make_frame(t):
    surface = gizeh.Surface(W,H)
    radius = W*(1+ (t*(duration-t))**2 )/6
    circle = gizeh.circle(radius, xy = (W/2,H/2), fill=(1,0,0))
    circle.draw(surface)
    return surface.get_npimage()

clip = mpy.VideoClip(make_frame, duration=duration)
clip.write_gif("circle.gif",fps=15, opt="OptimizePlus", fuzz=10)

Example 2

Now there are more circles, and we start to see the interest of making animations programmatically using for loops. The useful function polar2cart transforms polar coordinates (radius, angle) into cartesian coordinates (x,y).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import numpy as np
import gizeh
import moviepy.editor as mpy

W,H = 128,128
duration = 2
ncircles = 20 # Number of circles

def make_frame(t):

    surface = gizeh.Surface(W,H)

    for i in range(ncircles):
        angle = 2*np.pi*(1.0*i/ncircles+t/duration)
        center = W*( 0.5+ gizeh.polar2cart(0.1,angle))
        circle = gizeh.circle(r= W*(1.0-1.0*i/ncircles),
                              xy= center, fill= (i%2,i%2,i%2))
        circle.draw(surface)

    return surface.get_npimage()

clip = mpy.VideoClip(make_frame, duration=duration)
clip.write_gif("circles.gif",fps=15, opt="OptimizePlus", fuzz=10)

Example 3

Here we fill the circles with a slightly excentred radial gradient to give and impression of volume. The colors, initial positions and centers of rotations of the circles are chosen randomly at the beginning.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import gizeh as gz
import numpy as np
import moviepy.editor as mpy

W = H = 150
D = 2 # duration
nballs=60

# generate random values of radius, color, center
radii = np.random.randint(.1*W,.2*W, nballs)
colors = np.random.rand(nballs,3)
centers = np.random.randint(0,W, (nballs,2))

def make_frame(t):
    surface = gz.Surface(W,H)
    for r,color, center in zip(radii, colors, centers):
        angle = 2*np.pi*(t/D*np.sign(color[0]-.5)+color[1])
        xy = center+gz.polar2cart(W/5,angle) # center of the ball
        gradient = gz.ColorGradient(type="radial",
                     stops_colors = [(0,color),(1,color/10)],
                     xy1=[0.3,-0.3], xy2=[0,0], xy3 = [0,1.4])
        ball = gz.circle(r=1, fill=gradient).scale(r).translate(xy)
        ball.draw(surface)
    return surface.get_npimage()

clip = mpy.VideoClip(make_frame, duration=D)
clip.write_gif("balls.gif",fps=15,opt="OptimizePlus")

Example 4

The shadow is done using a circle with radial fading black gradient whose intensity diminishes when the ball is higher, for more realism (?). The shadow is then squeezed vertically using scale(r,r/2), so that its width is twice its height.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import numpy as np
import gizeh as gz
import moviepy.editor as mpy

W,H = 200,75
D = 3
r = 10 # radius of the ball
DJ, HJ = 50, 35 # distance and height of the jumps
ground = 0.75*H # y-coordinate of the ground


gradient = gz.ColorGradient(type="radial",
                stops_colors = [(0,(1,0,0)),(1,(0.1,0,0))],
                xy1=[0.3,-0.3], xy2=[0,0], xy3 = [0,1.4])

def make_frame(t):
    surface = gz.Surface(W,H, bg_color=(1,1,1))
    x = (-W/3)+(5*W/3)*(t/D)
    y = ground - HJ*4*(x % DJ)*(DJ-(x % DJ))/DJ**2
    coef = (HJ-y)/HJ
    shadow_gradient = gz.ColorGradient(type="radial",
                stops_colors = [(0,(0,0,0,.2-coef/5)),(1,(0,0,0,0))],
                xy1=[0,0], xy2=[0,0], xy3 = [0,1.4])
    shadow = (gz.circle(r=(1-coef/4), fill=shadow_gradient)
               .scale(r,r/2).translate((x,ground+r/2)))
    shadow.draw(surface)
    ball = gz.circle(r=1, fill=gradient).scale(r).translate((x,y))
    ball.draw(surface)
    return surface.get_npimage()

clip = mpy.VideoClip(make_frame, duration=D)
clip.write_gif("bouncingball.gif",fps=25, opt="OptimizePlus")

Example 5

This is a derivative of the Dave Whyte animation shown in the introduction. It is made of stacked circles moving towards the picture’s border, with carefully chosen sizes, starting times, and colors (I say carefully chosen because it took me a few dozens random tries). The black around the picture is simply a big circle with no fill and a very very thick black border.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import numpy as np
import gizeh as gz
import moviepy.editor as mpy

W,H = 256, 256
DURATION = 2.0
NDISKS_PER_CYCLE = 8
SPEED = .05

def make_frame(t):

    dt = 1.0*DURATION/2/NDISKS_PER_CYCLE # delay between disks
    N = int(NDISKS_PER_CYCLE/SPEED) # total number of disks
    t0 = 1.0/SPEED # indicates at which avancement to start

    surface = gz.Surface(W,H)
    for i in range(1,N):
        a = (np.pi/NDISKS_PER_CYCLE)*(N-i-1)
        r = np.maximum(0, .05*(t+t0-dt*(N-i-1)))
        center = W*(0.5+ gz.polar2cart(r,a))
        color = 3*((1.0*i/NDISKS_PER_CYCLE) % 1.0,)
        circle = gz.circle(r=0.3*W, xy = center,fill = color,
                              stroke_width=0.01*W)
        circle.draw(surface)
    contour1 = gz.circle(r=.65*W,xy=[W/2,W/2], stroke_width=.5*W)
    contour2 = gz.circle(r=.42*W,xy=[W/2,W/2], stroke_width=.02*W,
                            stroke=(1,1,1))
    contour1.draw(surface)
    contour2.draw(surface)
    return surface.get_npimage()

clip = mpy.VideoClip(make_frame, duration=DURATION)
clip.write_gif("shutter.gif",fps=20, opt="OptimizePlus", fuzz=10)

Example 6

You can draw more than circles ! And you can group different elements so that they will move together (here, a letter and a pentagon).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import numpy as np
import gizeh as gz
import moviepy.editor as mpy

W,H = 300, 75
D = 2 # duration in seconds
r = 22 # size of the letters / pentagons

gradient= gz.ColorGradient("linear",((0,(0,.5,1)),(1,(0,1,1))),
                           xy1=(0,-r), xy2=(0,r))
polygon = gz.regular_polygon(r, 5, stroke_width=3, fill=gradient)

def make_frame(t):
    surface = gz.Surface(W,H, bg_color=(1,1,1))
    for i, letter in enumerate("GIZEH"):
        angle = max(0,min(1,2*t/D-1.0*i/5))*2*np.pi
        txt = gz.text(letter, "Amiri", 3*r/2, fontweight='bold')
        group = (gz.Group([polygon, txt])
                 .rotate(angle)
                 .translate((W*(i+1)/6,H/2)))
        group.draw(surface)
    return surface.get_npimage()

clip = mpy.VideoClip(make_frame, duration=D)
clip.write_gif("gizeh.gif",fps=20, opt="OptimizePlus")

Example 7

We start with just a triangle. By rotating this triangle three time we obtain four triangles which fit nicely into a square. Then we copy this square following a checkerboard pattern. Finally we do the same with another color to fill the missing tiles. Now, if the original triangle is rotated, all the triangles on the picture will also be rotated.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import numpy as np
import gizeh as gz
import moviepy.editor as mpy

W,H = 200,200
WSQ = W/4 # width of one 'square'
D = 2 # duration
a = np.pi/8 # small angle in one triangle
points = [(0,0),(1,0),(1-np.cos(a)**2,np.sin(2*a)/2),(0,0)]

def make_frame(t):
    surface = gz.Surface(W,H)
    for k, (c1,c2) in enumerate([[(.7,0.05,0.05),(1,0.5,0.5)],
                                [(0.05,0.05,.7),(0.5,0.5,1)]]):

        grad = gz.ColorGradient("linear",xy1=(0,0), xy2 = (1,0),
                               stops_colors= [(0,c1),(1,c2)])
        r = min(np.pi/2,max(0,np.pi*(t-D/3)/D))
        triangle = gz.polyline(points,xy=(-0.5,0.5), fill=grad,
                        angle=r, stroke=(1,1,1), stroke_width=.02)
        square = gz.Group([triangle.rotate(i*np.pi/2)
                              for i in range(4)])
        squares = (gz.Group([square.translate((2*i+j+k,j))
                            for i in range(-3,4)
                            for j in range(-3,4)])
                   .scale(WSQ)
                   .translate((W/2-WSQ*t/D,H/2)))

        squares.draw(surface)

    return surface.get_npimage()

clip = mpy.VideoClip(make_frame=make_frame).set_duration(D)
clip.write_gif("blueradsquares.gif",fps=15, fuzz=30)

Example 8

A nice thing to do with vector graphics is fractals. We first build a ying-yang, then we use this ying-yang as the dots of a bigger ying-yang, and we use the bigger ying-yang as the dots of an even bigger ying yang etc. In the end we go one level deep into the imbricated ying-yangs, and we start zooming.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import numpy as np
import gizeh as gz
import moviepy.editor as mpy

W,H = 256,256
R=1.0*W/3
D = 4
yingyang = gz.Group( [
      gz.arc(R,0,np.pi, fill=(0,0,0)),
      gz.arc(R,-np.pi,0, fill=(1,1,1)),
      gz.circle(R/2,xy=(-R/2,0), fill=(0,0,0)),
      gz.circle(R/2,xy=(R/2,0), fill=(1,1,1))])

fractal = yingyang
for i in range(5):
    fractal = gz.Group([yingyang,
                fractal.rotate(np.pi).scale(0.25).translate([R/2,0]),
                fractal.scale(0.25).translate([-R/2,0]),
                gz.circle(0.26*R, xy=(-R/2,0),
                    stroke=(1,1,1), stroke_width=1),
                gz.circle(0.26*R, xy=(R/2,0),
                    stroke=(0,0,0), stroke_width=1)])

# Go one level deep into the fractal
fractal = fractal.translate([(R/2),0]).scale(4)

def make_frame(t):
    surface = gz.Surface(W,H)
    G = 2**(2*(t/D)) # zoom coefficient
    (fractal.translate([R*2*(1-1.0/G)/3,0]).scale(G) # zoom
     .translate(W/2+gz.polar2cart(W/12,2*np.pi*t/D)) # spiral effect
     .draw(surface))
    return surface.get_npimage()

clip = mpy.VideoClip(make_frame, duration=D)
clip.write_gif("yingyang.gif",fps=15, fuzz=30, opt="OptimizePlus")

Example 9

That one is inspired by this Dave Whyte animation. We draw white-filled circles, each of these being almost completely transparent so that they only add 1 to the value of the pixels that they cover. Pixels with an even value, which are the pixels covered by an even number of circles, are then painted white, while the others will be black. To complexify and have a nicely-looping animation, we draw two circles in each direction, one being a time-shifted version of the other.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
import gizeh as gz
import moviepy.editor as mpy

W,H = 400,400
D = 5 # duration, in seconds
ncircles = 10

def make_frame(t):
    surface = gz.Surface(W,H)
    for angle in np.linspace(0,2*np.pi,ncircles+1)[:-1]:
        center = np.array([W/2,H/2]) + gz.polar2cart(.2*W,angle)
        for i in [0,1]: # two circles belongin to two groups
            circle = gz.circle(W*.45*(i+t/D),xy=center,
                                  fill=(1,1,1,1.0/255))
            circle.draw(surface)
    return 255*((surface.get_npimage()+1) % 2)

clip = mpy.VideoClip(make_frame, duration=D).resize(.5)
clip.write_gif("rose.gif",fps=15, fuzz=30, opt="OptimizePlus")

Example 10

A pentagon made of rotating squares ! Interestingly, making the squares rotate the other direction creates a very different-looking animation. The squares are placed according to this polar equation.

The difficulty in this animation is that the last square drawn will necessarily be on top of all the others, and not, as it should be, below the first square ! The solution is to draw each frame twice. The first time, we draw the squares starting from the right, so that the faulty square will also be on the right, and we only keep the left part of that picture. The second time we start drawing the squares from the left, so that the faulty square is on the left, and we keep the right part. By assembling the two valid parts we reconstitute a valid picture.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import numpy as np
import moviepy.editor as mpy
import colorsys
import gizeh as gz

W,H = 256,256
NFACES, R, NSQUARES, DURATION = 5, 0.3,  100, 2

def half(t, side="left"):
    points = gz.geometry.polar_polygon(NFACES, R, NSQUARES)
    ipoint = 0 if side=="left" else NSQUARES/2
    points = (points[ipoint:]+points[:ipoint])[::-1]

    surface = gz.Surface(W,H)
    for (r, th, d) in points:
        center = W*(0.5+gz.polar2cart(r,th))
        angle = -(6*np.pi*d + t*np.pi/DURATION)
        color= colorsys.hls_to_rgb((2*d+t/DURATION)%1,.5,.5)
        square = gz.square(l=0.17*W, xy= center, angle=angle,
                   fill=color, stroke_width= 0.005*W, stroke=(1,1,1))
        square.draw(surface)
    im = surface.get_npimage()
    return (im[:,:W/2] if (side=="left") else im[:,W/2:])


def make_frame(t):
    return np.hstack([half(t,"left"),half(t,"right")])

clip = mpy.VideoClip(make_frame, duration=DURATION)
clip.write_gif("pentagon.gif",fps=15, opt="OptimizePlus")

Mixing videos and vector graphics

A nice advantage of combining Gizeh with MoviePy is that you can read actual video files (or gifs) and use the frames to fill shapes drawn with Gizeh.

We will use this video from the Blender Foundation (it’s under a Creative Common licence). Since you have read until there I’ll show you a little unrelated trick: at 4:32 the rabbit is jumping rope, so there is a potential for a well-looping GIF. We open the video around 4:32, and let MoviePy automatically decide where to cut to have the best-looping GIF possible:

1
2
3
4
5
6
from moviepy.editor import VideoFileClip
import moviepy.video.tools.cuts as cuts

clip = mpy.VideoFileClip("bunny.mp4").resize(0.2).subclip((4,32),(4,33))
t_loop = cuts.find_video_period(clip) # gives t=0.56
clip.subclip(0,t_loop).write_gif('jumping_bunny.gif')

Now we can feed the frames of this GIF to Gizeh, using MoviePy’s clip.fl(some_filter), which means “I want a new clip made by transforming the frames of the current clip with some_filter”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import moviepy.editor as mpy
import numpy as np
import gizeh as gz

clip = mpy.VideoFileClip("jumping_bunny.gif")
(w, h), d = clip.size, clip.duration
center=  np.array([w/2, h/2])

def my_filter(get_frame, t):
    """ Transforms a frame (given by get_frame(t)) into a different
    frame, using vector graphics."""

    surface = gz.Surface(w,h)
    fill = (gz.ImagePattern(get_frame(t), pixel_zero=center)
            .scale(1.5, center=center))
    for (nfaces,angle,f) in ([3, 0, 1.0/6],
                              [5, np.pi/3, 3.0/6],
                              [7, 2*np.pi/3, 5.0/6]):
        xy = (f*w, h*(.5+ .05*np.sin(2*np.pi*(t/d+f))))
        shape = gz.regular_polygon(w/6,nfaces, xy = xy,
                fill=fill.rotate(angle, center))
        shape.draw(surface)
    return surface.get_npimage()

clip.fl(my_filter).write_gif("jumping_bunny_shapes.gif")

Finally, this function adds a zoom on some part of the video.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import gizeh as gz
import moviepy.editor as mpy
import numpy as np

def add_zoom(clip, target_center, zoom_center, zoom_radius, zoomx):

    w, h = clip.size

    def fl(im):
        """ transforms the image by adding a zoom """

        surface = gz.Surface.from_image(im)
        fill = gz.ImagePattern(im, pixel_zero=target_center,
                               filter='best')
        line = gz.polyline([target_center, zoom_center],
                           stroke_width=3)
        circle_target= gz.circle(zoom_radius, xy=target_center,
                                 fill=fill, stroke_width=2)
        circle_zoom = gz.circle(zoom_radius, xy=zoom_center, fill=fill,
                       stroke_width=2).scale(zoomx, center=zoom_center)
        for e in line, circle_zoom, circle_target:
            e.draw(surface)
        return surface.get_npimage()

    return clip.fl_image(fl)


clip = mpy.VideoFileClip("jumping_bunny.gif")
w, h = clip.size
clip_with_zoom = clip.fx(add_zoom, target_center = [w/2, h/3], zoomx=3,
                   zoom_center = [5*w/6, h/4], zoom_radius=15)
clip_with_zoom.write_gif("jumping_bunnyt_zoom.gif")

Your turn now !

I hope I have convinced you that Python is a nice language for making vector animations. If you give it a try, let me know of any difficulty you may meet installing or using MoviePy and Gizeh. And any feedback, improvement ideas, commits, etc. are also very appreciated.

Comments