Forum Archive

GIF art in Python

Webmaster4o

I'm reading through this blog post, which is about creating cool vector animations in Python using the libraries gizeh and moviepy, neither of which have any hope of working in pythonista. That said, many of the same effects can be achieved in pythonista using images2gif and PIL. I've achieved a good result with the first example I tried to convert.

Their code:

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)

outputs
this image

And my code:

# coding: utf-8
import numpy as np
from PIL import Image, ImageDraw
from images2gif import writeGif
from math import sin,cos


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

def polar2cart(r,theta):
    x = r*cos(theta)
    y = r*sin(theta)
    return x, y

def make_frame(t):

    im = Image.new('RGB', (W,H), (0,0,0))

    surface = ImageDraw.Draw(im)

    for i in range(ncircles):
        angle = 2*np.pi*(1.0*i/ncircles+t/duration)
        center = W*( 0.5 + polar2cart(0.1,angle)[0]), W*( 0.5 + polar2cart(0.1,angle)[1])
        r = W*(1.0-1.0*i/ncircles)
        bbox = (center[0]-r, center[1]-r, center[0]+r, center[1]+r)
        surface.ellipse(bbox, fill= (i%2*255,i%2*255,i%2*255))

    del surface
    return im

images = []
for x in range(200):
    images.append(make_frame(x/25.0))

writeGif('tunnelswirl.gif',images,0.005)

outputs
this image

Ok, so theirs is prettier because it's vector art and mine is a 128x128 PIL image, but I achieved the same effect. My code is (very heavily) based off of theirs, but it wasn't easy for me to do. I can achieve slightly cleaner results from PIL if I generate frames at 1024x1024, then resize to 128,128. I use this technique whenever I do font rendering with PIL. It would also be interesting to do this with the canvas module, which is for vector graphics.

It also works with more circles

omz

Very cool! I saw that blog post a while ago and always wanted to port some of it to Pythonista.

Webmaster4o

Thanks :)

omz

Here's a version that uses the ui module for drawing the circles, so that they're anti-aliased – the result gets pretty close to the original, though I think the framerate is different.

# coding: utf-8
import numpy as np
from PIL import Image, ImageDraw
from images2gif import writeGif
from math import sin,cos
import ui
import io

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

def polar2cart(r,theta):
    x = r*cos(theta)
    y = r*sin(theta)
    return x, y

def ui2pil(ui_img):
    png_data = ui_img.to_png()
    return Image.open(io.BytesIO(png_data))

def make_frame(t):
    with ui.ImageContext(W, H, 1) as ctx:
        for i in range(ncircles):
            angle = 2*np.pi*(1.0*i/ncircles+t/duration)
            center = W*( 0.5 + polar2cart(0.1,angle)[0]), W*( 0.5 + polar2cart(0.1,angle)[1])
            r = W*(1.0-1.0*i/ncircles)
            ui.set_color((i%2, i%2, i%2))
            ui.Path.oval(center[0]-r, center[1]-r, r*2, r*2).fill()
        return ui2pil(ctx.get_image())

images = []
for x in range(200):
    images.append(make_frame(x/25.0))
writeGif('tunnelswirl.gif',images,0.005)

ccc
images = [make_frame(x/25.0) for x in xrange(200)]  # ;-)
Webmaster4o

@omz you need to change the last line to read

images.append(make_frame(x/25.0).convert('RGB'))

because alpha channels are not supported in images2gif

omz

@Webmaster4o It worked fine for me, but maybe I have a different version of images2gif (I used this one).

Webmaster4o

Yeah, I used the one I linked to in my original post. There are like 30 versions online. Yours is over 3 times the length of mine.

Webmaster4o

I'm torn between UI and PIL. UI looks so much better, but PIL will work on desktop as well as mobile.

EDIT:
I can achieve the same effect in PIL using an antialias filter on resize.

Updated code:

# coding: utf-8
import numpy as np
from PIL import Image, ImageDraw
from images2gif import writeGif
from math import sin,cos


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

def polar2cart(r,theta):
    x = r*cos(theta)
    y = r*sin(theta)
    return x, y

