Browse Source

Initial Commit -MT

master
Michael Tan 6 years ago
commit
e87e4f79b8
  1. 366
      EyeLinkCoreGraphicsPsychoPy.py
  2. 470
      SaccadePursuit.py
  3. 367
      SaccadePursuitEyeTracking.py
  4. 272
      eyelinker.py
  5. 362
      template.py

366
EyeLinkCoreGraphicsPsychoPy.py

@ -0,0 +1,366 @@
# Copyright (C) 2018 Zhiguo Wang
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or (at
# your option) any later version.
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
# Rev. March, 2018
# 1. added the print_function for Python 3 support
# 2. use the list(zip(x,y)) function to make sure zip returns a list
# 3. Added an image scaling factor to accommodate all versions of the API
# 4. Added the missing CR adjustment keys (-/+=)
# 5. revised the draw_image_line function
# 6. tracker parameters set at initialization
# Rev. July, 2018
# 1. Force drawing in absolute pixel coordinates, switch back to the units
# set by the user at the end of the calibration routine.
# 2. Updated the draw camera image function
# 3. Updated the draw lozenge function
# 4. camera image and drawing are all properly scaled
# Rev. August 22, 2018
# 1. Misplacement of calibration targets on Macs
# 2. Misalignment of crosshairs/squares and camera image
# Rev. February 5, 2019
# 1. Misalighnment of crosshairs/squares on Mac
# Rev. September 10, 2019
# 1. clear screen when entering camera setup mode
# 2. tested with PsychoPy version 3.2.2
# 3. Reformatted the calibration instructions to improve code clarity
from __future__ import print_function
from psychopy import visual, event, core, sound
from numpy import linspace
from math import sin, cos, pi
from PIL import Image
import array, string, pylink, os
class EyeLinkCoreGraphicsPsychoPy(pylink.EyeLinkCustomDisplay):
def __init__(self, tracker, win):
'''Initialize a Custom EyeLinkCoreGraphics
tracker: an eye-tracker instance
win: the Psychopy display we plan to use for stimulus presentation '''
pylink.EyeLinkCustomDisplay.__init__(self)
self.pylinkMinorVer = pylink.__version__.split('.')[1] # minor version 1-Mac, 11-Win/Linux
self.display = win
self.w, self.h = win.size
# on Macs with HiDPI screens, force the drawing routine to use the size defined
# in the monitor instance, as win.size will give the wrong size of the screen
if os.name == 'posix':
self.w,self.h = win.monitor.getSizePix()
#self.display.autoLog = False
# check the screen units of Psychopy, forcing the screen to use 'pix'
self.units = win.units
if self.units != 'pix': self.display.setUnits('pix')
# Simple warning beeps
self.__target_beep__ = sound.Sound('A', octave=4, secs=0.1)
self.__target_beep__done__ = sound.Sound('E', octave=4, secs=0.1)
self.__target_beep__error__ = sound.Sound('E', octave=6, secs=0.1)
self.imgBuffInitType = 'I'
self.imagebuffer = array.array(self.imgBuffInitType)
self.resizeImagebuffer = array.array(self.imgBuffInitType)
self.pal = None
self.bg_color = win.color
self.img_scaling_factor = 3
self.size = (192*self.img_scaling_factor, 160*self.img_scaling_factor)
# initial setup for the mouse
self.display.mouseVisible = False
self.mouse = event.Mouse(visible=False)
self.last_mouse_state = -1
# image title & calibration instructions
self.msgHeight = self.size[1]/20.0
self.title = visual.TextStim(self.display,'', height=self.msgHeight, color=[1,1,1],
pos = (0,-self.size[1]/2-self.msgHeight), wrapWidth=self.w, units='pix')
self.calibInst = visual.TextStim(self.display, alignHoriz='left',alignVert ='top', height=self.msgHeight, color=[1,1,1],
pos = (-self.w/2.0, self.h/2.0), units='pix',
text = 'Enter: Show/Hide camera image\n' +
'Left/Right: Switch camera view\n' +
'C: Calibration\n' +
'V: Validation\n' +
'O: Start Recording\n' +
'+=/-: CR threshold\n' +
'Up/Down: Pupil threshold\n' +
'Alt+arrows: Search limit')
# lines for drawing cross hair etc.
self.line = visual.Line(self.display, start=(0, 0), end=(0,0),
lineWidth=2.0, lineColor=[0,0,0], units='pix')
# set a few tracker parameters
self.tracker=tracker
self.tracker.setOfflineMode()
self.tracker_version = tracker.getTrackerVersion()
if self.tracker_version >=3:
self.tracker.sendCommand("enable_search_limits=YES")
self.tracker.sendCommand("track_search_limits=YES")
self.tracker.sendCommand("autothreshold_click=YES")
self.tracker.sendCommand("autothreshold_repeat=YES")
self.tracker.sendCommand("enable_camera_position_detect=YES")
# let the tracker know the correct screen resolution being used
self.tracker.sendCommand("screen_pixel_coords = 0 0 %d %d" % (self.w-1, self.h-1))
def setup_cal_display(self):
'''Set up the calibration display before entering the calibration/validation routine'''
self.display.clearBuffer()
self.calibInst.autoDraw = True
self.clear_cal_display()
def clear_cal_display(self):
'''Clear the calibration display'''
self.calibInst.autoDraw = False
self.title.autoDraw = False
self.display.clearBuffer()
self.display.color = self.bg_color
self.display.flip()
def exit_cal_display(self):
'''Exit the calibration/validation routine, set the screen units to
the original one used by the user'''
self.display.setUnits(self.units)
self.clear_cal_display()
def record_abort_hide(self):
'''This function is called if aborted'''
pass
def erase_cal_target(self):
'''Erase the calibration/validation & drift-check target'''
self.clear_cal_display()
self.display.flip()
def draw_cal_target(self, x, y):
'''Draw the calibration/validation & drift-check target'''
self.clear_cal_display()
xVis = (x - self.w/2)
yVis = (self.h/2 - y)
cal_target_out = visual.GratingStim(self.display, tex='none', mask='circle',
size=2.0/100*self.w, color=[1.0,1.0,1.0], units='pix')
cal_target_in = visual.GratingStim(self.display, tex='none', mask='circle',
size=2.0/300*self.w, color=[-1.0,-1.0,-1.0], units='pix')
cal_target_out.setPos((xVis, yVis))
cal_target_in.setPos((xVis, yVis))
cal_target_out.draw()
cal_target_in.draw()
self.display.flip()
def play_beep(self, beepid):
''' Play a sound during calibration/drift correct.'''
if beepid == pylink.CAL_TARG_BEEP or beepid == pylink.DC_TARG_BEEP:
self.__target_beep__.play()
if beepid == pylink.CAL_ERR_BEEP or beepid == pylink.DC_ERR_BEEP:
self.__target_beep__error__.play()
if beepid in [pylink.CAL_GOOD_BEEP, pylink.DC_GOOD_BEEP]:
self.__target_beep__done__.play()
def getColorFromIndex(self, colorindex):
'''Return psychopy colors for elements in the camera image'''
if colorindex == pylink.CR_HAIR_COLOR: return (1, 1, 1)
elif colorindex == pylink.PUPIL_HAIR_COLOR: return (1, 1, 1)
elif colorindex == pylink.PUPIL_BOX_COLOR: return (-1, 1, -1)
elif colorindex == pylink.SEARCH_LIMIT_BOX_COLOR: return (1, -1, -1)
elif colorindex == pylink.MOUSE_CURSOR_COLOR: return (1, -1, -1)
else: return (0,0,0)
def draw_line(self, x1, y1, x2, y2, colorindex):
'''Draw a line. This is used for drawing crosshairs/squares'''
if self.pylinkMinorVer== '1': # the Mac version
x1 = x1/2; y1=y1/2; x2=x2/2;y2=y2/2;
y1 = (-y1 + self.size[1]/2)* self.img_scaling_factor
x1 = (+x1 - self.size[0]/2)* self.img_scaling_factor
y2 = (-y2 + self.size[1]/2)* self.img_scaling_factor
x2 = (+x2 - self.size[0]/2)* self.img_scaling_factor
color = self.getColorFromIndex(colorindex)
self.line.start = (x1, y1)
self.line.end = (x2, y2)
self.line.lineColor = color
self.line.draw()
def draw_lozenge(self, x, y, width, height, colorindex):
''' draw a lozenge to show the defined search limits
(x,y) is top-left corner of the bounding box
'''
if self.pylinkMinorVer == '1': # Mac version
x = x/2; y=y/2; width=width/2;height=height/2;
width = width * self.img_scaling_factor
height = height* self.img_scaling_factor
y = (-y + self.size[1]/2)* self.img_scaling_factor
x = (+x - self.size[0]/2)* self.img_scaling_factor
color = self.getColorFromIndex(colorindex)
if width > height:
rad = height / 2
if rad == 0: return #cannot draw the circle with 0 radius
Xs1 = [rad*cos(t) + x + rad for t in linspace(pi/2, pi/2+pi, 72)]
Ys1 = [rad*sin(t) + y - rad for t in linspace(pi/2, pi/2+pi, 72)]
Xs2 = [rad*cos(t) + x - rad + width for t in linspace(pi/2+pi, pi/2+2*pi, 72)]
Ys2 = [rad*sin(t) + y - rad for t in linspace(pi/2+pi, pi/2+2*pi, 72)]
else:
rad = width / 2
if rad == 0: return #cannot draw sthe circle with 0 radius
Xs1 = [rad*cos(t) + x + rad for t in linspace(0, pi, 72)]
Ys1 = [rad*sin(t) + y - rad for t in linspace(0, pi, 72)]
Xs2 = [rad*cos(t) + x + rad for t in linspace(pi, 2*pi, 72)]
Ys2 = [rad*sin(t) + y + rad - height for t in linspace(pi, 2*pi, 72)]
lozenge = visual.ShapeStim(self.display, vertices = list(zip(Xs1+Xs2, Ys1+Ys2)),
lineWidth=2.0, lineColor=color, closeShape=True, units='pix')
lozenge.draw()
def get_mouse_state(self):
'''Get the current mouse position and status'''
X, Y = self.mouse.getPos()
mX = self.size[0]/2.0*self.img_scaling_factor + X
mY = self.size[1]/2.0*self.img_scaling_factor - Y
if mX <=0: mX = 0
if mX > self.size[0]*self.img_scaling_factor:
mX = self.size[0]*self.img_scaling_factor
if mY < 0: mY = 0
if mY > self.size[1]*self.img_scaling_factor:
mY = self.size[1]*self.img_scaling_factor
state = self.mouse.getPressed()[0]
mX = mX/self.img_scaling_factor
mY = mY/self.img_scaling_factor
if self.pylinkMinorVer == '1':
mX = mX *2; mY = mY*2
return ((mX, mY), state)
def get_input_key(self):
''' this function will be constantly pools, update the stimuli here is you need
dynamic calibration target '''
ky=[]
for keycode, modifier in event.getKeys(modifiers=True):
k= pylink.JUNK_KEY
if keycode == 'f1': k = pylink.F1_KEY
elif keycode == 'f2': k = pylink.F2_KEY
elif keycode == 'f3': k = pylink.F3_KEY
elif keycode == 'f4': k = pylink.F4_KEY
elif keycode == 'f5': k = pylink.F5_KEY
elif keycode == 'f6': k = pylink.F6_KEY
elif keycode == 'f7': k = pylink.F7_KEY
elif keycode == 'f8': k = pylink.F8_KEY
elif keycode == 'f9': k = pylink.F9_KEY
elif keycode == 'f10': k = pylink.F10_KEY
elif keycode == 'pageup': k = pylink.PAGE_UP
elif keycode == 'pagedown': k = pylink.PAGE_DOWN
elif keycode == 'up': k = pylink.CURS_UP
elif keycode == 'down': k = pylink.CURS_DOWN
elif keycode == 'left': k = pylink.CURS_LEFT
elif keycode == 'right': k = pylink.CURS_RIGHT
elif keycode == 'backspace': k = ord('\b')
elif keycode == 'return': k = pylink.ENTER_KEY
elif keycode == 'space': k = ord(' ')
elif keycode == 'escape': k = pylink.ESC_KEY
elif keycode == 'tab': k = ord('\t')
elif keycode in string.ascii_letters: k = ord(keycode)
elif k== pylink.JUNK_KEY: k = 0
# plus/equal & minux signs for CR adjustment
if keycode in ['num_add', 'equal']: k = ord('+')
if keycode in ['num_subtract', 'minus']: k = ord('-')
if modifier['alt']==True: mod = 256
else: mod = 0
ky.append(pylink.KeyInput(k, mod))
#event.clearEvents()
return ky
def exit_image_display(self):
'''Clcear the camera image'''
self.clear_cal_display()
self.calibInst.autoDraw=True
self.display.flip()
def alert_printf(self,msg):
'''Print error messages.'''
print("Error: " + msg)
def setup_image_display(self, width, height):
''' set up the camera image, for newer APIs, the size is 384 x 320 pixels'''
self.last_mouse_state = -1
self.size = (width, height)
self.title.autoDraw = True
self.calibInst.autoDraw=True
def image_title(self, text):
'''Draw title text below the camera image'''
self.title.text = text
def draw_image_line(self, width, line, totlines, buff):
'''Display image pixel by pixel, line by line'''
self.size = (width, totlines)
i =0
for i in range(width):
try: self.imagebuffer.append(self.pal[buff[i]])
except: pass
if line == totlines:
bufferv = self.imagebuffer.tostring()
img = Image.frombytes("RGBX", (width, totlines), bufferv) # Pillow
imgResize = img.resize((width*self.img_scaling_factor, totlines*self.img_scaling_factor))
imgResizeVisual = visual.ImageStim(self.display, image=imgResize, units='pix')
imgResizeVisual.draw()
self.draw_cross_hair()
self.display.flip()
self.imagebuffer = array.array(self.imgBuffInitType)
def set_image_palette(self, r,g,b):
'''Given a set of RGB colors, create a list of 24bit numbers representing the pallet.
I.e., RGB of (1,64,127) would be saved as 82047, or the number 00000001 01000000 011111111'''
self.imagebuffer = array.array(self.imgBuffInitType)
self.resizeImagebuffer = array.array(self.imgBuffInitType)
#self.clear_cal_display()
sz = len(r)
i =0
self.pal = []
while i < sz:
rf = int(b[i])
gf = int(g[i])
bf = int(r[i])
self.pal.append((rf<<16) | (gf<<8) | (bf))
i = i+1

470
SaccadePursuit.py

@ -0,0 +1,470 @@
"""An experiment to cause eye saccades (movements) or smooth pursuits
Author - Michael Tan (mtan30@uic.edu)
Adapted from: https://github.com/colinquirk/PsychopyChangeDetection
Note: this code relies on the Colin Quirk templateexperiments module. You can get it from
https://github.com/colinquirk/templateexperiments and either put it in the same folder as this
code or give the path to psychopy in the preferences.
"""
from __future__ import division
from __future__ import print_function
import os
import sys
import errno
import math
import warnings
import json
import random
import numpy as np
import psychopy.core
import psychopy.event
import psychopy.visual
import psychopy.hardware.joystick
import psychopy # noqa:F401
import eyelinker
import template
distance_to_monitor = 90
# Things you probably don't need to change, but can if you want to
exp_name = 'SaccadePursuit'
data_fields = [
'Subject',
'Condition',
'Block',
'Trial',
'Timestamp',
'TrialType',
'Duration',
'Frequency',
'Locations',
]
gender_options = [
'Male',
'Female',
'Other/Choose Not To Respond',
]
# Add additional questions here
questionaire_dict = {
'Age': 0,
'Gender': gender_options,
}
def warning_on_one_line(message, category, filename, lineno, file=None, line=None):
return ' %s:%s: %s:%s' % (filename, lineno, category.__name__, message)
# This is the logic that runs the experiment
# Change anything below this comment at your own risk
class SPtask(template.BaseExperiment):
"""The class that runs the Saccade Pursuit experiment.
Parameters:
Additional keyword arguments are sent to template.BaseExperiment().
Methods:
"""
def __init__(self, pursuit_time,
stim_color, pursuit_frequencies,
saccade_distance, saccade_time,
stimulus_size, saccade_fixation_color,
fixation_size, pursuit_distance,
necker_time, necker_color,
necker_scale, necker_bg_color,
necker_file, rivalry_file1, rivalry_file2,
fixation_trial_time, rivalry_time,
rivalry_scale,rivalry_border_color,
rivalry_border_width, rivalry_distance,
questionaire_dict=questionaire_dict, **kwargs):
# Pursuit
self.pursuit_time = pursuit_time
self.pursuit_distance = pursuit_distance
self.pursuit_frequencies = pursuit_frequencies
# Saccade
self.stim_color = stim_color
self.saccade_time = saccade_time
self.stimulus_size = stimulus_size
self.saccade_distance = saccade_distance
self.saccade_fixation_color = saccade_fixation_color
# Fixation
self.fixation_size = fixation_size
self.fixation_trial_time = fixation_trial_time
# Necker
self.necker_time = necker_time
self.necker_color = necker_color
self.necker_scale = necker_scale
self.necker_bg_color = necker_bg_color
self.necker_file = necker_file
# Rivalry
self.rivalry_time = rivalry_time
self.rivalry_scale = rivalry_scale
self.rivalry_file1 = rivalry_file1
self.rivalry_file2 = rivalry_file2
self.rivalry_border_color = rivalry_border_color
self.rivalry_border_width = rivalry_border_width
self.rivalry_distance = rivalry_distance
self.questionaire_dict = questionaire_dict
super(SPtask, self).__init__(**kwargs)
def chdir(self):
"""Changes the directory to where the data will be saved.
"""
try:
os.makedirs(self.data_directory)
except OSError as e:
if e.errno != errno.EEXIST:
raise
os.chdir(self.data_directory)
def make_block(self, condition, numTrials):
"""Makes a block of trials.
Returns a shuffled list of trials created by self.make_trial.
"""
trial_list = []
saccade_locations = list(range(-self.saccade_distance,0,1))
for x in range(self.saccade_distance):
saccade_locations.append(x+1)
random.shuffle(saccade_locations)
counter = 0
for num_trials in range(numTrials):
if condition == 'Pursuit':
trial = self.make_trial(self.pursuit_time, self.pursuit_frequencies, condition)
trial_list.append(trial)
elif condition == 'Necker':
trial = self.make_trial(self.necker_time, 1, condition)
trial_list.append(trial)
elif condition == 'Fixation':
trial = self.make_trial(self.fixation_trial_time,1,condition)
trial_list.append(trial)
elif condition == 'Rivalry':
trial = self.make_trial(self.rivalry_time,1,condition)
trial_list.append(trial)
else:
for loc in saccade_locations:
trial = self.make_trial(round(random.uniform(1,self.saccade_time),3), loc, condition)
trial_list.append(trial)
counter += 1
random.shuffle(trial_list)
return trial_list
def make_trial(self, trial_time, pursuit_frequency, trial_type):
"""Creates a single trial dict. A helper function for self.make_block.
Returns the trial dict.
Parameters:
set_size -- The number of stimuli for this trial.
trial_type -- Whether this trial is same or different.
"""
locs = [pursuit_frequency,0]
trial = {
'locations': locs,
'trial_time': trial_time,
'pursuit_frequency': pursuit_frequency,
'trial_type': trial_type
}
return trial
def display_break(self):
"""Displays a break screen in between blocks.
"""
break_text = 'Please take a short break. Press space to continue.'
self.display_text_screen(text=break_text, bg_color=[0, 0, 0])
def display_fixation(self, wait_time, necker_bg_color=[-1,-1,-1]):
"""Displays a fixation cross. A helper function for self.run_trial.
Parameters:
wait_time -- The amount of time the fixation should be displayed for.
"""
self.experiment_window.color = necker_bg_color
stim = psychopy.visual.TextStim(
self.experiment_window, text='+', color=self.stim_color, height=self.fixation_size, units='deg')
for frameN in range(int(round(wait_time*60))):
stim.draw()
self.experiment_window.flip()
def display_saccade(self, coordinates, stim_time):
"""Displays the stimuli. A helper function for self.run_saccade_trial.
Parameters:
coordinates -- A list of coordinates (list of x and y value) describing where the stimuli
should be displayed.
colors -- A list of colors describing what should be drawn at each coordinate.
"""
color = self.stim_color
stim = psychopy.visual.Circle(
self.experiment_window, radius=self.stimulus_size/2,
pos=coordinates, fillColor=color,
lineColor=color, units='deg')
fixation = psychopy.visual.TextStim(
self.experiment_window, text='+', color=self.saccade_fixation_color,
height=self.fixation_size, units='deg')
stimList = []
temp = list(range(-self.saccade_distance,self.saccade_distance,1))
for circleN in temp:
if circleN < 0:
tempCoord = circleN
else:
tempCoord = circleN+1
tempStim = psychopy.visual.Circle(
self.experiment_window, radius=self.stimulus_size/2,
pos=[tempCoord,0], fillColor=self.saccade_fixation_color,
lineColor=self.saccade_fixation_color, units='deg')
stimList.append(tempStim)
for frameN in range(int(round(stim_time*60))):
for s in stimList:
s.draw()
fixation.draw()
stim.draw()
self.experiment_window.flip()
def display_saccade_fixation(self, stim_time):
"""Displays the stimuli. A helper function for self.run_saccade_trial.
Parameters:
coordinates -- A list of coordinates (list of x and y value) describing where the stimuli
should be displayed.
colors -- A list of colors describing what should be drawn at each coordinate.
"""
fixation = psychopy.visual.TextStim(
self.experiment_window, text='+', color=self.stim_color,
height=self.fixation_size, units='deg')
stimList = []
temp = list(range(-self.saccade_distance,self.saccade_distance,1))
for circleN in temp:
if circleN < 0:
tempCoord = circleN
else:
tempCoord = circleN+1
tempStim = psychopy.visual.Circle(
self.experiment_window, radius=self.stimulus_size/2,
pos=[tempCoord,0], fillColor=self.saccade_fixation_color,
lineColor=self.saccade_fixation_color, units='deg')
stimList.append(tempStim)
for frameN in range(int(round(stim_time*60))):
for s in stimList:
s.draw()
fixation.draw()
self.experiment_window.flip()
def display_pursuit(self, stim_frequency, stim_time):
"""Displays a pursuit stimululus. A helper function for self.run_pursuit_trial.
Parameters:
coordinates -- A list of coordinates (list of x and y value) describing where the start
point of the stimulus.
color -- A color describing what should be drawn.
"""
color = self.stim_color
Xposition = [0]
num_frames_per_second = 60
counter = 0
for freq in stim_frequency:
stim_frames = int(round(stim_time[counter]*num_frames_per_second))
for time in range(stim_frames):
Xposition.append(math.sin(freq*math.radians((time+1)*360/num_frames_per_second))*self.pursuit_distance)
counter += 1
stim = psychopy.visual.Circle(
self.experiment_window, radius=self.stimulus_size/2,
pos=(0,0), fillColor=color,
lineColor=color, units='deg')
# Draw circle for 0.5s prior to moving
for frameN in range(30):
stim.draw()
self.experiment_window.flip()
for Xpos in Xposition:
stim.pos = (Xpos,0)
stim.draw()
self.experiment_window.flip()
# Draw circle for 0.5s after moving
for frameN in range(30):
stim.draw()
self.experiment_window.flip()
def display_necker(self, stim_time, tracker):
self.experiment_window.color = self.necker_bg_color
stim = psychopy.visual.ImageStim(
self.experiment_window,
image=self.necker_file)
stim.size *= self.necker_scale
stim.setColor(self.necker_color, 'rgb255')
responses = []
for frameN in range(stim_time*60):
response = psychopy.event.getKeys()
if response:
if response[0]=='1' or response[0]=='3':
direction = 'Left'
elif response[0]=='2' or response[0]=='4':
direction = 'Right'
else:
direction = 'Center'
responses.append((response[0],direction,psychopy.core.getAbsTime()))
if tracker:
#print(response[0])
tracker.send_message(['Direction: %s' % direction])
stim.draw()
self.experiment_window.flip()
#print(responses)
return responses
def display_rivalry(self, stim_time, tracker):
color = self.rivalry_border_color
lineSize = self.rivalry_border_width
imDist = self.rivalry_distance
stim1 = psychopy.visual.ImageStim(
self.experiment_window,
image=self.rivalry_file1, pos=(-imDist,0))
stim1.size *= self.rivalry_scale
stim2 = psychopy.visual.ImageStim(
self.experiment_window,
image=self.rivalry_file2, pos=(imDist,0))
stim2.size *= self.rivalry_scale
borderScale = stim1.size[0]*0.4
borderWidth1 = stim1.size[0]+borderScale
borderHeight1 = stim1.size[1]+borderScale
borderWidth2 = stim2.size[0]+borderScale
borderHeight2 = stim2.size[1]+borderScale
stimBorder1 = psychopy.visual.Rect(
self.experiment_window, width=borderWidth1,
height=borderHeight1,
pos=stim1.pos, lineWidth=lineSize,
lineColor=color, units='deg')
stimBorder2 = psychopy.visual.Rect(
self.experiment_window, width=borderWidth2,
height=borderHeight2,
pos=stim2.pos, lineWidth=lineSize,
lineColor=color, units='deg')
stimFix1_top = psychopy.visual.Line(
self.experiment_window, start=(stim1.pos[0],stim1.pos[1]+borderHeight1/2),
end=(stim1.pos[0],stim1.pos[1]+stim1.size[1]/1.9), units='deg',
lineColor=color, lineWidth=lineSize)
stimFix1_left = psychopy.visual.Line(
self.experiment_window, start=(stim1.pos[0]-borderWidth1/2,stim1.pos[1]),
end=(stim1.pos[0]-stim1.size[0]/1.9,stim1.pos[1]), units='deg',
lineColor=color, lineWidth=lineSize)
stimFix2_bottom = psychopy.visual.Line(
self.experiment_window, start=(stim2.pos[0],stim2.pos[1]-borderHeight2/2),
end=(stim2.pos[0],stim2.pos[1]-stim2.size[1]/1.9), units='deg',
lineColor=color, lineWidth=lineSize)
stimFix2_right = psychopy.visual.Line(
self.experiment_window, start=(stim2.pos[0]+borderWidth2/2,stim2.pos[1]),
end=(stim2.pos[0]+stim2.size[0]/1.9,stim2.pos[1]), units='deg',
lineColor=color, lineWidth=lineSize)
responses = []
for frameN in range(stim_time*60):
response = psychopy.event.getKeys()
if response:
if response[0]=='1' or response[0]=='3':
direction = 'Left'
elif response[0]=='2' or response[0]=='4':
direction = 'Right'
else:
direction = 'Center'
responses.append((response[0],direction,psychopy.core.getAbsTime()))
if tracker:
tracker.send_message(['Direction: %s' % direction])
stim1.draw()
stim2.draw()
stimBorder1.draw()
stimBorder2.draw()
stimFix1_top.draw()
stimFix1_left.draw()
stimFix2_bottom.draw()
stimFix2_right.draw()
self.experiment_window.flip()
return responses
def send_data(self, data):
"""Updates the experiment data with the information from the last trial.
This function is seperated from run_trial to allow additional information to be added
afterwards.
Parameters:
data -- A dict where keys exist in data_fields and values are to be saved.
"""
self.update_experiment_data([data])
def run_trial(self, trial, block_num, trial_num, tracker=[]):
freq = [0]
if trial['trial_type']=='Necker':
freq = self.display_necker(trial['trial_time'], tracker)
elif trial['trial_type']=='Pursuit':
self.display_pursuit(trial['pursuit_frequency'],trial['trial_time'])
freq = trial['pursuit_frequency']
elif trial['trial_type']=='Fixation':
self.display_fixation(trial['trial_time'])
elif trial['trial_type']=='Rivalry':
freq = self.display_rivalry(trial['trial_time'], tracker)
else:
self.display_saccade(trial['locations'], trial['trial_time'])
data = {
'Subject': self.experiment_info['Subject Identifier'],
'Block': block_num,
'Trial': trial_num,
'Timestamp': psychopy.core.getAbsTime(),
'TrialType': trial['trial_type'],
'Duration': trial['trial_time'],
'Frequency': freq,
'Locations': trial['locations']
}
return data
# If you call this script directly, directs you to correct file
if __name__ == '__main__':
warnings.showwarning(
'Please run SaccadePursuitEyeTracking.py',
Warning,'SaccadePursuit.py',414)
warnings.formatwarning = warning_on_one_line

367
SaccadePursuitEyeTracking.py

@ -0,0 +1,367 @@
"""A wrapper for SaccadePursuit to track eye movements in addition
Author - Michael Tan (mtan30@uic.edu)
Adapted from: https://github.com/colinquirk/ChangeDetectionEyeTracking
Note: this code relies on the Colin Quirk templateexperiments module. You can get it from
https://github.com/colinquirk/templateexperiments and either put it in the same folder as this
code or give the path to psychopy in the preferences.
"""
from __future__ import division
from __future__ import print_function
import os
import random
import sys
import traceback
import subprocess
# Necesssary to access psychopy paths
import psychopy # noqa:F401
import eyelinker
import SaccadePursuit
# Experimental Parameters
monitor_name = 'testMonitor'
monitor_width = 41
distance_to_monitor = 70
monitor_px = [1440,900]
window_screen = 1
disableTracker = False # For Debugging
conditions = ['Fixation', 'Pursuit', 'Saccade', 'AntiSaccade', 'Necker', 'Rivalry']
#conditions = ['Saccade']
isi_time = 1 # Interstimulus Interval
data_directory = os.path.join(
os.path.expanduser('~'), 'Desktop', 'ExperimentalData', 'SaccadePursuitEyeTracking')
image_directory = os.path.join(os.getcwd(),'Images')
# Saccade / Antisaccade Parameters
number_of_saccade_trials = 1
number_of_saccade_blocks = 1
saccade_distance = 15 #15
saccade_time = 3 #3
stimulus_size = 0.3
stim_color = [1,-1,-1]
saccade_fixation_color = [1,1,1]
# Pursuit Parameters
number_of_pursuit_trials = 1
number_of_pursuit_blocks = 1
pursuit_distance = 15
pursuit_frequencies = [0.1,0.2,0.4]
pursuit_time = [40,20,15]
# Necker Cube Parameters
number_of_necker_trials = 1
number_of_necker_blocks = 1
necker_time = 90
necker_color = [255,255,255]
necker_bg_color = [-0.5,-0.5,-0.5]
necker_scale = 0.5
necker_file = os.path.join(image_directory,'Necker1.tif')
# Fixation Parameters
number_of_fixation_trials = 1
number_of_fixation_blocks = 1
fixation_size = stimulus_size*2
fixation_trial_time = 15
# Binocular Rivalry Parameters
number_of_rivalry_trials = 1
number_of_rivalry_blocks = 1
rivalry_time = 90
rivalry_scale = 2.5
rivalry_file1 = os.path.join(image_directory,'house4n_11-160.tif')
rivalry_file2 = os.path.join(image_directory,'face2nS_11-160.tif')
rivalry_border_color = [1,1,1]
rivalry_border_width = 5
rivalry_distance = 4
data_fields = [
'Subject',
'Condition',
'Block',
'Trial',
'Timestamp',
'TrialType',
'Duration',
'Frequency',
'Locations',
]
instruct_text = [
('Welcome to the experiment. Press space to continue.'),
('In this experiment you will be following targets.\n\n'
'Each trial will start with a fixation cross. '
'Focus on the fixation cross until a stimulus appears.\n\n'
'In the first three phases, you will follow the stimuli. '
'In the fourth phase, you will focus on the '
'opposite side of center cross at an '
'equal distance as the stimulus. In the fifth '
'phase, you will see a cube, and are to respond as '
'indicated.'
'\n\n'
'Do not move your head during the trials of this '
'experiment. Move only your eyes to follow the targets.'
'You will get breaks in between blocks.\n\n'
'Press space to continue.'),
]
saccade_instruct_text = (
'For these trials, focus on the fixation cross. When the target '
'appears, move your gaze to the target. Move your gaze back to '
'the fixation point after the target disappears.\n\n'
'Try not to blink after the target appears.\n\n'
'Press space to continue.'
)
antisaccade_instruct_text = (
'For these trials, focus on the fixation cross. When the target '
'appears, move your gaze to the OPPOSITE direction of the target '
'at approximately the same distance from the fixation cross as '
'the target. Move your gaze back to the fixation point after '
'the target disappears.\n\n'
'Try not to blink after the target appears.\n\n'
'Press space to continue.'
)
pursuit_instruct_text = (
'For these trials, when the circle target appears, follow the '
'target with your eyes.\n\n'
'Try not to blink while the circle is moving.\n\n'
'Press space to continue.'
)
fixation_instruct_text = (
'For these trials, when the cross appears, fixate on it.\n\n'
'Try not to move your eyes from the cross while it '
'remains visible.\n\n'
'Press space to continue.'
)
necker_instruct_text = (
'For these trials, focus on the square. When your '
'perception is that the square is pointing down and '
'to the left, press the left button. As soon as it '
'switches to up and to the right, press the right '
'button.\n\n'
'Respond at any time during the stimulus.\n\n'
'Press space to continue.'
)
rivalry_instruct_text = (
'For these trials, the experimenter will move a '
'system in front of your field of view. Look through '
'the mirrors to see a different image in each eye.\n\n'
'If you perceive a face, press the right button. If '
'you perceive a house, press the left button. If '
'you perceive a combination of the face and house, '
'press the middle button.\n\n'
'Respond at any time during the stimuli.\n\n'
'Press space to continue.'
)
class EyeTrackingSaccadePursuit(SaccadePursuit.SPtask):
def __init__(self, **kwargs):
self.quit = False # Needed because eyetracker must shut down
self.tracker = None
self.disable_tracker = disableTracker
self.window_screen = window_screen
self.number_of_saccade_trials = number_of_saccade_trials
self.number_of_saccade_blocks = number_of_saccade_blocks
self.number_of_pursuit_trials = number_of_pursuit_trials
self.number_of_pursuit_blocks = number_of_pursuit_blocks
self.number_of_necker_trials = number_of_necker_trials
self.number_of_necker_blocks = number_of_necker_blocks
self.number_of_fixation_trials = number_of_fixation_trials
self.number_of_fixation_blocks = number_of_fixation_blocks
self.number_of_rivalry_trials = number_of_rivalry_trials
self.number_of_rivalry_blocks = number_of_rivalry_blocks
super(EyeTrackingSaccadePursuit, self).__init__(**kwargs)
def quit_experiment(self):
self.quit = True
if self.experiment_window:
self.display_text_screen('Quiting...', wait_for_input=False)
if self.tracker:
fName = os.path.join(self.data_directory,
'ETSP' + self.experiment_info['Subject Identifier'] + '.edf')
self.tracker.set_offline_mode()
self.tracker.close_edf()
self.tracker.transfer_edf()
self.tracker.close_connection()
subprocess.call(['edf2asc',fName])
super(EyeTrackingSaccadePursuit, self).quit_experiment()
def run(self):
self.chdir()
print('Note: EDF file will be overwritten if identical subject identifiers are used!')
ok = self.get_experiment_info_from_dialog(self.questionaire_dict)
if not ok:
print('Experiment has been terminated.')
sys.exit(1)
self.save_experiment_info()
self.open_csv_data_file()
self.open_window(screen=self.window_screen)
self.display_text_screen('Loading...', wait_for_input=False)
if not self.disableTracker:
self.tracker = eyelinker.EyeLinker(
self.experiment_window,
'ETSP' + self.experiment_info['Subject Identifier'] + '.edf',
'BOTH'
)
self.tracker.initialize_graphics()
self.tracker.open_edf()
self.tracker.initialize_tracker()
self.tracker.send_calibration_settings()
for instruction in self.instruct_text:
self.display_text_screen(text=instruction)
if not self.disableTracker:
self.tracker.display_eyetracking_instructions()
self.tracker.setup_tracker()
#random.shuffle(self.conditions)
condition_counter = 0
for condition in self.conditions:
condition_counter += 1
numBlocks = 1
numTrials = 1
if condition == 'Saccade':
self.display_text_screen(text=saccade_instruct_text)
numBlocks = self.number_of_saccade_blocks
numTrials = self.number_of_saccade_trials
elif condition=='AntiSaccade':
self.display_text_screen(text=antisaccade_instruct_text)
numBlocks = self.number_of_saccade_blocks
numTrials = self.number_of_saccade_trials
elif condition=='Fixation':
self.display_text_screen(text=fixation_instruct_text)
numBlocks = self.number_of_fixation_blocks
numTrials = self.number_of_fixation_trials
elif condition=='Pursuit':
self.display_text_screen(text=pursuit_instruct_text)
numBlocks = self.number_of_pursuit_blocks
numTrials = self.number_of_pursuit_trials
elif condition=='Necker':
self.display_text_screen(text=necker_instruct_text)
numBlocks = self.number_of_necker_blocks
numTrials = self.number_of_necker_trials
elif condition=='Rivalry':
self.display_text_screen(text=rivalry_instruct_text)
numBlocks = self.number_of_rivalry_blocks
numTrials = self.number_of_rivalry_trials
else:
continue
for block_num in range(numBlocks):
block = self.make_block(condition, numTrials)
self.display_text_screen(text='Get ready...', wait_for_input=False)
psychopy.core.wait(2)
if condition == 'Saccade' or condition == 'AntiSaccade':
self.display_saccade_fixation(1)
else:
self.display_fixation(0.5)
for trial_num, trial in enumerate(block):
print(
"Condition: ",condition,"(",condition_counter,"/",len(self.conditions),")",
"Block ",block_num+1,"/",numBlocks,
" Trial ",trial_num+1,"/",len(block)
)
if not self.disableTracker:
self.tracker.send_message('CONDITION %s' % condition)
self.tracker.send_message('BLOCK %d' % block_num)
self.tracker.send_message('TRIAL %d' % trial_num)
status = '%s: Block %d, Trial %d' % (condition, block_num, trial_num)
self.tracker.send_status(status)
self.tracker.start_recording()
data = self.run_trial(trial, block_num, trial_num, self.tracker)
self.tracker.stop_recording()
else:
data = self.run_trial(trial, block_num, trial_num)
data.update({'Condition': condition})
self.send_data(data)
if not condition == 'Saccade' and not condition=='AntiSaccade':
self.display_fixation(self.isi_time)
if condition == 'Saccade' or condition == 'AntiSaccade':
self.display_saccade_fixation(1)
else:
self.display_fixation(0.5)
self.save_data_to_csv()
if block_num + 1 != numBlocks:
self.display_break()
if condition == 'Saccade':
self.display_text_screen(text='Remember:\n\n' + saccade_instruct_text)
elif condition=='AntiSaccade':
self.display_text_screen(text='Remember:\n\n' + antisaccade_instruct_text)
else:
self.display_text_screen(text='Remember:\n\n' + pursuit_instruct_text)
self.display_text_screen(
'The experiment is now over.',
bg_color=[50, 150, 50], text_color=[255, 255, 255],
wait_for_input=False)
psychopy.core.wait(5)
self.quit_experiment()
experiment = EyeTrackingSaccadePursuit(
experiment_name='ETSP',
data_fields=data_fields,
data_directory=data_directory,
instruct_text=instruct_text,
monitor_name=monitor_name,
monitor_width=monitor_width,
monitor_px=monitor_px,
monitor_distance=distance_to_monitor,
conditions=conditions,
pursuit_time=pursuit_time, stim_color=stim_color,
pursuit_frequencies=pursuit_frequencies,
saccade_distance=saccade_distance,
saccade_time=saccade_time,
saccade_fixation_color=saccade_fixation_color,
isi_time=isi_time, stimulus_size=stimulus_size,
fixation_size=fixation_size,
pursuit_distance=pursuit_distance,
necker_time=necker_time, necker_color=necker_color,
necker_scale=necker_scale,
necker_bg_color=necker_bg_color,
fixation_trial_time=fixation_trial_time,
rivalry_time=rivalry_time,
rivalry_scale=rivalry_scale,
necker_file=necker_file,
rivalry_file1=rivalry_file1,
rivalry_file2=rivalry_file2,
rivalry_border_color=rivalry_border_color,
disableTracker=disableTracker,
rivalry_border_width=rivalry_border_width,
rivalry_distance=rivalry_distance,
)
if __name__ == '__main__':
try:
experiment.run()
except Exception:
print(traceback.format_exc())
if not experiment.quit:
experiment.quit_experiment()

272
eyelinker.py

@ -0,0 +1,272 @@
from __future__ import print_function
from __future__ import division
import os
import sys
import time
import pylink as pl
#from PsychoPyCustomDisplay import PsychoPyCustomDisplay
from EyeLinkCoreGraphicsPsychoPy import EyeLinkCoreGraphicsPsychoPy
import psychopy.event
import psychopy.visual
class EyeLinker(object):
def __init__(self, window, filename, eye):
if len(filename) > 12:
raise ValueError(
'EDF filename must be at most 12 characters long including the extension.')
if filename[-4:] != '.edf':
raise ValueError(
'Please include the .edf extension in the filename.')
if eye not in ('LEFT', 'RIGHT', 'BOTH'):
raise ValueError('eye must be set to LEFT, RIGHT, or BOTH.')
self.window = window
self.edf_filename = filename
self.edf_open = False
self.eye = eye
self.resolution = tuple(window.size)
self.tracker = pl.EyeLink()
#self.genv = PsychoPyCustomDisplay(self.window, self.tracker)
self.genv = EyeLinkCoreGraphicsPsychoPy(self.tracker, self.window)
if all(i >= 0.5 for i in self.window.color):
self.text_color = (-1, -1, -1)
else:
self.text_color = (1, 1, 1)
def initialize_graphics(self):
self.set_offline_mode()
pl.openGraphicsEx(self.genv)
def initialize_tracker(self):
if not self.edf_open:
raise RuntimeError('EDF file must be open before tracker can be initialized.')
pl.flushGetkeyQueue()
self.set_offline_mode()
self.send_command("screen_pixel_coords = 0 0 %d %d" % self.resolution)
self.send_message("DISPLAY_COORDS 0 0 %d %d" % self.resolution)
#self.tracker.setFileEventFilter(
# "LEFT,RIGHT,FIXATION,SACCADE,BLINK,MESSAGE,BUTTON,INPUT")
#self.tracker.setFileSampleFilter(
# "LEFT,RIGHT,GAZE,AREA,GAZERES,STATUS")
#self.tracker.setLinkEventFilter(
# "LEFT,RIGHT,FIXATION,FIXUPDATE,SACCADE,BLINK,BUTTON,INPUT")
#self.tracker.setLinkSampleFilter(
# "LEFT,RIGHT,GAZE,GAZERES,AREA,STATUS")
self.tracker.sendCommand("file_event_filter = LEFT,RIGHT,FIXATION,SACCADE,BLINK,MESSAGE,BUTTON,INPUT")
self.tracker.sendCommand("link_event_filter = LEFT,RIGHT,FIXATION,FIXUPDATE,SACCADE,BLINK,BUTTON,INPUT")
self.tracker.sendCommand("file_sample_data = LEFT,RIGHT,GAZE,GAZERES,PUPIL,HREF,AREA,STATUS,HTARGET,INPUT")
self.tracker.sendCommand("link_sample_data = LEFT,RIGHT,GAZE,GAZERES,PUPIL,HREF,AREA,STATUS,HTARGET,INPUT")
def send_calibration_settings(self, settings=None):
defaults = {
'automatic_calibration_pacing': 1000,
'background_color': (0, 0, 0),
'calibration_area_proportion': (0.5, 0.5),
'calibration_type': 'HV5',
'elcl_configuration': 'BTABLER',
'enable_automatic_calibration': 'YES',
'error_sound': '',
'foreground_color': (255, 255, 255),
'good_sound': '',
'preamble_text': None,
'pupil_size_diameter': 'NO',
'saccade_acceleration_threshold': 9500,
'saccade_motion_threshold': 0.15,
'saccade_pursuit_fixup': 60,
'saccade_velocity_threshold': 30,
'sample_rate': 1000,
'target_sound': '',
'validation_area_proportion': (0.5, 0.5),
}
if settings is None:
settings = {}
settings.update(defaults)
self.send_command('elcl_select_configuration = %s' % settings['elcl_configuration'])
pl.setCalibrationColors(settings['foreground_color'], settings['background_color'])
pl.setCalibrationSounds(
settings['target_sound'], settings['good_sound'], settings['error_sound'])
if self.eye in ('LEFT', 'RIGHT'):
self.send_command('active_eye = %s' % self.eye)
self.send_command(
'automatic_calibration_pacing = %i' % settings['automatic_calibration_pacing'])
if self.eye == 'BOTH':
self.send_command('binocular_enabled = YES')
else:
self.send_command('binocular_enabled = NO')
#self.send_command(
# 'calibration_area_proportion %f %f' % settings['calibration_area_proportion'])
self.send_command('calibration_type = %s' % settings['calibration_type'])
self.send_command(
'enable_automatic_calibration = %s' % settings['enable_automatic_calibration'])
if settings['preamble_text'] is not None:
self.send_command('add_file_preamble_text %s' % '"' + settings['preamble_text'] + '"')
self.send_command('pupil_size_diameter = %s' % settings['pupil_size_diameter'])
#self.send_command(
# 'saccade_acceleration_threshold = %i' % settings['saccade_acceleration_threshold'])
#self.send_command('saccade_motion_threshold = %i' % settings['saccade_motion_threshold'])
#self.send_command('saccade_pursuit_fixup = %i' % settings['saccade_pursuit_fixup'])
#self.send_command(
# 'saccade_velocity_threshold = %i' % settings['saccade_velocity_threshold'])
self.send_command('sample_rate = %i' % settings['sample_rate'])
#self.send_command(
# 'validation_area_proportion %f %f' % settings['validation_area_proportion'])
def open_edf(self):
self.tracker.openDataFile(self.edf_filename)
self.edf_open = True
def close_edf(self):
self.tracker.closeDataFile()
self.edf_open = False
def transfer_edf(self, newFilename=None):
if not newFilename:
newFilename = self.edf_filename
# Prevents timeouts due to excessive printing
sys.stdout = open(os.devnull, "w")
self.tracker.receiveDataFile(self.edf_filename, newFilename)
sys.stdout = sys.__stdout__
print(newFilename + ' has been transferred successfully.')
def setup_tracker(self):
self.window.flip()
self.tracker.doTrackerSetup()
def display_eyetracking_instructions(self):
self.window.flip()
psychopy.visual.Circle(
self.window, units='pix', radius=18, lineColor='black', fillColor='white'
).draw()
psychopy.visual.Circle(
self.window, units='pix', radius=6, lineColor='black', fillColor='black'
).draw()
psychopy.visual.TextStim(
self.window, text='Sometimes a target that looks like this will appear.',
color=self.text_color, units='norm', pos=(0, 0.22), height=0.05
).draw()
psychopy.visual.TextStim(
self.window, color=self.text_color, units='norm', pos=(0, -0.18), height=0.05,
text='We use it to calibrate the eye tracker. Stare at it whenever you see it.'
).draw()
psychopy.visual.TextStim(
self.window, color=self.text_color, units='norm', pos=(0, -0.28), height=0.05,
text='Press any key to continue.'
).draw()
self.window.flip()
psychopy.event.waitKeys()
self.window.flip()
def calibrate(self, text=None):
self.window.flip()
if text is None:
text = (
'Experimenter:\n'
'If you would like to calibrate, press space.\n'
'To skip calibration, press the escape key.'
)
psychopy.visual.TextStim(
self.window, text=text, pos=(0, 0), height=0.05, units='norm', color=self.text_color
).draw()
self.window.flip()
keys = psychopy.event.waitKeys(keyList=['escape', 'space'])
self.window.flip()
if 'space' in keys:
self.tracker.doTrackerSetup()
def drift_correct(self, position=None, setup=1):
if position is None:
position = tuple([int(round(i/2)) for i in self.resolution])
try:
self.tracker.doDriftCorrect(position[0], position[1], 1, setup)
self.tracker.applyDriftCorrect()
except RuntimeError as e:
print(e.message)
def record(self, trial_func):
def wrapped_func():
self.start_recording()
trial_func()
self.stop_recording()
return wrapped_func
def start_recording(self):
self.tracker.startRecording(1, 1, 1, 1)
time.sleep(.1) # required
def stop_recording(self):
time.sleep(.1) # required
self.tracker.stopRecording()
@property
def gaze_data(self):
sample = self.tracker.getNewestSample()
if self.eye == 'LEFT':
return sample.getLeftEye().getGaze()
elif self.eye == 'RIGHT':
return sample.getRightEye().getGaze()
else:
return (sample.getLeftEye().getGaze(), sample.getRightEye().getGaze())
@property
def pupil_size(self):
sample = self.tracker.getNewestSample()
if self.eye == 'LEFT':
return sample.getLeftEye().getPupilSize()
elif self.eye == 'RIGHT':
return sample.getRightEye().getPupilSize()
else:
return (sample.getLeftEye().getPupilSize(), sample.getRightEye().getPupilSize())
def set_offline_mode(self):
self.tracker.setOfflineMode()
def send_command(self, cmd):
self.tracker.sendCommand(cmd)
def send_message(self, msg):
self.tracker.sendMessage(msg)
def send_status(self, status):
if len(status) >= 80:
print('Warning: Status should be less than 80 characters.')
self.send_command("record_status_message '%s'" % status)
def close_connection(self):
self.tracker.close()
pl.closeGraphics()

362
template.py

@ -0,0 +1,362 @@
"""Basic experiment class that is designed to be extended.
Author - Michael Tan (mtan30@uic.edu)
Adapted from: https://github.com/colinquirk/templateexperiments
This class provides basic utility functions that are needed by all
experiments. Specific experiment classes should inherit and extend/overwrite as needed.
Functions:
convert_color_value -- Converts a list of 3 values from 0 to 255 to -1 to 1.
"""
from __future__ import division
from __future__ import print_function
import os
import pickle
import sys
import psychopy.monitors
import psychopy.visual
import psychopy.gui
import psychopy.core
import psychopy.event
# Convenience
def convert_color_value(color):
"""Converts a list of 3 values from 0 to 255 to -1 to 1.
Parameters:
color -- A list of 3 ints between 0 and 255 to be converted.
"""
return [round(((n/127.5)-1), 2) for n in color]
class BaseExperiment(object):
"""Basic experiment class providing functionality in all experiments
Parameters:
bg_color -- list of 3 values (0-255) defining the background color
data_fields -- list of strings defining data fields
experiment_name -- string defining the experiment title
monitor_distance -- int describing participant distance from monitor in cm
monitor_name -- name of the monitor to be used
monitor_px -- list containing monitor resolution (x,y)
monitor_width -- int describing length of display monitor in cm
Methods:
display_text_screen -- draws a string centered on the screen.
get_experiment_info_from_dialog -- gets subject info from a dialog box.
open_csv_data_file -- opens a csv data file and writes the header.
open_window -- open a psychopy window.
quit_experiment -- ends the experiment.
save_data_to_csv -- append new entries in experiment_data to csv data file.
save_experiment_info -- write the info from the dialog box to a text file.
save_experiment_pickle -- save a pickle so crashes can be recovered from.
update_experiment_data -- extends any new data to the experiment_data list.
"""
def __init__(self, experiment_name, data_fields, bg_color=[0, 0, 0],
monitor_name='testMonitor', monitor_width=59.5,
monitor_distance=30, monitor_px=[2560,1440], **kwargs):
"""Creates a new BaseExperiment object.
Parameters:
bg_color -- A list of 3 values between 0 and 255 defining the
background color.
data_fields -- list of strings containing the data fields to be stored
experiment_name -- A string for the experiment title that also defines
the filename the experiment info from the dialog box is saved to.
monitor_distance -- An int describing the distance the participant sits
from the monitor in cm (default 70).
monitor_name -- The name of the monitor to be used
Psychopy will search for the provided name to see if it was defined
in monitor center. If it is not defined, a temporary monitor will
be created.
monitor_px -- A list containing the resolution of the monitor (x,y).
monitor_width -- An int describing the length of the display monitor
in cm (default 53).
"""
self.experiment_name = experiment_name
self.data_fields = data_fields
self.bg_color = convert_color_value(bg_color)
self.monitor_name = monitor_name
self.monitor_width = monitor_width
self.monitor_distance = monitor_distance
self.monitor_px = monitor_px
self.experiment_data = []
self.experiment_data_filename = None
self.data_lines_written = 0
self.experiment_info = {}
self.experiment_window = None
self.overwrite_ok = None
self.experiment_monitor = psychopy.monitors.Monitor(
self.monitor_name, width=self.monitor_width,
distance=self.monitor_distance)
self.experiment_monitor.setSizePix(monitor_px)
vars(self).update(kwargs) # Add anything else you want
@staticmethod
def _confirm_overwrite():
"""Private, static method that shows a dialog asking if a file can be
overwritten.
Returns a bool describing if the file should be overwritten.
"""
overwrite_dlg = psychopy.gui.Dlg(
'Overwrite?', labelButtonOK='Overwrite',
labelButtonCancel='New File', screen=0)
overwrite_dlg.addText('File already exists. Overwrite?')
overwrite_dlg.show()
return overwrite_dlg.OK
def get_experiment_info_from_dialog(self, additional_fields_dict=None):
"""Gets subject info from dialog box.
Parameters:
additional_fields_dict -- An optional dictionary containing more
fields for the dialog box and output dictionary.
"""
self.experiment_info = {'Subject Identifier': 'XX00',
'Age': '0',
'Experimenter Initials': 'MT',
}
if additional_fields_dict is not None:
self.experiment_info.update(additional_fields_dict)
# Modifies experiment_info dict directly
exp_info = psychopy.gui.DlgFromDict(
self.experiment_info, title=self.experiment_name,
order=['Subject Identifier',
'Age',
'Experimenter Initials',
],
screen=0
)
return exp_info.OK
def save_experiment_info(self, filename=None):
"""Writes the info from the dialog box to a text file.
Parameters:
filename -- a string defining the filename with no extension
"""
if filename is None:
filename = (self.experiment_name + '_' +
self.experiment_info['Subject Identifier'].zfill(3) +
'_info')
elif filename[-4:] == '.txt':
filename = filename[:-4]
if os.path.isfile(filename + '.txt'):
if self.overwrite_ok is None:
self.overwrite_ok = self._confirm_overwrite()
if not self.overwrite_ok:
# If the file exists make a new filename
i = 1
new_filename = filename + '(' + str(i) + ')'
while os.path.isfile(new_filename + '.txt'):
i += 1
new_filename = filename + '(' + str(i) + ')'
filename = new_filename
filename = filename + '.txt'
with open(filename, 'w') as info_file:
for key, value in self.experiment_info.items():
info_file.write(key + ':' + str(value) + '\n')
info_file.write('\n')
def open_csv_data_file(self, data_filename=None):
"""Opens the csv file and writes the header.
Parameters:
data_filename -- name of the csv file with no extension
(defaults to experimentname_subjectnumber).
"""
if data_filename is None:
data_filename = (self.experiment_name + '_' +
self.experiment_info['Subject Identifier'].zfill(3))
elif data_filename[-4:] == '.csv':
data_filename = data_filename[:-4]
if os.path.isfile(data_filename + '.csv'):
if self.overwrite_ok is None:
self.overwrite_ok = self._confirm_overwrite()
if not self.overwrite_ok:
# If the file exists and we can't overwrite make a new filename
i = 1
new_filename = data_filename + '(' + str(i) + ')'
while os.path.isfile(new_filename + '.csv'):
i += 1
new_filename = data_filename + '(' + str(i) + ')'
data_filename = new_filename
self.experiment_data_filename = data_filename + '.csv'
# Write the header
with open(self.experiment_data_filename, 'w+') as data_file:
for field in self.data_fields:
data_file.write('"')
data_file.write(field)
data_file.write('"')
if field != self.data_fields[-1]:
data_file.write(',')
data_file.write('\n')
def update_experiment_data(self, new_data):
"""Extends any new data to the experiment_data list.
Parameters:
new_data -- A list of dictionaries that are extended to
experiment_data. Only keys that are included in data_fields should
be included, as only those will be written in save_data_to_csv()
"""
self.experiment_data.extend(new_data)
def save_data_to_csv(self):
"""Opens the data file and appends new entries in experiment_data.
Only appends lines (tracked by data_lines_written) that have not yet
been written to the csv.
Update the experiment data to be written with update_experiment_data.
"""
with open(self.experiment_data_filename, 'a') as data_file:
for trial in range(
self.data_lines_written, len(self.experiment_data)):
for field in self.data_fields:
data_file.write('"')
try:
data_file.write(
str(self.experiment_data[trial][field]))
except KeyError:
data_file.write('NA')
data_file.write('"')
if field != self.data_fields[-1]:
data_file.write(',')
data_file.write('\n')
self.data_lines_written = len(self.experiment_data)
def save_experiment_pickle(self, additional_fields_dict=None):
"""Saves the pickle containing the experiment data so that a crash can
be recovered from.
This method uses dict.update() so if any keys in the
additional_fields_dict are in the default dictionary the new values
will be used.
Parameters:
additional_fields_dict -- An optional dictionary that updates the
dictionary that is saved in the pickle file.
"""
pickle_dict = {
'experiment_name': self.experiment_name,
'data_fields': self.data_fields,
'bg_color': self.bg_color,
'monitor_name': self.monitor_name,
'monitor_width': self.monitor_width,
'monitor_distance': self.monitor_distance,
'experiment_data': self.experiment_data,
'experiment_data_filename': self.experiment_data_filename,
'data_lines_written': self.data_lines_written,
'experiment_info': self.experiment_info,
}
if additional_fields_dict is not None:
pickle_dict.update(additional_fields_dict)
pickle.dump(pickle_dict, open(
self.experiment_name + '_' +
self.experiment_info['Subject Number'].zfill(3) + '.pickle',
'wb+'))
def open_window(self, **kwargs):
"""Opens the psychopy window.
Additional keyword arguments are sent to psychopy.visual.Window().
"""
self.experiment_window = psychopy.visual.Window(self.monitor_px,
monitor=self.experiment_monitor, fullscr=True, color=self.bg_color,
colorSpace='rgb', units='deg', allowGUI=False, **kwargs)
def display_text_screen(
self, text='', text_color=[255, 255, 255], text_height=36,
bg_color=None, wait_for_input=True, **kwargs):
"""Takes a string as input and draws it centered on the screen.
Allows for simple writing of text to a screen with a background color
other than the normal one. Switches back to the default background
color after any keyboard input.
This works by drawing a rect on top of the background
that fills the whole screen with the selected color.
Parameters:
text -- A string containing the text to be displayed.
text_color -- A list of 3 values between 0 and 255
(default is [0, 0, 0]).
text_height --- An int that defines the height of the text in pix.
bg_color -- A list of 3 values between 0 and 255 (default is default
background color).
wait_for_input -- Bool that defines whether the screen will wait for
keyboard input before continuing. If waiting for keys, a .5 second
buffer is added to prevent accidental advancing.
Additional keyword arguments are sent to psychopy.visual.TextStim().
"""
if bg_color is None:
bg_color = self.bg_color
else:
bg_color = convert_color_value(bg_color)
backgroundRect = psychopy.visual.Rect(
self.experiment_window, fillColor=bg_color, units='norm', width=2,
height=2)
text_color = convert_color_value(text_color)
textObject = psychopy.visual.TextStim(
self.experiment_window, text=text, color=text_color, units='pix',
height=text_height, alignHoriz='center', alignVert='center',
wrapWidth=round(.8*self.experiment_window.size[0]), **kwargs)
backgroundRect.draw()
textObject.draw()
self.experiment_window.flip()
keys = None
if wait_for_input:
psychopy.core.wait(.2) # Prevents accidental key presses
keys = psychopy.event.waitKeys()
self.experiment_window.flip()
return keys
def quit_experiment(self):
"""Completes anything that must occur when the experiment ends."""
if self.experiment_window:
self.experiment_window.close()
print('The experiment has ended.')
sys.exit(0)
Loading…
Cancel
Save