Drawing Pictures Using Epicycles

Epicycle Drawing of Pi'

Overview

Introduction

Have you ever wondered to yourself: What is the best path to draw an outline without lifting my pen? Or maybe the question was: How can I tell a robot to draw a 2D image using circles? If these questions keep you up at night like me, then look no further.

What are Epicycles?

Any image can be expressed by a series of frequencies on the 2D plane. The series of connected circles rotating is called epicycles, and a great explanation can be found here and here

Project Objectives

The objective here is: given an image, compute the contour of the image, conduct the Discrete Fourier Transform (DFT) on the path, and animate the epicycle of N circles required to compute that path. Lets go.

  • Input image as coordinates (X,Y)
  • Find shortest tour path of points
  • DFT in series of path
  • Draw circles animated

Brief Maths

I will keep the math of this post very brief. Much smarter people than me have covered this topic in depth, but the major equation to keep in mind is that to a defined order, any curve can be approximated by the form:

$$ z(t) = \sum_{j=-m}^mc_{j}e^{^-2\pi ij/n} $$

Where \(z(t)\) is the curve, and \(c_{j}\) is the coefficient for each order of the exponent. A true Fourier series would go to infinity, but constraints in reality force us to cut off the order to some \(m\) for approximation.

Lets Start Coding!

First, we need import some dependencies and load our image.

 1#Dependecies
 2import numpy as np
 3import matplotlib.pyplot as plt
 4import PIL
 5from IPython.display import HTML
 6from matplotlib import animation, rc
 7from scipy.integrate import quad
 8
 9#Are you team Tau or team Pi?
10tau = 2*np.pi
11
12%matplotlib inline
1#import image
2im = PIL.Image.open("pi.png").convert("L")
3contour_plot = plt.contour(im,levels =50,origin='image')

png

 1#Find path
 2contour_path = contour_plot.collections[0].get_paths()[0]
 3X, Y = contour_path.vertices[:,0], contour_path.vertices[:,1]
 4
 5#Center X and Y
 6X = X - min(X)
 7Y = Y - min(Y)
 8X = X - max(X)/2
 9Y = Y - max(Y)/2
10
11#Visualize
12plt.plot(X,Y)
13#For the period of time 0-2*pi
14time = np.linspace(0,tau,len(X))

png

Now we have a contour of the verticies of our image! Also we have centered them around zero, which helps us with the next steps... Lets define some user functions!

 1def f(t, time_table, x_table, y_table):
 2    #Convert the X and Y coords to complex number over time
 3    X = np.interp(t, time_table, x_table) 
 4    Y = 1j*np.interp(t, time_table, y_table)
 5    return X + Y
 6
 7
 8def coef_list(time_table, x_table, y_table, order=5):
 9    #Find the real and imaginary parts of the coefficients to the summation
10    coef_list = []
11    for n in range(-order, order+1):
12        #integrate across f .
13        real_coef = quad(lambda t: np.real(f(t, time_table, x_table, y_table) * np.exp(-n*1j*t)), 0, tau, limit=100, full_output=1)[0]/tau
14        imag_coef = quad(lambda t: np.imag(f(t, time_table, x_table, y_table) * np.exp(-n*1j*t)), 0, tau, limit=100, full_output=1)[0]/tau
15        coef_list.append([real_coef, imag_coef])
16    return np.array(coef_list)
17
18def DFT(t, coef_list, order=5):
19    #Compute the discrete fourier series with a given order
20    kernel = np.array([np.exp(-n*1j*t) for n in range(-order, order+1)])
21    series = np.sum( (coef_list[:,0]+1j*coef_list[:,1]) * kernel[:])
22    return np.real(series), np.imag(series)

Take a minute to review what these functions do...

  • First, we need to convert the X and Y coordinates to Complex plane, where real numbers fall on the X axis and imaginary numbers fall on the Y axis.

  • Second, We need to get the real and imaginary coefficients by the fourier transform on the original path, integrating across the time from 0 to 2\(\pi\)

  • Third is the Discrete Fourier Transform of the coefficient list.

    • When the series is summed together, it is the addition of multiple circles with frequencies \(c_k\)
    • What this shows is that as the order increases, the longer the fourier series is, meaning more circles to draw, the closer to the original path you become.

The last part is visualization.

Special shoutout to WirkaDKP for the great code as to how to visualize the results!

 1def visualize(x_DFT, y_DFT, coef, order, space, fig_lim):
 2    
 3    fig, ax = plt.subplots()
 4    lim = max(fig_lim)
 5    ax.set_xlim([-lim, lim])
 6    ax.set_ylim([-lim, lim])
 7    ax.set_aspect('equal')
 8
 9    # Initialize