def make_frame(t):

    im = Image.new('RGB', (W,H), (0,0,0))

    surface = ImageDraw.Draw(im)

    for i in range(ncircles):
        angle = 2*np.pi*(1.0*i/ncircles+t/duration)
        center = W*( 0.5 + polar2cart(0.1,angle)[0]), W*( 0.5 + polar2cart(0.1,angle)[1])
        r = W*(1.0-1.0*i/ncircles)
        bbox = (center[0]-r, center[1]-r, center[0]+r, center[1]+r)
        surface.ellipse(bbox, fill= (i%2*255,i%2*255,i%2*255))

    del surface
    return im.resize((256,256), Image.ANTIALIAS)

images = []
for x in range(50):
    images.append(make_frame(x/25.0))

writeGif('tunnelswirl.gif',images,0.005)

Output:

Webmaster4o

I've tried to port the spinning hexagons to pythonista.

Code:

# coding: utf-8

import colorsys, console
import numpy as np
from images2gif import writeGif
from PIL import Image, ImageDraw

sin, cos, pi = np.sin, np.cos, np.pi
W,H = 1024,1024
NFACES = 5 #Number of faces on the polygon
R = 0.3 #Radius of polygon
NSQUARES = 100 # Number of squares
DURATION = 1

def polar_polygon(nfaces,radius, npoints):
    """ Returns the (x,y) coordinates of n points regularly spaced
    along a regular polygon of `nfaces` faces and given radius.
    """
    theta=np.linspace(0,2*np.pi,npoints)[:-1]
    n = nfaces
    r= cos( pi/n )/cos((theta%(2*pi/n))-pi/n)
    d = np.cumsum(np.sqrt(((r[1:]-r[:-1])**2)))
    d = [0]+list(d/d.max())
    return zip(radius*r, theta, d)

def polar2cart(r,theta):
    x = r*cos(theta)
    y = r*sin(theta)
    return x, y

def squarecoords(sidelength, center, angle):
    cx, cy = center
    radius = sidelength/2
    corners = [(cx-radius,cy-radius), (cx+radius,cy-radius), (cx+radius,cy+radius), (cx-radius,cy+radius)]

    def rotate(point, angle, center=(0, 0)):
        theta = angle*(np.pi/180.0)
        translated = point[0]-center[0] , point[1]-center[1]
        rotated = (translated[0]*cos(theta)-translated[1]*sin(theta),translated[0]*sin(theta)+translated[1]*cos(theta))
        newcoords = (round(rotated[0]+center[0], 1),round(rotated[1]+center[1], 1))
        return newcoords
    newcorners = []
    for x in corners:
        newcorners.append(rotate(x,angle,center))

    return tuple([tuple([int(x) for x in y]) for y in newcorners])

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

    i = Image.new('RGB', (W, H), (0,0,0))
    surface = ImageDraw.Draw(i)

    for (r, th, d) in points:
        center = W*(0.5+polar2cart(r,th)[0]),W*(0.5+polar2cart(r,th)[1])
        #angle = -(np.pi*d + t*np.pi/DURATION)*50
        angle = -(t*180)

        color= colorsys.hls_to_rgb((2*d+t/DURATION)%1,.5,.5)
        color = tuple([int(x*255) for x in color])
        coords = squarecoords(0.17*W, center, angle)
        surface.polygon(coords, color, outline=(255,255,255))
    im = np.asarray(i)
    return (im[:,:W/2] if (side=="left") else im[:,W/2:])

def make_frame(t):
    lefthalf = half(t,"left")
    righthalf = half(t,"right")
    return Image.fromarray(np.hstack((lefthalf, righthalf)))

images = []
for x in range(100):
    images.append(make_frame(x/100.0).resize((512,512), Image.ANTIALIAS))
    console.clear()
    print str(x+1)+'%'

console.clear()
print 'Writing gif...'
writeGif("pentagon.gif", images, duration=0.01)

Note:
The original had a wave effect sort of:

I could not replicate this. The commented out line above where I declare angle is the line they used to achieve this, but I could not replicate the result. Feel free to try it.

As you can tell, this was not as successful as the last.

Webmaster4o

New animation based on this code:

Code:

# coding: utf-8

import math
from operator import itemgetter

import console
from images2gif import writeGif
from PIL import Image, ImageDraw



