Back to the main index

Practice with PsychoPy

Part of the introductory series to using Python for Vision Research brought to you by the GestaltReVision group (KU Leuven, Belgium).

In this part, you will gain more experience with using PsychoPy for various stimulus drawing tasks.

Author: Jonas Kubilius
Year: 2014
Copyright: Public Domain as in CC0

Contents

Quick setup

(you'll have to rerun this cell every time the kernel dies)

In [ ]:
import numpy as np
from psychopy import visual, core, event, monitors

General tips

  1. Break down the task into concrete steps of what you need to do.
  2. For each step, look up the classes and functions you need in the PsychoPy Documentation.
  3. Once you find the relevant object, you need to call it properly. Suppose you need the TextStim. You will see in documentation that is it defined like this: class psychopy.visual.TextStim(win, text='Hello World', font='', ...). If you imported the relevant PsychoPy modules with the command above, then Python knows what visual is. So you initialize the TextStim as: visual.TextStim(...).
  4. Use the absolute minimum parameters necessary to initialize an object. Want a text Hit spacebar? Then visual.TextStim(win, text='Hit spacebar') will suffice. Notice that there are two types of parameters: normal arguments (win) and keyword arguments (text=..., pos=...). Normal arguments are always required in order to call an objects. Keyword arguments are optional because they have a default value which is used unless you pass a different value.
  5. Also notice that object names are case-sensitive. If you see Window written in the PsychoPy documentation, that means you have to call it exactly like that and not window or WiNdOW. The convention is that classes start with a capital letter and may have some more capital letters mixed in (e.g., TextStim()), while the rest is in lowercase (e.g., flip()). In PsychoPy, one unconventional thing is that functions usually have some capital letters, like waitKeys(). For your own scripts, try to stick to lowercase, like show_stimuli().

Exercise 1

Draw a red rectangle on white background and show it until a key is pressed.

Information

No idea what to do, right? Basically, you have to fill in the blanks and ellipses:

In [ ]:
# open a window
win = visual.Window(color='white')
# create a red rectangle stimulus
rect = visual.Rect(win, size=(.5,.3), fillColor='red')
# draw the stimulus
rect.draw()
# flip the window
win.flip()
# wait for a key response
event.waitKeys()
# close window
win.close()

OK, but you can't remember any PsychoPy commands? Me neither. I use the online documentation to help me out.

Tip: Can't close the window? Restart the kernel (Kernel > Restart). Remember to reimport all packages that are listed at the top of this notebook after restart.

Solution

In [ ]:
win = visual.Window(color='white')
rect = visual.Rect(win, width=.5, height=.3, fillColor='red')
rect.draw() 
win.flip()  # don't forget to flip when done with drawing all stimuli so that the stimuli become visible
event.waitKeys()
win.close()

Tip. Notice the fillColor keyword. Make sure you understand why we use this and not just color. Check out the explanation of colors and color spaces.

Exercise 2: Japanese flag

Draw a red circle on white background and show it until a keys is pressed.

Information

Oh, that sounds trivial now? Just change Rectangle to Circle? Well, try it:

In [ ]:
win = visual.Window(color='white')
circle = visual.Circle(win, radius=.4, fillColor='red')
circle.draw()
win.flip()  # don't forget to flip when done with drawing all stimuli so that the stimuli become visible
event.waitKeys()
win.close()

And oops, you get an ellipse (at least I do). Why?

Solution

By default, PsychoPy is using normalized ('norm') units that are proportional to the window. Since the window is rectangular, everything gets distorted horizontally. In order to keep aspect ratio sane, use 'height' units. Read more here

In [ ]:
win = visual.Window(color='white', units='height')
circle = visual.Circle(win, radius=.4, fillColor='red')
circle.draw()
win.flip()  # don't forget to flip when done with drawing all stimuli so that the stimuli become visible
event.waitKeys()
win.close()

Usually, however, people use 'deg' units that allow defining stimuli in terms of their size in visual angle. However, to be able to use visual angle, you first have to define you monitor parameters: resolution, width, and viewing distance. (You can also apply gamma correction etc.)

In [ ]:
mon = monitors.Monitor('My screen', width=37.5, distance=57)
mon.setSizePix((1280,1024))
win = visual.Window(color='white', units='deg', monitor=mon)
circle = visual.Circle(win, radius=.4, fillColor='red')
circle.draw()
win.flip()  # don't forget to flip when done with drawing all stimuli so that the stimuli become visible
event.waitKeys()
win.close()

Pro Tip. Hate specifying monitor resolution manually? (Note that wx is messed up and next time you run this snippet it's not gonna work because 'app' is somehow already there... just rename app to app2 then.)

In [ ]:
import wx
app = wx.App(False)  # create an app if there isn't one and don't show it
nmons = wx.Display.GetCount()  # how many monitors we have
mon_sizes = [wx.Display(i).GetGeometry().GetSize() for i in range(nmons)]
print mon_sizes

Exercise 3

Draw a fixation cross, a radial stimulus on the left (like used in fMRI for retinotopic mapping) and a gabor patch on the left all on the default ugly gray background.

Information

Oh no, how do you make a gabor patch? And a radial stimulus? Something like that was in Part 3 so are we going to do the same? Well, think a bit. Chances are that other people needed these kind of stimulus in the past. Maybe PsychoPy has them built-in?

Solution

In [ ]:
mon = monitors.Monitor('My screen', width=37.5, distance=57)
mon.setSizePix((1280,1024))
win = visual.Window(units='deg', monitor=mon)

# make stimuli
fix_hor = visual.Line(win, start=(-.3, 0), end=(.3, 0), lineWidth=3)
fix_ver = visual.Line(win, start=(0, -.3), end=(0, .3), lineWidth=3)
radial = visual.RadialStim(win, mask='gauss',size=(3,3), pos=(-4, 0))
gabor = visual.GratingStim(win, mask='gauss', size=(3,3), pos=(4, 0))

# draw stimuli
fix_hor.draw()
fix_ver.draw()
radial.draw()
gabor.draw()

win.flip()
event.waitKeys()
win.close()

Follow-up: PsychoPy is not perfect yet

PsychoPy has been around for long enough to be a stable package. However, it is still evolving and bugs may occur. Some of them are quite complex but others are something you can easily fix as long as you're not afraid of getting your hand dirty. You shouldn't be, and I'll illustrate that with the following example:

Draw the same shapes as before but this time make the fixation cross black.

So that should be a piece of cake, right? According to the documentation, simply adding color='black' to the LineStim should do the trick. Go ahead ant try it:

In [ ]:
mon = monitors.Monitor('My screen', width=37.5, distance=57)
mon.setSizePix((1280,1024))
win = visual.Window(units='deg', monitor=mon)

# make stimulic
fix_hor = visual.Line(win, start=(-.3, 0), end=(.3, 0), lineColor='black')
fix_ver = visual.Line(win, start=(0, -.3), end=(0, .3), lineColor='black')
radial = visual.RadialStim(win, size=(3,3), pos=(-4, 0))
gabor = visual.GratingStim(win, mask='gauss', size=(3,3), pos=(4, 0))

# draw stimuli
fix_hor.draw()
fix_ver.draw()
radial.draw()
gabor.draw()

win.flip()
event.waitKeys()
win.close()

You should get an error along the lines of

TypeError: __init__() got an unexpected keyword argument 'color'

*(Because of this error, the window remains open -- simply restart the kernel to kill it, and reimport all modules at Quick setup.)

So now what? You need that black fixation cross real bad. Notice the error message tells you the whole hierarchy of how the problem came about:

  • it started with fix_hor = visual.Line(win, start=(-.3, 0), end=(.3, 0), color='black') -- clearly due to the color keyword cause it used to work before
  • which called ShapeStim.__init__(self, win, **kwargs) and that raised an error.

If you were to check out ShapeStim's documentation, you'd see that ShapeStim only accepts fillColor and lineColor but not color keywords (even though later in the documentation it seems as if there were a color keyword too -- yet another bug).

OK, so if you don't care, just use lineColor='black' and it will do the job.

However, consider that Jon Peirce and other people has put lots of love in creating PsychoPy. If you find something not working, why not let them know? You can easily report bugs on Psychopy's GitHub repo or, if you're not confident there is a bug, just post it on the Psychopy's help forum.

But the best of all is trying to fix it yourself, and reporting the bug together with a fix. That way you help not only yourself, but also many other users. Let's see if we can fix this one. First, notice that the problem is that ShapeStim does not recognize the color keyword. We are not going to mess with ShapeStim because it has fillColor and lineColor for a reason. So instead we can modify Line to accept this keyword. So -- open up the file where Line is defined and change it. In my case, this is C:\Miniconda32\envs\psychopy\lib\site-packages\psychopy\visual\line.py.

Simply insert color=None in def __init__() (line 21 in my case), and kwargs['lineColor'] = color just below kwargs['fillColor'] = None (line 50) and self.color = self.lineColor right after calling ShapeStim.__init__() (line 51). That's it! Just restart the kernel in this notebook, reimport all packages at the top (to update them with this change), and run the code above again. That should run now.

Note that this is not the full fix yet because we still need to include colorSpace keyword and also functions such as setColor and setColorSpace, and there may be yet other compactibility issues to verify. But for our modest purposes, it's fixed!

Let it be a lesson for you as well about the whole idea behind open source -- if something is not working, just open the source file and fix it. You're in control here. Now go ahead and fix a bug in your Windows or OS X.

Advanced. The proper way to submit you fixes is by forking the repo, making a patch, and submitting a pull request, as explained on GitHub's help.

Exercise 4: Hinton's "Lilac Chaser"

In this exercise, we will create the famous Hinton's "Lilac Chaser". The display consists of 12 equally-spaced blurry pink dots on a larger circle (on a light gray background). Dots are disappearing one after another to create an illusion of a green dot moving.

Hints

If your math is a bit rusty at the moment, here is how to find the coordinates for placing the pink dots on a circle:

In [ ]:
r = 5  # radius of the big circle
ncircles = 12
angle = 2 * np.pi / ncircles  # angle between two pink dots in radians

for i in range(ncircles): 
    pos = (r*np.cos(angle*i), r*np.sin(angle*i))

Part 1

Draw 12 equally-spaced dots on a larger circle. (Don't worry about making them blurry for now.) Also make a fixation spot.

Solution

In [ ]:
mon = monitors.Monitor('My screen', width=37.5, distance=57)
mon.setSizePix((1280,1024))
win = visual.Window(color='lightgray', units='deg', monitor=mon)

# draw a fixation
fix = visual.Circle(win, radius=.1, fillColor='black')
fix.draw()

r = 5  # radius of the larger circle
ncircles = 12
angle = 2 * np.pi / ncircles

# make and draw stimuli
for i in range(ncircles):
    pos = (r*np.cos(angle*i), r*np.sin(angle*i))
    circle = visual.Circle(win, radius=.5, fillColor='purple', lineColor='purple', pos=pos)
    circle.draw()

win.flip()
event.waitKeys()
win.close()

Part 2

Make dots disappear one at a time.

Solution

In [ ]:
mon = monitors.Monitor('My screen', width=37.5, distance=57)
mon.setSizePix((1280,1024))
win = visual.Window(color='lightgray', units='deg', monitor=mon)

# make a fixation
fix = visual.Circle(win, radius=.1, fillColor='black')

r = 5  # radius of the larger circle
ncircles = 12
angle = 2 * np.pi / ncircles

# make and draw stimuli
dis = 0  # which one will disappear

while len(event.getKeys()) == 0: 
    for i in range(ncircles):
        if i != dis:
            pos = (r*np.cos(angle*i), r*np.sin(angle*i))
            circle = visual.Circle(win, radius=.5, fillColor='purple', lineColor='purple', pos=pos)
            circle.draw()
    dis = (dis + 1) % ncircles
    fix.draw()
    win.flip()
    core.wait(.1)
    
win.close()

Part 3 (advanced)

Optimize your code; make dots blurry.

Solution

In [ ]:
mon = monitors.Monitor('My screen', width=37.5, distance=57)
mon.setSizePix((1280,1024))
win = visual.Window(color='lightgray', units='deg', monitor=mon)

r = 5  # radius of the larger circle
ncircles = 12
angle = 2 * np.pi / ncircles

# make a stimuli
fix = visual.Circle(win, radius=.1, fillColor='black')
circle = visual.GratingStim(win, size=(2,2), tex=None, mask='gauss', color='purple')

# make and draw stimuli
dis = 0  # which one will disappear

while len(event.getKeys()) == 0: 
    for i in range(ncircles):
        if i != dis:
            pos = (r*np.cos(angle*i), r*np.sin(angle*i))
            circle.setPos(pos)
            circle.draw()
    dis = (dis + 1) % ncircles
    fix.draw()
    win.flip()
    core.wait(.1)
    
win.close()

Resources

  • PsychoPy
  • Documentation
  • Help forum
  • Where are all my packages? import site; print site.getsitepackages()
  • GitHub repository with the latest (but unstable) version of Psychopy where bugs might have been fixed
  • Report bugs
  • Cite PsychoPy in your papers (at least one of the folllowing):

    • Peirce, JW (2007) PsychoPy - Psychophysics software in Python. J Neurosci Methods, 162(1-2):8-13
    • Peirce JW (2009) Generating stimuli for neuroscience using PsychoPy. Front. Neuroinform. 2:10. doi:10.3389/neuro.11.010.2008