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.








