You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

366 lines
15 KiB

# 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