10    line = plt.plot([], [], 'k.-', linewidth=2)[0]
11    radius = [plt.plot([], [], 'b-', linewidth=0.5, marker='o', markersize=1)[0] for _ in range(2 * order + 1)]
12    circles = [plt.plot([], [], 'b-', linewidth=0.5)[0] for _ in range(2 * order + 1)]
13
14    def update_c(c, t):
15        new_c = []
16        for i, j in enumerate(range(-order,order + 1)):
17            dtheta = -j * t
18            ct, st = np.cos(dtheta), np.sin(dtheta)
19            v = [ct * c[i][0] - st * c[i][1], st * c[i][0] + ct * c[i][1]]
20            new_c.append(v)
21        return np.array(new_c)
22
23    def sort_velocity(order):
24        #reorder the frequencies
25        idx = []
26        for i in range(1,order+1):
27            idx.extend([order+i, order-i]) 
28        return idx    
29    
30    def animate(i):
31        # animate lines
32        line.set_data(x_DFT[:i], y_DFT[:i])
33        # animate circles
34        r = [np.linalg.norm(coef[j]) for j in range(len(coef))]
35        pos = coef[order]
36        c = update_c(coef, i / len(space) * tau)
37        idx = sort_velocity(order)
38        for j, rad, circle in zip(idx,radius,circles):
39            new_pos = pos + c[j]
40            rad.set_data([pos[0], new_pos[0]], [pos[1], new_pos[1]])
41            theta = np.linspace(0, tau, 50)
42            x, y = r[j] * np.cos(theta) + pos[0], r[j] * np.sin(theta) + pos[1]
43            circle.set_data(x, y)
44            pos = new_pos
45                
46    # Animation
47    ani = animation.FuncAnimation(fig, animate, frames=len(space), interval=10)
48    return ani

Now that the functions are out of the way lets put them to use! Lets look at the path of the DFT vs the actual path of the image with just an order of 5

1order = 5
2coef = coef_list(time, X, Y, order)
3space = np.linspace(0,tau,300)
4x_DFT = [DFT(t, coef, order)[0] for t in space]
5y_DFT = [DFT(t, coef, order)[1] for t in space]
1fig, ax = plt.subplots(figsize=(5, 5))
2ax.plot(x_DFT, y_DFT, 'r--')
3ax.plot(X, Y, 'k-')
4ax.set_aspect('equal', 'datalim')
5xmin, xmax = plt.xlim()
6ymin, ymax = plt.ylim()

png

The DFT path certainly has the characteristics of the original image, but doesn't do a good job representing it. Lets increase the order to 100

1order = 100
2coef = coef_list(time, X, Y, order)
3space = np.linspace(0,tau,300)
4x_DFT = [DFT(t, coef, order)[0] for t in space]
5y_DFT = [DFT(t, coef, order)[1] for t in space]
1fig, ax = plt.subplots(figsize=(5, 5))
2ax.plot(x_DFT, y_DFT, 'r--')
3ax.plot(X, Y, 'k-')
4ax.set_aspect('equal', 'datalim')
5xmin, xmax = plt.xlim()
6ymin, ymax = plt.ylim()

png

Almost indestinguishable from the original! What I love about this algorithm is it doesn't have any optimization algorithm to it or guessing methods, it is just a how well can I represent data given a base transformation. This is the power of Fourier transforms. And is highly related to how image compression works

The most beautiful and intuitive thing about the entire process is the visualization of the drawwing. All the math aside can't replace the imagery given by a series of circles stacked upon eachother to draw an image! Everytime it is brilliant!

1anim = visualize(x_DFT, y_DFT, coef, order,space, [xmin, xmax, ymin, ymax])
2
3#Change based on what writer you have
4#HTML(anim.to_html5_video())
5anim.save('pi.gif',writer='ffmpeg')
6#anim.save('Pi.gif',writer='pillow')

png

Pi

Below are some other animations I've made with this same process:

Eigth Notes
Heart
Michigan

Conclusion

So now we can take any image, do a fourier transform on it, and watch our little machine draw it in peace. I skipped over a lot of the math and wanted to just show the code, which you can download on my github. Maybe this is something that you would like to try yourself besides falling asleep at the math portion! If you want to learn more MyFourierEpicycles has a great description and further resources on how this works!

Cheers! -Q