From e87e4f79b8fdba8bd46ecc248f829b5860a16c6b Mon Sep 17 00:00:00 2001 From: Michael Tan Date: Fri, 18 Oct 2019 12:10:56 -0500 Subject: [PATCH] Initial Commit -MT --- EyeLinkCoreGraphicsPsychoPy.py | 366 +++++++++++++++++++++++++ SaccadePursuit.py | 470 +++++++++++++++++++++++++++++++++ SaccadePursuitEyeTracking.py | 367 +++++++++++++++++++++++++ eyelinker.py | 272 +++++++++++++++++++ template.py | 362 +++++++++++++++++++++++++ 5 files changed, 1837 insertions(+) create mode 100755 EyeLinkCoreGraphicsPsychoPy.py create mode 100755 SaccadePursuit.py create mode 100755 SaccadePursuitEyeTracking.py create mode 100755 eyelinker.py create mode 100755 template.py diff --git a/EyeLinkCoreGraphicsPsychoPy.py b/EyeLinkCoreGraphicsPsychoPy.py new file mode 100755 index 0000000..f6e7925 --- /dev/null +++ b/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 diff --git a/SaccadePursuit.py b/SaccadePursuit.py new file mode 100755 index 0000000..8e66c13 --- /dev/null +++ b/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 diff --git a/SaccadePursuitEyeTracking.py b/SaccadePursuitEyeTracking.py new file mode 100755 index 0000000..26dcfa1 --- /dev/null +++ b/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() diff --git a/eyelinker.py b/eyelinker.py new file mode 100755 index 0000000..ac29596 --- /dev/null +++ b/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() diff --git a/template.py b/template.py new file mode 100755 index 0000000..bbffa94 --- /dev/null +++ b/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)