W, H = (1024, 1024)
class Point3D:
    def __init__(self, x = 0, y = 0, z = 0):
        self.x, self.y, self.z = float(x), float(y), float(z)

    def rotateX(self, angle):
        """ Rotates the point around the X axis by the given angle in degrees. """
        rad = angle * math.pi / 180
        cosa = math.cos(rad)
        sina = math.sin(rad)
        y = self.y * cosa - self.z * sina
        z = self.y * sina + self.z * cosa
        return Point3D(self.x, y, z)

    def rotateY(self, angle):
        """ Rotates the point around the Y axis by the given angle in degrees. """
        rad = angle * math.pi / 180
        cosa = math.cos(rad)
        sina = math.sin(rad)
        z = self.z * cosa - self.x * sina
        x = self.z * sina + self.x * cosa
        return Point3D(x, self.y, z)

    def rotateZ(self, angle):
        """ Rotates the point around the Z axis by the given angle in degrees. """
        rad = angle * math.pi / 180
        cosa = math.cos(rad)
        sina = math.sin(rad)
        x = self.x * cosa - self.y * sina
        y = self.x * sina + self.y * cosa
        return Point3D(x, y, self.z)

    def project(self, win_width, win_height, fov, viewer_distance):
        """ Transforms this 3D point to 2D using a perspective projection. """
        factor = fov / (viewer_distance + self.z)
        x = self.x * factor + win_width / 2
        y = -self.y * factor + win_height / 2
        return Point3D(x, y, self.z)


class Cube:
    def __init__(self, win_width = 640, win_height = 480):

        self.vertices = [
        Point3D(-1,1,-1),
        Point3D(1,1,-1),
        Point3D(1,-1,-1),
        Point3D(-1,-1,-1),
        Point3D(-1,1,1),
        Point3D(1,1,1),
        Point3D(1,-1,1),
        Point3D(-1,-1,1)
        ]

        # Define the vertices that compose each of the 6 faces. These numbers are
        # indices to the vertices list defined above.
        self.faces  = [(0,1,2,3),(1,5,6,2),(5,4,7,6),(4,0,3,7),(0,4,5,1),(3,2,6,7)]

        # Define colors for each face
        self.colors = [
                        '#FFEC94',
                        '#FFAEAE',
                        '#404040',
                        '#B0E57C',
                        '#B4D8E7',
                        '#7BC8A4'
                        ]

    def make_frame(self, angle):
        # It will hold transformed vertices.
        t = []

        screen = Image.new('RGB',(W, H), (255,255,255))
        draw = ImageDraw.Draw(screen)

        for v in self.vertices:
            # Rotate the point around X axis, then around Y axis, and finally around Z axis.
            r = v.rotateX(angle).rotateY(angle).rotateZ(angle)
            # Transform the point from 3D to 2D

            #if angle <= 180:
            #   p = r.project(W, H, 256, 3+(angle/90.0))
            #else:
            #   p = r.project(W, H, 256, 5-((angle-180)/90.0))
            p = r.project(W, H, 256, 3)
            # Put the point in the list of transformed vertices
            t.append(p)

        # Calculate the average Z values of each face.
        avg_z = []
        i = 0
        for f in self.faces:
            z = (t[f[0]].z + t[f[1]].z + t[f[2]].z + t[f[3]].z) / 4.0
            avg_z.append([i,z])
            i = i + 1

        # Draw the faces using the Painter's algorithm:
        # Distant faces are drawn before the closer ones.
        for tmp in sorted(avg_z,key=itemgetter(1),reverse=True):
            face_index = tmp[0]
            f = self.faces[face_index]
            pointlist = [(t[f[0]].x, t[f[0]].y), (t[f[1]].x, t[f[1]].y),
            (t[f[1]].x, t[f[1]].y), (t[f[2]].x, t[f[2]].y),
            (t[f[2]].x, t[f[2]].y), (t[f[3]].x, t[f[3]].y),
            (t[f[3]].x, t[f[3]].y), (t[f[0]].x, t[f[0]].y)]
            draw.polygon(pointlist, fill=self.colors[face_index])

        return screen.resize((512,512), Image.ANTIALIAS)

c = Cube()
images = []
for x in range(0,360,5):
    images.append(c.make_frame(x))
    console.clear()
    print str((x/360.0)*100.0)[:5]+'%'

