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:
12345678
importgizehsurface=gizeh.Surface(width=320,height=260)# dimensions in pixelcircle=gizeh.circle(r=40,# radius, in pixelsxy=[156,200],# coordinates of the centerfill=(1,0,0))# 'red' in RGB coordinatescircle.draw(surface)# draw the circle on the surfacesurface.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:
12345678910
frommoviepy.editorimportVideoClipdefmake_frame(t):""" returns a numpy array of the frame at time t """# ... here make a frame_for_time_treturnframe_for_time_tclip=VideoClip(make_frame,duration=3)# 3-second clipclip.write_videofile("my_animation.mp4",fps=24)# export as videoclip.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:
123456789101112131415
importgizehimportmoviepy.editorasmpyW,H=128,128# width, height, in pixelsduration=2# duration of the clip, in secondsdefmake_frame(t):surface=gizeh.Surface(W,H)radius=W*(1+(t*(duration-t))**2)/6circle=gizeh.circle(radius,xy=(W/2,H/2),fill=(1,0,0))circle.draw(surface)returnsurface.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).
1234567891011121314151617181920212223
importnumpyasnpimportgizehimportmoviepy.editorasmpyW,H=128,128duration=2ncircles=20# Number of circlesdefmake_frame(t):surface=gizeh.Surface(W,H)foriinrange(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)returnsurface.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.
123456789101112131415161718192021222324252627
importgizehasgzimportnumpyasnpimportmoviepy.editorasmpyW=H=150D=2# durationnballs=60# generate random values of radius, color, centerradii=np.random.randint(.1*W,.2*W,nballs)colors=np.random.rand(nballs,3)centers=np.random.randint(0,W,(nballs,2))defmake_frame(t):surface=gz.Surface(W,H)forr,color,centerinzip(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 ballgradient=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)returnsurface.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.
importnumpyasnpimportgizehasgzimportmoviepy.editorasmpyW,H=200,75D=3r=10# radius of the ballDJ,HJ=50,35# distance and height of the jumpsground=0.75*H# y-coordinate of the groundgradient=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])defmake_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**2coef=(HJ-y)/HJshadow_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)returnsurface.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.
importnumpyasnpimportgizehasgzimportmoviepy.editorasmpyW,H=256,256DURATION=2.0NDISKS_PER_CYCLE=8SPEED=.05defmake_frame(t):dt=1.0*DURATION/2/NDISKS_PER_CYCLE# delay between disksN=int(NDISKS_PER_CYCLE/SPEED)# total number of diskst0=1.0/SPEED# indicates at which avancement to startsurface=gz.Surface(W,H)foriinrange(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)returnsurface.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).
12345678910111213141516171819202122232425
importnumpyasnpimportgizehasgzimportmoviepy.editorasmpyW,H=300,75D=2# duration in secondsr=22# size of the letters / pentagonsgradient=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)defmake_frame(t):surface=gz.Surface(W,H,bg_color=(1,1,1))fori,letterinenumerate("GIZEH"):angle=max(0,min(1,2*t/D-1.0*i/5))*2*np.pitxt=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)returnsurface.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.
importnumpyasnpimportgizehasgzimportmoviepy.editorasmpyW,H=200,200WSQ=W/4# width of one 'square'D=2# durationa=np.pi/8# small angle in one trianglepoints=[(0,0),(1,0),(1-np.cos(a)**2,np.sin(2*a)/2),(0,0)]defmake_frame(t):surface=gz.Surface(W,H)fork,(c1,c2)inenumerate([[(.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)foriinrange(4)])squares=(gz.Group([square.translate((2*i+j+k,j))foriinrange(-3,4)forjinrange(-3,4)]).scale(WSQ).translate((W/2-WSQ*t/D,H/2)))squares.draw(surface)returnsurface.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.
importnumpyasnpimportgizehasgzimportmoviepy.editorasmpyW,H=256,256R=1.0*W/3D=4yingyang=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=yingyangforiinrange(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 fractalfractal=fractal.translate([(R/2),0]).scale(4)defmake_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))returnsurface.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.
1234567891011121314151617181920
importnumpyasnpimportgizehasgzimportmoviepy.editorasmpyW,H=400,400D=5# duration, in secondsncircles=10defmake_frame(t):surface=gz.Surface(W,H)forangleinnp.linspace(0,2*np.pi,ncircles+1)[:-1]:center=np.array([W/2,H/2])+gz.polar2cart(.2*W,angle)foriin[0,1]:# two circles belongin to two groupscircle=gz.circle(W*.45*(i+t/D),xy=center,fill=(1,1,1,1.0/255))circle.draw(surface)return255*((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.
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:
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”.
12345678910111213141516171819202122232425
importmoviepy.editorasmpyimportnumpyasnpimportgizehasgzclip=mpy.VideoFileClip("jumping_bunny.gif")(w,h),d=clip.size,clip.durationcenter=np.array([w/2,h/2])defmy_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)returnsurface.get_npimage()clip.fl(my_filter).write_gif("jumping_bunny_shapes.gif")
Finally, this function adds a zoom on some part of the video.
importgizehasgzimportmoviepy.editorasmpyimportnumpyasnpdefadd_zoom(clip,target_center,zoom_center,zoom_radius,zoomx):w,h=clip.sizedeffl(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)foreinline,circle_zoom,circle_target:e.draw(surface)returnsurface.get_npimage()returnclip.fl_image(fl)clip=mpy.VideoFileClip("jumping_bunny.gif")w,h=clip.sizeclip_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.