Computer Programming

for Experimental Psychologists

ShapeStim and Trigonometry Magic


Introduction

People often wonder if you need to be good at mathematics to be a good programmer. If you are not planning on writing your own machine learning algorithms I would say no. However, I do think that some basic trigonometry knowledge should be part of your toolbox. In fact, most cases where I had to actually ‘use’ mathematics in my research involved just that.

In this post I will demonstrate how you can create the figure below in PsychoPy using the ShapeStim object. Before we do that, we first need to ask the question if it is really necessary to take that approach. After all, if you already have the image, why not just render the image itself in PsychoPy?

This is always a good consideration to make before you start coding, as an elegant solution does not necessarily mean you have to write code. In this case, I would argue that if you do not need the flexibly change your stimulus during the course of the experiment, it is best to stick with the image. However, here we will take the point that you do need that flexibility.

Getting started: The ShapeStim

Because this stimulus is not part of the PsychoPy toolbox, we need to create it ourselves. For that we can use the ShapeStim object. This allows you to create a shape by defining an arbitrary set of vertices. In fact, some of the objects that you can use (such as Rect, Circle, Polygon, …) are examples of a ShapeStim. They just have their vertices preset so that we don’t need to bother about that.

So what are vertices? They are basically the points, or (x,y) coordinates, that define a figure. For example, a rectangle can be defined by four vertices. The actual rectangle is then rendered by drawing a line through those points. The principle also applies to circles. Although we say that a circle is round, the drawing process on a computer involves defining a lot of vertices on the circumference of that circle. These vertices are connected by straight lines, but because you have so many of them (and because each single one is very tiny), you have the impression of a round object.

Step 1: creating a circle

In fact, let’s start by doing just that. We will create a circle by manually specifying the vertices and then create a ShapeStim based on those vertices. So how do we create these vertices? Since a circle consists of 360°, we will first say that we will sample the circumference after each 1° (this is the step_size). However, vertices are (x,y) coordinates, so we need to convert these degrees. This is where the trigonometry kicks in. If we know the radius of a circle and an angle on the circumference, we can get to the (x,y) coordinates using the following formula:

x = radius * cos(angle)

y = radius * sin(angle)

And that’s all you need to know! The only thing we now need is a loop that goes through all 360°, and for each angle we calculate the corresponding (x,y) coordinates. Each coordinate is added to a list of vertices. Finally, we create a ShapeStim and set its vertices equal to the list of vertices we created. The remainder of the code takes are of displaying a window that closes as soon as you hit a key. 

from psychopy import visual, event
import math

# Shape parameters
step_size    = 1
outer_radius = 90

# Define the vertices
vertices = []
for angle in range(360, -1, -step_size):
    x = outer_radius * math.cos(math.radians(angle))
    y = outer_radius * math.sin(math.radians(angle))
    vertices.append([x, y])

# Create PsychoPy components
win = visual.Window((800, 600), units = 'pix')
shape = visual.ShapeStim(win, vertices = vertices)

# Run the program
while True:
    shape.draw()
    win.flip()
    
    if event.getKeys():
        break

win.close()

The only additional thing that happens here is that before we calculate the sine and cosine, we convert the angle to radians (using the math.radians function). If you do not know what radians are, just think of them as a different way of defining a circle. Most people are used to thinking that a circle goes from 0° to 360°. However, if you are a mathematician you would prefer to say that a circle goes from 0 to 2pi radians. The trigonometric functions in the Python math module assume that radians are used. Therefore you need to convert from degrees first. In fact, whenever you are doing something with trigonometric functions and you find you are getting some really strange results, check your units! Usually, this is where the mistakes happen.

Step 2: Going back

If you look carefully at the shape we want to create, you can see that there are two parts to it. First, by starting at the small tail, you can move on the outer circumference of a circle until you arrive at the widest end. The outer circumference is also fixed (i.e., it does not change as a function of where you are on the circle).

Then, you move from the outer radius to the circumference of a smaller circle by adding one additional vertex. In the code you can see that we keep the y-coordinate from the previous step and just update the value of the x-coordinate.

From that point on, you move back to the tail of the shape. However, the inner circumference is not fixed and does change as a function of where you are. To get the proper radius at each degree, we can decompose the problem in the following steps: we start with an inner_radius. The inner_radius differs from the outer_radius by an amount outer_radius – inner_radius. This difference is added to the inner_radius proportionally to where we are on the circumference of the circle (angle/360).

# Shape parameters
step_size    = 1
outer_radius = 90
inner_radius = 70

# Define the vertices
vertices = []
for angle in range(360, -1, -step_size):
    x = outer_radius * math.cos(math.radians(angle))
    y = outer_radius * math.sin(math.radians(angle))
    vertices.append([x, y])

# Move to inner radius    
x = inner_radius * math.cos(math.radians(angle))
vertices.append([x, y])

# Turn back
for angle in range(0, 360 + 1, step_size):
    radius = inner_radius + (outer_radius-inner_radius) * (angle/360)
    x = radius * math.cos(math.radians(angle))
    y = radius * math.sin(math.radians(angle))
    vertices.append([x, y])

In the code above we only show the steps for creating the vertices. Creating the PsychoPy components and drawing the shape can be done in the same way as the previous part. This is what you should see after running the program:

Adding an offset

Notice how in the screenshot above, the tail of the shape coincides with the widest part. A small cosmetic improvement would be the include a small offset between these two parts. For that, take the following steps:

  1. Define a variable called offset. This is the distance in degrees between the tail and the other end.
  2. Since there now is a distance between both parts of the shape, this means that the total distance we travel around the circle to get to both ends of the shape is now 360 - offset. Assign this value to a variable named span.
  3. In the remainder of the code, replace the value 0 with offset, and the value 360 with the span variable.

If you have done everthing correctly, you should see the following result for an offset value of 10: