Putting it all together—another example

Objectives

  • Be able to follow and understand another example of experiment creation and implementation in Python.

In this lesson, we are going to work through the process of implementing another experiment in Python and psychopy.

Our experiment involves a particular visual illusion in which a completely static stimulus appears to be moving, particularly around the time of an eye movement. Perhaps it was motivated after seeing the cover of the album Merriweather Post Pavilion by Animal Collective, as shown below.

Cover of Animal Collective's album "Merriweather Post Pavillion"

We are interested in knowing what factors in the stimulus lead us to perceive the motion. We have an inkling that the light and dark regions at the edge of each oval are critical to the illusion. Hence, we decide to run an experiment where we manipulate the difference between the intensity of the light and dark regions as our independent variable. As our dependent variable, we will have participants rate the subjective experience of motion on a scale from 1 to 5.

As before, we will work through our experiment framework and build up the code as we go through.

1. What information is needed to begin an experiment session?

We will require a participant identifier and a repeat number. As all the experimental conditions will be experienced during each repeat, there is no need to specify anything to do with conditions.

import os
import sys

import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

2. What data will be recorded from the experiment session?

We will be recording behavioural data, consisting of the information about each trial.

import os
import sys

import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

exp_data = []

3. What sort of stimuli are required?

Now we reach the tricky part—how can we create a stimulus containing the illusion? First, we need to closely examine our demonstration stimulus and determine its key components. On visual inspection of the image above, we can see that it is formed from a grid of green oval shapes. Moving across the columns from left to right, we can see that the orientation of each successive oval is shifted anti-clockwise from the previous oval. Moving up the rows from the bottom, we can see that the orientation of each successive oval is shifted clockwise from the previous oval. Looking at each individual oval, we can also see that one side has a white edge and the other side has a black edge. Overall, the ovals are presented on a dark lightly-textured background of blues and purples.

Now that we have a qualitative understanding the key components of the stimulus, let’s make them quantitative so we can specify them in our code. First, let’s set the overall stimulus size at 400 pixels. This is probably too small if we were running the experiment “for real”, but it will do for our purposes. Next, let’s set the background colour to a uniform darkish blue; say [-1, -1, -0,25]. Now let’s think about each oval; we will give it a lightish green colour, say [-1, 0.25, -1], and a size of 10 pixels horizontally and 18 pixels vertically. We will give the line around the outside a width of 3 pixels. Ultimately, we will want to manipulate the intensity of the light and dark edges, but for now let’s set them to +1 and -1. Now for the grid, let’s have 40 ovals both horizontally and vertically, spaced evenly between 20 and 380 pixels in the window. Finally, let’s make the change in orientation of successive ovals 30 degrees anti-clockwise across columns and 30 degrees clockwise across ascending rows.

We will approach this by creating a different set of code to our main experiment that we can use to get a working stimulus without worrying about the other components of the experiment. First, let’s specify in code the above stimulus details and prepare a window:

import psychopy.visual

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

psychopy.event.waitKeys()

win.close()

Now let’s think about the ovals. The basic shape is straightforward; we can simply use a Circle with an unequal aspect ratio:

import psychopy.visual

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

n_edges = 128

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=n_edges,
    fillColor=oval_fill_colour,
)

psychopy.event.waitKeys()

win.close()

The tricky part is getting the outside lines to have different intensities. Here, our strategy is going to be to create two new shapes for each oval; one corresponding to the light edge and one corresponding to the dark edge. To do this, we will use a ShapeStim, which requires the coordinates of each vertex to be specified. We could work out the positioning of vertices along the required arc, but it is easier just to extract half the vertices from our oval stimulus, which has a property verticesPix. To stop ShapeStim producing an enclosed shape, we set the closeShape parameter to False.

import psychopy.visual

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

n_edges = 128

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

psychopy.event.waitKeys()

win.close()

OK, so that will give us one oval with light and dark edges. Now, we want to draw a grid of such ovals, with the appropriate offsets in orientation across rows and columns. To do so, we will loop across columns and then across rows—you can visualise this as starting off in the bottom left hand corner, then moving one spot to the right, then continuing until reaching the end of the row at which point you move to the second row from the bottom and repeat the process.

First, we set the starting orientation of the items in a given row. We do this by multiplying the row index by how much we want the orientation to change across rows (30 degrees, in our example). Then, for each column we add the column offset (-30 degrees, in our example). We then update the position and orientation and draw the stimulus. We can then have a look at it!

import psychopy.visual
import psychopy.event

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

n_edges = 128

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

offsets = range(-180, 181, n_ovals_per_dim)

row_count = 0

for y_offset in offsets:

    ori = row_count * oval_row_ori_change

    for x_offset in offsets:

        ori = ori + oval_col_ori_change

        for stim in [oval, left_side, right_side]:
            stim.pos = [x_offset, y_offset]
            stim.ori = ori
            stim.draw()

    row_count = row_count + 1

win.flip()

psychopy.event.waitKeys()

win.close()

Not quite the same, but close enough. Now, let’s fold the stimulus creation into our experiment code, leaving the drawing until later.

import os
import sys

import psychopy.visual
import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

exp_data = []

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0
oval_n_edges = 128

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=oval_n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(oval_n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

offsets = range(-180, 181, n_ovals_per_dim)

win.close()

4. What trial sequence will be used?

Our independent variable in this experiment is the difference of the light and dark edges from mid-grey. We will probe five levels, equally spaced between 0 and 1, with each having 10 trials per repeat. We want the order of trials to be randomised across the course of the repeat. We can achieve the above in code via:

import os
import sys
import random

import psychopy.visual
import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

exp_data = []

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0
oval_n_edges = 128

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=oval_n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(oval_n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

offsets = range(-180, 181, n_ovals_per_dim)

oval_line_contrasts = [0, 0.25, 0.5, 0.75, 1.0]
n_oval_line_contrasts = len(oval_line_contrasts)

n_trials_per_line_contrast = 10

trial_oval_line_contrasts = oval_line_contrasts * n_trials_per_line_contrast
random.shuffle(trial_oval_line_contrasts)

win.close()

In the above, we first created a list of the line intensity differences (which we call the “line contrast”) for all 50 trials (5 conditions times 10 trials per condition), in non-random order. We then used random.shuffle to randomise this list—if we now use the items in turn, the order of conditions will be randomised.

5. What instructions need to be provided?

Here, we will simply instruct the participant of what keys to push and how they relate to their perception. Were we creating a real experiment, we would likely want to create some more involved instructions so that we gain better control of the criteria that participants use to make their ratings.

import os
import sys
import random

import psychopy.visual
import psychopy.event
import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

exp_data = []

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0
oval_n_edges = 128

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=oval_n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(oval_n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

offsets = range(-180, 181, n_ovals_per_dim)

oval_line_contrasts = [0, 0.25, 0.5, 0.75, 1.0]
n_oval_line_contrasts = len(oval_line_contrasts)

n_trials_per_line_contrast = 10

trial_oval_line_contrasts = oval_line_contrasts * n_trials_per_line_contrast
random.shuffle(trial_oval_line_contrasts)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press a key from 1 to 5 to rate your sense of motion for the stimulus on a
given trial.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

win.close()

6. What happens on a given trial?

On a given trial, we are going to show the stimulus with a given line contrast for 500ms, after a 500ms blank period, and then wait for a response from the participant, with a minimum inter-trial interval of 2 seconds. To draw the stimulus, we will follow the strategy we worked out in the demonstration code.

import os
import sys
import random

import psychopy.visual
import psychopy.event
import psychopy.gui
import psychopy.core


gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

exp_data = []

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0
oval_n_edges = 128

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=oval_n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(oval_n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

offsets = range(-180, 181, n_ovals_per_dim)

oval_line_contrasts = [0, 0.25, 0.5, 0.75, 1.0]
n_oval_line_contrasts = len(oval_line_contrasts)

n_trials_per_line_contrast = 10

trial_oval_line_contrasts = oval_line_contrasts * n_trials_per_line_contrast
random.shuffle(trial_oval_line_contrasts)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press a key from 1 to 5 to rate your sense of motion for the stimulus on a
given trial.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

pre_duration_s = 0.5
stim_duration_s = 0.5

min_iti_s = 2.0

clock = psychopy.core.Clock()

for trial_oval_line_contrast in trial_oval_line_contrasts:

    left_side.lineColor = [-trial_oval_line_contrast] * 3
    right_side.lineColor = [+trial_oval_line_contrast] * 3

    clock.reset()

    while clock.getTime() < pre_duration_s:
        win.flip()

    while clock.getTime() < (pre_duration_s + stim_duration_s):

        row_count = 0

        for y_offset in offsets:

            ori = row_count * oval_row_ori_change

            for x_offset in offsets:

                ori = ori + oval_col_ori_change

                for stim in [oval, left_side, right_side]:
                    stim.pos = [x_offset, y_offset]
                    stim.ori = ori
                    stim.draw()

            row_count = row_count + 1

        win.flip()

    win.flip()

    keys = psychopy.event.waitKeys(
        keyList=["1", "2", "3", "4", "5", "q"],
        timeStamped=clock
    )

    while clock.getTime() < min_iti_s:
        win.flip()

win.close()

7. What data needs to be recorded for each trial?

For each trial, we need to record the line contrast and the response. While we are there, we will record the response time.

import os
import sys
import random

import psychopy.visual
import psychopy.event
import psychopy.gui
import psychopy.core


gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

exp_data = []

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0
oval_n_edges = 128

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=oval_n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(oval_n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

offsets = range(-180, 181, n_ovals_per_dim)

oval_line_contrasts = [0, 0.25, 0.5, 0.75, 1.0]
n_oval_line_contrasts = len(oval_line_contrasts)

n_trials_per_line_contrast = 10

trial_oval_line_contrasts = oval_line_contrasts * n_trials_per_line_contrast
random.shuffle(trial_oval_line_contrasts)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press a key from 1 to 5 to rate your sense of motion for the stimulus on a
given trial.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

pre_duration_s = 0.5
stim_duration_s = 0.5

min_iti_s = 2.0

clock = psychopy.core.Clock()

for trial_oval_line_contrast in trial_oval_line_contrasts:

    left_side.lineColor = [-trial_oval_line_contrast] * 3
    right_side.lineColor = [+trial_oval_line_contrast] * 3

    clock.reset()

    while clock.getTime() < pre_duration_s:
        win.flip()

    while clock.getTime() < (pre_duration_s + stim_duration_s):

        row_count = 0

        for y_offset in offsets:

            ori = row_count * oval_row_ori_change

            for x_offset in offsets:

                ori = ori + oval_col_ori_change

                for stim in [oval, left_side, right_side]:
                    stim.pos = [x_offset, y_offset]
                    stim.ori = ori
                    stim.draw()

            row_count = row_count + 1

        win.flip()

    win.flip()

    keys = psychopy.event.waitKeys(
        keyList=["1", "2", "3", "4", "5", "q"],
        timeStamped=clock
    )

    for key in keys:

        if key[0] == "q":
            sys.exit("User quit")

        key_num = int(key[0])
        rt = key[1]

    trial_data = [trial_oval_line_contrast, key_num, rt]

    exp_data.append(trial_data)

    while clock.getTime() < min_iti_s:
        win.flip()

win.close()

Notice that we have converted the numeric responses, which are received as strings, into integers. We have also allowed a “q” response, which terminates the program. This is often a good thing to add to your program, at least during development.

8. How should the data be saved for further analysis?

We want to save the data as a plain-text file, where each row is a trial and the columns are the line contrast, the rating, and the response time.

import os
import sys
import random

import numpy as np

import psychopy.visual
import psychopy.event
import psychopy.gui
import psychopy.core


gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
rep_num = gui.data[1]

data_path = subj_id + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

exp_data = []

bg_colour = [-1, -1, -0.25]

oval_radius_pix = [10, 18]
oval_fill_colour = [-1, 0.25, -1]
n_ovals_per_dim = 40
oval_col_ori_change = -30
oval_row_ori_change = +30
oval_edge_contrast = 1.0
oval_line_width = 3.0
oval_n_edges = 128

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False,
    color=bg_colour
)

oval = psychopy.visual.Circle(
    win=win,
    radius=oval_radius_pix,
    units="pix",
    edges=oval_n_edges,
    fillColor=oval_fill_colour,
)

vertices = oval.verticesPix.tolist()

right_vertices = vertices[:(oval_n_edges / 2 + 1)]

left_vertices = []

for vertex in right_vertices:
    left_vertices.append([-vertex[0], vertex[1]])

left_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=left_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[-oval_edge_contrast] * 3
)

right_side = psychopy.visual.ShapeStim(
    win=win,
    vertices=right_vertices,
    closeShape=False,
    units="pix",
    lineWidth=oval_line_width,
    lineColor=[+oval_edge_contrast] * 3
)

offsets = range(-180, 181, n_ovals_per_dim)

oval_line_contrasts = [0, 0.25, 0.5, 0.75, 1.0]
n_oval_line_contrasts = len(oval_line_contrasts)

n_trials_per_line_contrast = 10

trial_oval_line_contrasts = oval_line_contrasts * n_trials_per_line_contrast
random.shuffle(trial_oval_line_contrasts)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press a key from 1 to 5 to rate your sense of motion for the stimulus on a
given trial.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

pre_duration_s = 0.5
stim_duration_s = 0.5

min_iti_s = 2.0

clock = psychopy.core.Clock()

for trial_oval_line_contrast in trial_oval_line_contrasts:

    left_side.lineColor = [-trial_oval_line_contrast] * 3
    right_side.lineColor = [+trial_oval_line_contrast] * 3

    clock.reset()

    while clock.getTime() < pre_duration_s:
        win.flip()

    while clock.getTime() < (pre_duration_s + stim_duration_s):

        row_count = 0

        for y_offset in offsets:

            ori = row_count * oval_row_ori_change

            for x_offset in offsets:

                ori = ori + oval_col_ori_change

                for stim in [oval, left_side, right_side]:
                    stim.pos = [x_offset, y_offset]
                    stim.ori = ori
                    stim.draw()

            row_count = row_count + 1

        win.flip()

    win.flip()

    keys = psychopy.event.waitKeys(
        keyList=["1", "2", "3", "4", "5", "q"],
        timeStamped=clock
    )

    for key in keys:

        if key[0] == "q":
            sys.exit("User quit")

        key_num = int(key[0])
        rt = key[1]

    trial_data = [trial_oval_line_contrast, key_num, rt]

    exp_data.append(trial_data)

    while clock.getTime() < min_iti_s:
        win.flip()

np.savetxt(data_path, exp_data, delimiter="\t")

win.close()

Summary

Here, we have been able to put together another full example experiment. The most complicated aspect was the stimulus creation, but we ended up being able to combine some simple psychopy components to generate quite a complex stimulus. We also used a randomised trial sequence and a different kind of task.