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
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
|
|
|