Cardioid string art with generativepy

Martin McBride
2019-06-13

In this post we will look at how to create an image like this using Python and generativepy:

To follow this tutorial you will need Python3.5 or above, generativepy, pycairo and numpy.

Making a cardioid curve

This type of curve is often created using pins and string, but it can be done by drawing lines on a computer image.

We start out with a set of points distributed equally around the circumference of a circle:

In this case we have 10 points, but in the image at the start of this post there are 200 points.

We will assume that the points are on a circle of radius 1 unit, with its centre at the point (0, 0). The (x, y) coordinates of point i are given by:

a = i*2*math.pi/N
x = math.cos(a)
y = math.sin(a)

N is the total number of points (10 in this case). a is the angle of the point around the circle, measured in radians. The points are numbered 0 to 9.

To create the image, we simply draw a line from each point i to the point 2*i:

So:

  • Point 0 is connected to point 0 (the line has zero length so it isn't drawn)
  • Point 1 is connected to point 2
  • Point 2 is connected to point 4
  • Point 3 is connected to point 6

When we calculate the second point, we use modulo N, so:

  • Point 5 is connected to point 0 (10, modulo 10)
  • Point 6 is connected to point 2 (12, modulo 10)
  • Point 7 is connected to point 4 (14, modulo 10)

Here is the complete drawing. Believe it or not, when you increase the number of points to 200, you will see a cardioid curve:

Drawing images with generativepy

Here is how we create a PNG image in generativepy:

makeImage("line.png", draw, pixelSize=(500, 500), width=2.2,
          startX=-1.1, startY=-1.1, background=Color(0.25))

This will create an image file line.png.

draw is a function object that we will provide that does the actual drawing, see later.

pixelSize sets the size of the image in pixels.

width, startX and startY set the user coordinate system, as described below.

background sets the background colour to 0.25 grey (a dark grey).

User coordinates

The pixel image we create will be 500 pixels square. We measure the x position from left to right, and the y position from top to bottom.

It is often useful to define user coordinates. This has two advantages. Firstly, we can choose a convenient scale. Secondly, if we want to change the pixel size of the image, we can simply change the pixelSize parameter. The drawing code, defined in user coordinates, doesn't need to change at all.

We set the width to 2.2, which means that 2.2 user units map on to 500 pixels. The height is chosen automatically to match the aspect ratio of the pixel size - the image is square so the height is also 2.2.

We have also set the startX and startY values to -1.1, which means that the top left of the image corresponds to user coordinate (-1.1, -1.1). The bottom right is coordinate (1.1, 1.1). The user coordinate origin, (0, 0) is exactly in the centre of the image.

Why have we chosen this size? Well our cardioid image will be based on a circle of radius 1, which will fit right in the middle of our 2.2 unit square, with a bit of space around the edges.

The draw function

To do the actual drawing, we must define a draw function. You can call it anything, but we will call it draw. It must take one parameter, a generativepy Canvas object. Here is our draw function:

def draw(canvas):
    canvas.stroke(Color(0.6, 0.6, 1))
    canvas.strokeWeight(0.01)
    canvas.line(-1, -0.5, 0.8, 1)

This function calls several methods of the Canvas object:

  • stroke sets the colour of any lines we draw. Color(0.6, 0.6, 1) gives rgb values of 0.6, 0.6 and 1.0, which is a light blue colour.
  • strokeWeight sets the thickness of any lines drawn. It is measured in user coordinates. Since the whole image is only 2.2 units wide, we obviously must use quite a small value for the line width. You can experiment, but 0.01 is a good starting point.
  • line draws a line from position (-1, -0.5) to position (0.8, 1), again in user coordinates.

Here is the image:

And here is the full code:

from generativepy import drawing
from generativepy.drawing import makeImage
from generativepy.color import Color

def draw(canvas):
    canvas.stroke(Color(0.6, 0.6, 1))
    canvas.strokeWeight(0.01)
    canvas.line(-1, -0.5, 0.8, 1)

makeImage("line.png", draw, pixelSize=(500, 500), width=2.2,
          startX=-1.1, startY=-1.1, background=Color(0.25))

Drawing the cardioid image

We have seen how to draw a straight line with generativepy. And we have seen that the cardioid image is simply a lot of straight lines drawn between specific points. So we are now in a position to draw the image.

First we nee to calculate the points. We need 200 points, equally spaced around a unit circle. The equation was given above, we just need to convert this to Python code.

We will store each point as an (x, y) tuple, and place all the tuples in an array points. Here is the code:

N = 200

points = [(math.cos(i*2*math.pi/N), math.sin(i*2*math.pi/N)) for i in range(N)]

If you are not familiar with list comprehensions you can ead about them here. This is the equivalent code using a loop - it does the same thing, less tersely, use whichever you prefer:

N = 200

points = []
for i in range(N):
    x = (math.cos(i*2*math.pi/N)
    y = math.sin(i*2*math.pi/N))
    point = (x, y)
    points.append(point)

Next, we need to produce a set of lines joining point[i] to point[i*2] (modulo N):

for i in range(N):
    j = (i*2) % N
    canvas.line(*points[i], *points[j])

(This requires Python 3.5 or later, because it uses the extended unpacking syntax in PEP 448).

This code goes into our draw function. We will also make minor changes to the makeImage function. The output filename is different, and the pixel size has been increased to 800 square. Here is the full code:

from generativepy import drawing
from generativepy.drawing import makeImage
from generativepy.color import Color
import math

N = 400

def draw(canvas):
    points = [(math.cos(i*2*math.pi/N), math.sin(i*2*math.pi/N)) for i in range(N)]
    canvas.stroke(Color(0.6, 0.6, 1, 0.25))
    canvas.strokeWeight(0.005)

    for i in range(N):
        j = (i*2) % N
        canvas.line(*points[i], *points[j])

makeImage("cardioid.png", draw, pixelSize=(800, 800), width=2.2,
          startX=-1.1, startY=-1.1, background=Color(0.25))

There is one final touch. The stroke colour is set to (0.6, 0.6, 1, 0.25). This is the previous light blue, but the fourth values makes the colour transparent. It has 25% opacity (ie it is 75% transparent). This means that where the lines overlap around the border of the cardioid shape, the colour looks brighter.

Popular tags

ebooks fractal generative art generativepy generativepy tutorials github koch curve l systems mandelbrot open source productivity pysound python recursion scipy sine sound spriograph tinkerbell turtle writing