[Fun with Python] Create spiral art with Python

A toy Python script that converts an image into a spiral curve art.

Intro

Some time ago I saw a gif animation online showing someone doing a replica of the Mona Lisa in a "spiral" way. He/she keeps on drawing a continuous, tightly packed-up spiral curve from the center of the canvas outwards, using a calligraphy brush to create varying thickness of the line stroke. In the end you see the image of Mona Lisa, it is like a Mosaic art but in the polar coordinate. I thought I could probably do the similar thing, but with the power of a programming language. So here is an attempt in Python.

The Python script

Post the script first:

FILE_IN='people01.jpg'
dr1=4.5
dr2=4
lseg=4
nlayers=4
sigma=3

import numpy as np
from PIL import Image
from PIL import ImageFilter
import matplotlib.pyplot as plt

if __name__=='__main__':

    #-----------Read in image----------------------
    img=Image.open(FILE_IN).convert('L')

    # resize
    newsize=(300, int(float(img.size[1])/img.size[0]*300))
    img=img.resize(newsize,Image.ANTIALIAS)
    print('Image size: %s' %str(img.size))

    # blur
    img=img.filter(ImageFilter.GaussianBlur(sigma))

    # convert to array
    img=np.array(img)
    img=img[::-1,:]

    # thresholding
    layers=np.linspace(np.min(img),np.max(img),nlayers+1)
    img_layers=np.zeros(img.shape)
    ii=1
    for z1,z2 in zip(layers[:-1],layers[1:]):
        img_layers=np.where((img>=z1) & (img<z2),ii,img_layers)
        ii+=1
    img_layers=img_layers.max()-img_layers+1

    # get diagonal length
    size=img.shape # ny,nx
    diag=np.sqrt(size[0]**2/4+size[1]**2/4)

    figure=plt.figure(figsize=(12,10),dpi=100)
    ax=figure.add_subplot(111)

    # create spiral
    rii=1.0
    line=np.zeros(img.shape)
    nc=0
    while True:
        if nc==0:
            rii2=rii+dr1
        else:
            rii=rii2
            rii2=rii+dr2

        print('r = %.1f' %rii)
        nii=max(64,2*np.pi*rii//lseg)
        tii=np.linspace(0,2*np.pi,nii)
        riis=np.linspace(rii,rii2,nii)
        xii=riis*np.cos(tii)
        yii=riis*np.sin(tii)

        if np.all(riis>=diag):
            break

        # get indices
        xidx=np.around(xii,0).astype('int')+size[1]//2
        yidx=np.around(yii,0).astype('int')+size[0]//2
        idx=[jj for jj in range(len(yidx)) if xidx[jj]>=0 and yidx[jj]>=0\
                and xidx[jj]<=size[1]-1 and yidx[jj]<=size[0]-1]
        xidx=xidx[idx]
        yidx=yidx[idx]

        # skip diagonal jumps
        if len(yidx)>0 and yidx[0]*yidx[-1]<0:
            continue
        xii=xii[idx]
        yii=yii[idx]

        # pick line width from image
        lw=img_layers[yidx,xidx]

        # add random perturbation
        lwran=np.random.random(lw.shape)-0.5
        lw=lw+lwran

        # randomize color
        colorjj=np.random.randint(0,60)*np.ones(3)/float(255)

        for jj in range(len(xii)-1):

            # smooth line widths
            lwjjs=lw[max(0,jj-3):min(len(lw),jj+3)]
            if jj==0 and nc>0:
                lwjjs=np.r_[lwjjs_old,lwjjs]

            wjj=np.ones(len(lwjjs))
            wjj=wjj/len(wjj)
            lwjj=np.dot(lwjjs,wjj)

            ax.plot([xii[jj], xii[jj+1]],
                    [yii[jj], yii[jj+1]],
                    color=colorjj,
                    linewidth=lwjj)

            if jj==len(xii)-2:
                lwjjs_old=lwjjs

        nc+=1

    ax.axis('off')
    ax.set_facecolor((1.,0.5,0.5))
    ax.set_aspect('equal')
    figure.show()

Break down of the script

To create a "spiral replica" of some image/painting we first need to load a digital copy of the reference image. (The image file used in the script can be obtained from here.) I used the PIL package for this, and re-scaled it to a predefined width of 300 to reduce the amount of details and the computational cost:

from PIL import Image
img = Image.open(FILE_IN).convert('L')
newsize = (300, int(float(img.size[1])/img.size[0]*300))
img = img.resize(newsize, Image.ANTIALIAS)

The .convert('L') function converts the color image to greyscale, using this formula:

L = R * 299/1000 + G * 587/1000 + B * 114/1000

Then I did a Gaussian blur on the greyscale image, again to reduce the high frequency details:

img = img.filter(ImageFilter.GaussianBlur(sigma))

The sigma parameter is defined at the top of the script, you can adjust it yourself.

After converting the image to numpy array, I segmented the greyscale intensities of the image into a number of layers:

layers=np.linspace(np.min(img), np.max(img), nlayers+1)

Such layers will be used to control the width of the spiral curve: the lower the greyscale intensity (lower intensity corresponds to darker color), the thicker the line stroke:

img_layers=np.zeros(img.shape)
ii=1
for z1,z2 in zip(layers[:-1],layers[1:]):
	img_layers=np.where((img>=z1) & (img<z2),ii,img_layers)
	ii+=1
img_layers=img_layers.max()-img_layers+1

The spirals are created in the big while True loop, the number of circles is recorded in the nc variable.

In each cycle we define the starting radius (using the center of the image as origin) as rii and the finishing radius after a 360 degree rotation as rii2. rii2 > rii so it spirals outwards.

In the 1st cycle (nc=0), the starting and finish radii are rii and rii+dr1, respectively. For subsequent circles the increase of radius in each cycle is dr2. I defined dr1 and dr2 separately because the first cycle of the spiral tends to create a big solid dot at the center of the image so a slightly bigger dr1 can help remove the big solid dot.

During each cycle the spiral curve is broken down into a number line segments, each with a length of lseg; or the number of segments is set to 64, whichever way gives a larger segment number. Then the 2 pi cycle is segmented accordingly, and the x, y coordinates obtained from the linearly interpolated radius riis:

nii = max(64, 2*np.pi*rii//lseg)
tii = np.linspace(0, 2*np.pi, nii)
riis = np.linspace(rii, rii2, nii)
xii = riis*np.cos(tii)
yii = riis*np.sin(tii)

Then I rounded the xii, yii coordinates to the nearest integers, which are used as indices to index the line width values at the underlying pixels:

xidx=np.around(xii,0).astype('int')+size[1]//2
yidx=np.around(yii,0).astype('int')+size[0]//2
idx=[jj for jj in range(len(yidx)) if xidx[jj]>=0 and yidx[jj]>=0\
	and xidx[jj]<=size[1]-1 and yidx[jj]<=size[0]-1]
xidx=xidx[idx]
yidx=yidx[idx]

# skip diagonal jumps
if len(yidx)>0 and yidx[0]*yidx[-1]<0:
	continue
xii=xii[idx]
yii=yii[idx]

# pick line width from image
lw=img_layers[yidx,xidx]

A random perturbation is given to the sampled line widths, and the line colors, to give a more natural looking curve:

lwran=np.random.random(lw.shape)-0.5
lw=lw+lwran
colorjj=np.random.randint(0,60)*np.ones(3)/float(255)

However, this still creates jagged line segments. Therefore I further smoothed the line widths with a 3-unit moving average:

for jj in range(len(xii)-1):

	# smooth line widths
	lwjjs=lw[max(0,jj-3):min(len(lw),jj+3)]
	if jj==0 and nc>0:
	lwjjs=np.r_[lwjjs_old,lwjjs]

	wjj=np.ones(len(lwjjs))
	wjj=wjj/len(wjj)
	lwjj=np.dot(lwjjs,wjj)

Lastly, the line segments are plotted using matplotlib. This is the basic functionality of the script. Some extra care has been taken to skip some line segments that connects one edge of the image and another. Some sample results are shown below.

Leave a Reply