writeGif('cube.gif', images, 0.01)
JonB

@Webmaster4o

Duration = 1

should be

Duration = 1.0

there are a few other places where you intended floats, but wrote ints, although i am not aure if any of those caused issues.

or you can use
from __future__ import division

edit: on second look, the problem is that the polygon function expects degrees, not radians. The commented line is.... not quite either (unless the intention was to use a number near 180, but nonrepeating)

try
angle = -(N1.*d+N2*t/DURATION)*360.0

playing with those two factors will control, respectively, how many revolutions a square gets going around the polygon in a single image, and how many revolutions a square gets over the animation. You might try 0.5 for both, and play with these in integer 1/2 steps, the numbers can be different.

Webmaster4o

New sierpinski fractal animation:

Code is sloppy, I'll probably clean it up later:

```

coding: utf-8

from images2gif import writeGif
from PIL import Image, ImageDraw, ImageChops

import console

W, H = 1024,1024
RESOLUTION = 5
FG, BG = '#ffbb00', '#009bff'

FG, BG = '#000000', '#ffffff'

def drange(start, stop, step=1):
n = int(round((stop - start)/float(step)))
if n > 1:
return([start + step*i for i in range(n+1)])
else:
return([])

def irange(start, increments):
mylist = [start]
for i in increments:
mylist.append(mylist[-1]+i)
return mylist

class Rect:
def init(self, left, top, width, height):
self.left = left
self.right = left + width
self.top = top
self.bottom = top+height

    self.centerx = left + (width/2)
    self.centery = top + (height/2)

    self.width = width
    self.height = height

    self.bbox = (left, top), (self.right, self.bottom)

def drawSierpinski(surf, rect, fgcolor=FG, bgcolor=BG, level=6, topshade=True):
try:
rect.left
except AttributeError:
left, top, width, height = rect
rect = Rect(left, top, width, height)

if level == 0:
    return

quarterWidth = (rect.width/4)+rect.left
threeQuarterWidth = (rect.width/4*3)+rect.left

topRect = Rect(quarterWidth, rect.top, (rect.width / 2), (rect.height / 2))
leftRect = Rect(rect.left, rect.centery, (rect.width/2), (rect.height/2))
rightRect = Rect(rect.centerx, rect.centery, (rect.width / 2), (rect.height / 2))

#Shade topleft
if topshade:
    surf.rectangle((rect.left, rect.top, rect.centerx, rect.bottom), fill=bgcolor)
#outer triangle
surf.polygon([(rect.centerx,rect.top),(rect.left,rect.bottom),(rect.right,rect.bottom)],fgcolor)
#inner upside-down triangle
surf.polygon([(quarterWidth,rect.centery),(rect.centerx,rect.bottom),(threeQuarterWidth, rect.centery)],bgcolor)

#do recursive calls
drawSierpinski(surf, topRect, fgcolor, bgcolor, level-1, topshade)
drawSierpinski(surf, leftRect, fgcolor, bgcolor, level-1, topshade)
drawSierpinski(surf, rightRect, fgcolor, bgcolor, level-1, topshade)

return im

def make_sierpinski(level, topshade=True):
im = Image.new('RGB', (W, H), (255,255,255))
surf = ImageDraw.Draw(im)

drawSierpinski(surf, Rect(0, 0, W, H), FG, BG, level, topshade)
return im

im = Image.new('RGB', (W, H), (255,255,255))
surf = ImageDraw.Draw(im)

sierpinski1 = make_sierpinski(RESOLUTION)
sierpinski2 = make_sierpinski(RESOLUTION+1)

numbers = [int(n) for n in irange(0, drange(8,4,-0.0475))]

images = []
for x in numbers:
a = sierpinski1.crop((x, x, W, W)).resize((512, 512), Image.ANTIALIAS)
b = sierpinski2.crop((x, x, W, W)).resize((512, 512), Image.ANTIALIAS)

alpha = x / float(W/2)
images.append(Image.blend(a, b, alpha))
console.clear()
print str(alpha*100)+'%'

console.clear()
print 'writing...'

writeGif('sierpinski.gif', images, 2.0/len(images))```

Webmaster4o

I put these on GitHub.