class EyeTribeTracker(BaseEyeTracker): """A class for EyeTribeTracker objects""" def __init__(self, display, logfile=settings.LOGFILE, eventdetection=settings.EVENTDETECTION, saccade_velocity_threshold=35, saccade_acceleration_threshold=9500, blink_threshold=settings.BLINKTHRESH, **args): """Initializes the EyeTribeTracker object arguments display -- a pygaze.display.Display instance keyword arguments logfile -- logfile name (string value); note that this is the name for the eye data log file (default = LOGFILE) """ # try to copy docstrings (but ignore it if it fails, as we do # not need it for actual functioning of the code) try: copy_docstr(BaseEyeTracker, EyeTribeTracker) except: # we're not even going to show a warning, since the copied # docstring is useful for code editors; these load the docs # in a non-verbose manner, so warning messages would be lost pass # object properties self.disp = display self.screen = Screen() self.dispsize = settings.DISPSIZE # display size in pixels self.screensize = settings.SCREENSIZE # display size in cm self.kb = Keyboard(keylist=['space', 'escape', 'q'], timeout=1) self.errorbeep = Sound(osc='saw', freq=100, length=100) # output file properties self.outputfile = logfile # eye tracker properties self.connected = False self.recording = False self.errdist = 2 # degrees; maximal error for drift correction self.pxerrdist = 30 # initial error in pixels self.maxtries = 100 # number of samples obtained before giving up (for obtaining accuracy and tracker distance information, as well as starting or stopping recording) self.prevsample = (-1, -1) self.prevps = -1 # event detection properties self.fixtresh = 1.5 # degrees; maximal distance from fixation start (if gaze wanders beyond this, fixation has stopped) self.fixtimetresh = 100 # milliseconds; amount of time gaze has to linger within self.fixtresh to be marked as a fixation self.spdtresh = saccade_velocity_threshold # degrees per second; saccade velocity threshold self.accthresh = saccade_acceleration_threshold # degrees per second**2; saccade acceleration threshold self.blinkthresh = blink_threshold # milliseconds; blink detection threshold used in PyGaze method self.eventdetection = eventdetection self.set_detection_type(self.eventdetection) self.weightdist = 10 # weighted distance, used for determining whether a movement is due to measurement error (1 is ok, higher is more conservative and will result in only larger saccades to be detected) # connect to the tracker self.eyetribe = EyeTribe(logfilename=logfile) # get info on the sample rate self.samplerate = self.eyetribe._samplefreq self.sampletime = 1000.0 * self.eyetribe._intsampletime # initiation report self.log("pygaze initiation report start") self.log("display resolution: {}x{}".format(self.dispsize[0], self.dispsize[1])) self.log("display size in cm: {}x{}".format(self.screensize[0], self.screensize[1])) self.log("samplerate: {} Hz".format(self.samplerate)) self.log("sampletime: {} ms".format(self.sampletime)) self.log("fixation threshold: {} degrees".format(self.fixtresh)) self.log("speed threshold: {} degrees/second".format(self.spdtresh)) self.log("acceleration threshold: {} degrees/second**2".format( self.accthresh)) self.log("pygaze initiation report end") def calibrate(self): """Calibrates the eye tracking system arguments None keyword arguments None returns success -- returns True if calibration succeeded, or False if not; in addition a calibration log is added to the log file and some properties are updated (i.e. the thresholds for detection algorithms) """ # CALIBRATION # determine the calibration points calibpoints = [] for x in [0.1, 0.5, 0.9]: for y in [0.1, 0.5, 0.9]: calibpoints.append( (int(x * self.dispsize[0]), int(y * self.dispsize[1]))) random.shuffle(calibpoints) # show a message self.screen.clear() self.screen.draw_text( text="Press Space to calibrate, S to skip, and Q to quit", fontsize=20) self.disp.fill(self.screen) self.disp.show() # wait for keyboard input key, keytime = self.kb.get_key(keylist=['q', 's', 'space'], timeout=None, flush=True) if key == 's': return True if key == 'q': quited = True else: quited = False # Pause the processing of samples during the calibration. # self.eyetribe._pause_sample_processing() # run until the user is statisfied, or quits calibrated = False calibresult = None while not quited and not calibrated: # Clear the existing calibration. if self.eyetribe._tracker.get_iscalibrated(): self.eyetribe._lock.acquire(True) self.eyetribe.calibration.clear() self.eyetribe._lock.release() # Wait for a bit. clock.pause(1500) # start a new calibration if not self.eyetribe._tracker.get_iscalibrating(): self.eyetribe._lock.acquire(True) self.eyetribe.calibration.start(pointcount=len(calibpoints)) self.eyetribe._lock.release() # loop through calibration points for cpos in calibpoints: # Check whether the calibration is already done. # (Not sure how or why, but for some reason some data # can persist between calbrations, and the tracker will # simply stop allowing further pointstart requests.) if self.eyetribe._tracker.get_iscalibrated(): break # Draw a calibration target. self.draw_calibration_target(cpos[0], cpos[1]) # wait for a bit to allow participant to start looking at # the calibration point (#TODO: space press?) clock.pause(settings.EYETRIBEPRECALIBDUR) # start calibration of point self.eyetribe._lock.acquire(True) self.eyetribe.calibration.pointstart(cpos[0], cpos[1]) self.eyetribe._lock.release() # wait for a second clock.pause(settings.EYETRIBECALIBDUR) # stop calibration of this point self.eyetribe._lock.acquire(True) self.eyetribe.calibration.pointend() self.eyetribe._lock.release() # check if the Q key has been pressed if self.kb.get_key(keylist=['q'], timeout=10, flush=False)[0] == 'q': # abort calibration self.eyetribe._lock.acquire(True) self.eyetribe.calibration.abort() self.eyetribe._lock.release() # set quited variable and break this for loop quited = True break # retry option if the calibration was aborted if quited: # show retry message self.screen.clear() self.screen.draw_text( "Calibration aborted. Press Space to restart or 'Q' to quit", fontsize=20) self.disp.fill(self.screen) self.disp.show() # get input key, keytime = self.kb.get_key(keylist=['q', 'space'], timeout=None, flush=True) if key == 'space': # unset quited Boolean quited = False # skip further processing continue # empty display self.disp.fill() self.disp.show() # allow for a bit of calculation time # (this is waaaaaay too much) clock.pause(1000) # get the calibration result self.eyetribe._lock.acquire(True) calibresult = self.eyetribe._tracker.get_calibresult() self.eyetribe._lock.release() # results # clear the screen self.screen.clear() # draw results for each point if type(calibresult) == dict: for p in calibresult['calibpoints']: # only draw the point if data was obtained if p['state'] > 0: # draw the mean error # self.screen.draw_circle(colour=(252,233,79), # pos=(p['cpx'],p['cpy']), r=p['mepix'], pw=0, # fill=True) self.screen.draw_line(spos=(p['cpx'], p['cpy']), epos=(p['mecpx'], p['mecpy']), pw=2) # draw the point self.screen.draw_fixation(fixtype='dot', colour=(115, 210, 22), pos=(p['cpx'], p['cpy'])) # draw the estimated point self.screen.draw_fixation(fixtype='dot', colour=(32, 74, 135), pos=(p['mecpx'], p['mecpy'])) # annotate accuracy self.screen.draw_text(text="{}".format(\ round(p['acd'], ndigits=2)), pos=(p['cpx']+10,p['cpy']+10), fontsize=20) # if no data was obtained, draw the point in red else: self.screen.draw_fixation(fixtype='dot', colour=(204, 0, 0), pos=(p['cpx'], p['cpy'])) # draw box for averages # self.screen.draw_rect(colour=(238,238,236), x=int(self.dispsize[0]*0.15), y=int(self.dispsize[1]*0.2), w=400, h=200, pw=0, fill=True) # draw result if calibresult['result']: self.screen.draw_text(text="Calibration successful", colour=(0, 255, 0), pos=(int(self.dispsize[0] * 0.5), int(self.dispsize[1] * 0.25)), fontsize=20) else: self.screen.draw_text(text="Calibration failed", colour=(255, 0, 0), pos=(int(self.dispsize[0] * 0.5), int(self.dispsize[1] * 0.25)), fontsize=20) # draw average accuracy self.screen.draw_text( text="Average error = {} degrees".format(round(\ calibresult['deg'], ndigits=2)), \ pos=(int(self.dispsize[0]*0.5),int(self.dispsize[1]*0.25+30)), fontsize=20) # draw input options self.screen.draw_text( text="Press Space to continue or 'R' to restart", pos=(int(self.dispsize[0] * 0.5), int(self.dispsize[1] * 0.25 + 60)), fontsize=20) else: self.screen.draw_text( text="Calibration failed. Press 'R' to try again.", fontsize=20) # show the results self.disp.fill(self.screen) self.disp.show() # wait for input key, keytime = self.kb.get_key(keylist=['space', 'r'], timeout=None, flush=True) # process input if key == 'space': calibrated = True # Continue the processing of samples after the calibration. # self.eyetribe._unpause_sample_processing() # calibration failed if the user quited if quited: return False # NOISE CALIBRATION # get all error estimates (pixels) var = [] for p in calibresult['calibpoints']: # only draw the point if data was obtained if p['state'] > 0: var.append(p['mepix']) noise = sum(var) / float(len(var)) self.pxdsttresh = (noise, noise) # AFTERMATH # store some variables pixpercm = (self.dispsize[0] / float(self.screensize[0]) + self.dispsize[1] / float(self.screensize[1])) / 2 screendist = settings.SCREENDIST # calculate thresholds based on tracker settings self.accuracy = ((calibresult['Ldeg'], calibresult['Ldeg']), (calibresult['Rdeg'], calibresult['Rdeg'])) self.pxerrdist = deg2pix(screendist, self.errdist, pixpercm) self.pxfixtresh = deg2pix(screendist, self.fixtresh, pixpercm) self.pxaccuracy = ((deg2pix(screendist, self.accuracy[0][0], pixpercm), deg2pix(screendist, self.accuracy[0][1], pixpercm)), (deg2pix(screendist, self.accuracy[1][0], pixpercm), deg2pix(screendist, self.accuracy[1][1], pixpercm))) self.pxspdtresh = deg2pix(screendist, self.spdtresh / 1000.0, pixpercm) # in pixels per millisecond self.pxacctresh = deg2pix(screendist, self.accthresh / 1000.0, pixpercm) # in pixels per millisecond**2 # calibration report self.log("pygaze calibration report start") self.log("accuracy (degrees): LX={}, LY={}, RX={}, RY={}".format( self.accuracy[0][0], self.accuracy[0][1], self.accuracy[1][0], \ self.accuracy[1][1])) self.log("accuracy (in pixels): LX={}, LY={}, RX={}, RY={}".format( \ self.pxaccuracy[0][0], self.pxaccuracy[0][1], \ self.pxaccuracy[1][0], self.pxaccuracy[1][1])) self.log("precision (RMS noise in pixels): X={}, Y={}".format( \ self.pxdsttresh[0], self.pxdsttresh[1])) self.log("distance between participant and display: {} cm".format( \ screendist)) self.log("fixation threshold: {} pixels".format(self.pxfixtresh)) self.log("speed threshold: {} pixels/ms".format(self.pxspdtresh)) self.log("acceleration threshold: {} pixels/ms**2".format( \ self.pxacctresh)) self.log("pygaze calibration report end") return True def close(self): """Neatly close connection to tracker arguments None returns Nothing -- saves data and sets self.connected to False """ # close connection self.eyetribe.close() self.connected = False def connected(self): """Checks if the tracker is connected arguments None returns connected -- True if connection is established, False if not; sets self.connected to the same value """ res = self.eyetribe._tracker.get_trackerstate() if res == 0: self.connected = True else: self.connected = False return self.connected def drift_correction(self, pos=None, fix_triggered=False): """Performs a drift check arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) fix_triggered -- Boolean indicating if drift check should be performed based on gaze position (fix_triggered = True) or on spacepress (fix_triggered = False) (default = False) returns checked -- Boolaan indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ if pos == None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 if fix_triggered: return self.fix_triggered_drift_correction(pos) self.draw_drift_correction_target(pos[0], pos[1]) pressed = False while not pressed: pressed, presstime = self.kb.get_key() if pressed: if pressed == 'escape' or pressed == 'q': print( "libeyetribe.EyeTribeTracker.drift_correction: 'q' or 'escape' pressed" ) return self.calibrate() gazepos = self.sample() if ((gazepos[0] - pos[0])**2 + (gazepos[1] - pos[1])**2)**0.5 < self.pxerrdist: return True else: self.errorbeep.play() return False def draw_drift_correction_target(self, x, y): """ Draws the drift-correction target. arguments x -- The X coordinate y -- The Y coordinate """ self.screen.clear() self.screen.draw_fixation(fixtype='dot', colour=settings.FGC, pos=(x, y), pw=0, diameter=12) self.disp.fill(self.screen) self.disp.show() def draw_calibration_target(self, x, y): self.draw_drift_correction_target(x, y) def fix_triggered_drift_correction(self, pos=None, min_samples=10, max_dev=60, reset_threshold=30): """Performs a fixation triggered drift correction by collecting a number of samples and calculating the average distance from the fixation position arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) min_samples -- minimal amount of samples after which an average deviation is calculated (default = 10) max_dev -- maximal deviation from fixation in pixels (default = 60) reset_threshold -- if the horizontal or vertical distance in pixels between two consecutive samples is larger than this threshold, the sample collection is reset (default = 30) returns checked -- Boolaan indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ self.draw_drift_correction_target(pos[0], pos[1]) if pos == None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 # loop until we have sufficient samples lx = [] ly = [] while len(lx) < min_samples: # pressing escape enters the calibration screen if self.kb.get_key()[0] in ['escape', 'q']: print( "libeyetribe.EyeTribeTracker.fix_triggered_drift_correction: 'q' or 'escape' pressed" ) return self.calibrate() # collect a sample x, y = self.sample() if len(lx) == 0 or x != lx[-1] or y != ly[-1]: # if present sample deviates too much from previous sample, reset counting if len(lx) > 0 and (abs(x - lx[-1]) > reset_threshold or abs(y - ly[-1]) > reset_threshold): lx = [] ly = [] # collect samples else: lx.append(x) ly.append(y) if len(lx) == min_samples: avg_x = sum(lx) / len(lx) avg_y = sum(ly) / len(ly) d = ((avg_x - pos[0])**2 + (avg_y - pos[1])**2)**0.5 if d < max_dev: return True else: lx = [] ly = [] def get_eyetracker_clock_async(self): """Not supported for EyeTribeTracker (yet)""" print("function not supported yet") def log(self, msg): """Writes a message to the log file arguments ms -- a string to include in the log file returns Nothing -- uses native log function of iViewX to include a line in the log file """ self.eyetribe.log_message(msg) def prepare_drift_correction(self, pos): """Not supported for EyeTribeTracker (yet)""" print("function not supported yet") def pupil_size(self): """Return pupil size arguments None returns pupil size -- returns pupil diameter for the eye that is currently being tracked (as specified by self.eye_used) or -1 when no data is obtainable """ # get newest pupil size ps = self.eyetribe.pupil_size() # invalid data if ps == None: return -1 # check if the new pupil size is the same as the previous if ps != self.prevps: # update the pupil size self.prevps = copy.copy(ps) return self.prevps def sample(self): """Returns newest available gaze position arguments None returns sample -- an (x,y) tuple or a (-1,-1) on an error """ # get newest sample s = self.eyetribe.sample() # invalid data if s == (None, None): return (-1, -1) # check if the new sample is the same as the previous if s != self.prevsample: # update the current sample self.prevsample = copy.copy(s) return self.prevsample def send_command(self, cmd): """Sends a command to the eye tracker arguments cmd -- the command to be sent to the EyeTribe, which should be a list with the following information: [category, request, values] returns Nothing """ self.eyetribe._connection.request(cmd) def start_recording(self): """Starts recording eye position arguments None returns Nothing -- sets self.recording to True when recording is successfully started """ self.eyetribe.start_recording() self.recording = True def status_msg(self, msg): """Not supported for EyeTribeTracker (yet)""" print("function not supported yet") def stop_recording(self): """Stop recording eye position arguments None returns Nothing -- sets self.recording to False when recording is successfully started """ self.eyetribe.stop_recording() self.recording = False def set_detection_type(self, eventdetection): """Set the event detection type to either PyGaze algorithms, or native algorithms as provided by the manufacturer (only if available: detection type will default to PyGaze if no native functions are available) arguments eventdetection -- a string indicating which detection type should be employed: either 'pygaze' for PyGaze event detection algorithms or 'native' for manufacturers algorithms (only if available; will default to 'pygaze' if no native event detection is available) returns -- detection type for saccades, fixations and blinks in a tuple, e.g. ('pygaze','native','native') when 'native' was passed, but native detection was not available for saccade detection """ if eventdetection in ['pygaze', 'native']: self.eventdetection = eventdetection return ('pygaze', 'pygaze', 'pygaze') def wait_for_event(self, event): """Waits for event arguments event -- an integer event code, one of the following: 3 = STARTBLINK 4 = ENDBLINK 5 = STARTSACC 6 = ENDSACC 7 = STARTFIX 8 = ENDFIX returns outcome -- a self.wait_for_* method is called, depending on the specified event; the return values of corresponding method are returned """ if event == 5: outcome = self.wait_for_saccade_start() elif event == 6: outcome = self.wait_for_saccade_end() elif event == 7: outcome = self.wait_for_fixation_start() elif event == 8: outcome = self.wait_for_fixation_end() elif event == 3: outcome = self.wait_for_blink_start() elif event == 4: outcome = self.wait_for_blink_end() else: raise Exception( "Error in libeyetribe.EyeTribeTracker.wait_for_event: eventcode {} is not supported" .format(event)) return outcome def wait_for_blink_end(self): """Waits for a blink end and returns the blink ending time arguments None returns timestamp -- blink ending time in milliseconds, as measured from experiment begin time """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method blinking = True # loop while there is a blink while blinking: # get newest sample gazepos = self.sample() # check if it's valid if self.is_valid_sample(gazepos): # if it is a valid sample, blinking has stopped blinking = False # return timestamp of blink end return clock.get_time() def wait_for_blink_start(self): """Waits for a blink start and returns the blink starting time arguments None returns timestamp -- blink starting time in milliseconds, as measured from experiment begin time """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method blinking = False # loop until there is a blink while not blinking: # get newest sample gazepos = self.sample() # check if it's a valid sample if not self.is_valid_sample(gazepos): # get timestamp for possible blink start t0 = clock.get_time() # loop until a blink is determined, or a valid sample occurs while not self.is_valid_sample(self.sample()): # check if time has surpassed BLINKTHRESH if clock.get_time() - t0 >= self.blinkthresh: # return timestamp of blink start return t0 def wait_for_fixation_end(self): """Returns time and gaze position when a fixation has ended; function assumes that a 'fixation' has ended when a deviation of more than self.pxfixtresh from the initial fixation position has been detected (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer fixation detection; \ PyGaze algorithm will be used") # # # # # # PyGaze method # function assumes that a 'fixation' has ended when a deviation of more than fixtresh # from the initial 'fixation' position has been detected # get starting time and position stime, spos = self.wait_for_fixation_start() # loop until fixation has ended while True: # get new sample npos = self.sample() # get newest sample # check if sample is valid if self.is_valid_sample(npos): # check if sample deviates to much from starting position if (npos[0] - spos[0])**2 + ( npos[1] - spos[1])**2 > self.pxfixtresh**2: # Pythagoras # break loop if deviation is too high break return clock.get_time(), spos def wait_for_fixation_start(self): """Returns starting time and position when a fixation is started; function assumes a 'fixation' has started when gaze position remains reasonably stable (i.e. when most deviant samples are within self.pxfixtresh) for five samples in a row (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a fixation start # detection built into their API (only ending) print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer fixation detection; \ PyGaze algorithm will be used") # # # # # # PyGaze method # function assumes a 'fixation' has started when gaze position # remains reasonably stable for self.fixtimetresh # get starting position spos = self.sample() while not self.is_valid_sample(spos): spos = self.sample() # get starting time t0 = clock.get_time() # wait for reasonably stable position moving = True while moving: # get new sample npos = self.sample() # check if sample is valid if self.is_valid_sample(npos): # check if new sample is too far from starting position if (npos[0] - spos[0])**2 + ( npos[1] - spos[1])**2 > self.pxfixtresh**2: # Pythagoras # if not, reset starting position and time spos = copy.copy(npos) t0 = clock.get_time() # if new sample is close to starting sample else: # get timestamp t1 = clock.get_time() # check if fixation time threshold has been surpassed if t1 - t0 >= self.fixtimetresh: # return time and starting position return t1, spos def wait_for_saccade_end(self): """Returns ending time, starting and end position when a saccade is ended; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos, endpos -- endtime in milliseconds (from expbegintime); startpos and endpos are (x,y) gaze position tuples """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # get starting position (no blinks) t0, spos = self.wait_for_saccade_start() # get valid sample prevpos = self.sample() while not self.is_valid_sample(prevpos): prevpos = self.sample() # get starting time, intersample distance, and velocity t1 = clock.get_time() s = ((prevpos[0] - spos[0])**2 + (prevpos[1] - spos[1])** 2)**0.5 # = intersample distance = speed in px/sample v0 = s / (t1 - t0) # run until velocity and acceleration go below threshold saccadic = True while saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # calculate distance s = ((newpos[0] - prevpos[0])**2 + (newpos[1] - prevpos[1])** 2)**0.5 # = speed in pixels/sample # calculate velocity v1 = s / (t1 - t0) # calculate acceleration a = (v1 - v0) / ( t1 - t0 ) # acceleration in pixels/sample**2 (actually is v1-v0 / t1-t0; but t1-t0 = 1 sample) # check if velocity and acceleration are below threshold if v1 < self.pxspdtresh and (a > -1 * self.pxacctresh and a < 0): saccadic = False epos = newpos[:] etime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] return etime, spos, epos def wait_for_saccade_start(self): """Returns starting time and starting position when a saccade is started; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos -- endtime in milliseconds (from expbegintime); startpos is an (x,y) gaze position tuple """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # get starting position (no blinks) newpos = self.sample() while not self.is_valid_sample(newpos): newpos = self.sample() # get starting time, position, intersampledistance, and velocity t0 = clock.get_time() prevpos = newpos[:] s = 0 v0 = 0 # get samples saccadic = False while not saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # check if distance is larger than precision error sx = newpos[0] - prevpos[0] sy = newpos[1] - prevpos[1] if (sx / self.pxdsttresh[0])**2 + ( sy / self.pxdsttresh[1] )**2 > self.weightdist: # weigthed distance: (sx/tx)**2 + (sy/ty)**2 > 1 means movement larger than RMS noise # calculate distance s = ((sx)**2 + (sy)** 2)**0.5 # intersampledistance = speed in pixels/ms # calculate velocity v1 = s / (t1 - t0) # calculate acceleration a = (v1 - v0) / (t1 - t0) # acceleration in pixels/ms**2 # check if either velocity or acceleration are above threshold values if v1 > self.pxspdtresh or a > self.pxacctresh: saccadic = True spos = prevpos[:] stime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] return stime, spos def is_valid_sample(self, gazepos): """Checks if the sample provided is valid, based on EyeTribe specific criteria (for internal use) arguments gazepos -- a (x,y) gaze position tuple, as returned by self.sample() returns valid -- a Boolean: True on a valid sample, False on an invalid sample """ # return False if a sample is invalid if gazepos == (None, None) or gazepos == (-1, -1): return False # in any other case, the sample is valid return True
#scr.draw_rect() scr.clear() scr.draw_text("There should be two rectangles on the screen: \ \nred filled on the left, and green unfilled on the right", pos=(DISPSIZE[0]/2, DISPSIZE[1]/4)) scr.draw_rect(colour=(255,0,0), x=DISPSIZE[0]*0.25, y=DISPSIZE[1]/2, w=DISPSIZE[0]/10, h=DISPSIZE[0]/5, pw=5, fill=True) scr.draw_rect(colour=(0,255,0), x=DISPSIZE[0]*0.75, y=DISPSIZE[1]/2, w=DISPSIZE[0]/10, h=DISPSIZE[0]/5, pw=5, fill=False) disp.fill(scr) disp.show() kb.get_key() #scr.draw_line() scr.clear() scr.draw_text("There should be three lines on the screen: \ \nred oblique on the left, green horizontal in the centre, and blue vertical on the right", pos=(DISPSIZE[0]/2, DISPSIZE[1]/4)) scr.draw_line(colour=(255,0,0), spos=(DISPSIZE[0]*0.20,DISPSIZE[1]*0.45), epos=(DISPSIZE[0]*0.30,DISPSIZE[1]*0.55), pw=5) scr.draw_line(colour=(0,255,0), spos=(DISPSIZE[0]*0.45,DISPSIZE[1]/2), epos=(DISPSIZE[0]*0.55,DISPSIZE[1]/2), pw=5) scr.draw_line(colour=(0,0,255), spos=(DISPSIZE[0]*0.75,DISPSIZE[1]*0.45), epos=(DISPSIZE[0]*0.75,DISPSIZE[1]*0.55), pw=5) disp.fill(scr) disp.show() kb.get_key() #scr.draw_polygon() scr.clear() scr.draw_text("There should be two polygons on the screen: \ \nred filled triangle on the left, and green unfilled hexagon on the right", pos=(DISPSIZE[0]/2, DISPSIZE[1]/4)) pl = [(DISPSIZE[0]*0.25, DISPSIZE[1]*0.45), (DISPSIZE[0]*0.2, DISPSIZE[1]*0.55), (DISPSIZE[0]*0.3, DISPSIZE[1]*0.55)] scr.draw_polygon(pl, colour=(255,0,0), pw=5, fill=True) # topleft, topright, centreright, bottomright, bottomleft, centreleft pl = [(DISPSIZE[0]*0.70, DISPSIZE[1]*0.40), (DISPSIZE[0]*0.80, DISPSIZE[1]*0.40), (DISPSIZE[0]*0.85, DISPSIZE[1]*0.5), (DISPSIZE[0]*0.80, DISPSIZE[1]*0.60), (DISPSIZE[0]*0.70, DISPSIZE[1]*0.60), (DISPSIZE[0]*0.65, DISPSIZE[1]*0.5)] scr.draw_polygon(pl, colour=(0,255,0), pw=5, fill=False)
class TobiiProTracker(BaseEyeTracker): """A class for Tobii Pro EyeTracker objects""" def __init__(self, display, logfile=settings.LOGFILE, eventdetection=settings.EVENTDETECTION, saccade_velocity_threshold=35, saccade_acceleration_threshold=9500, blink_threshold=settings.BLINKTHRESH, **args): """Initializes a TobiiProTracker instance arguments display -- a pygaze.display.Display instance keyword arguments None """ self.gaze = [] self.disp = display # initialize a screen self.screen = Screen() # initialize keyboard self.kb = Keyboard(keylist=['space', 'escape', 'q','enter'], timeout=1) self.recording = False self.screendist = settings.SCREENDIST if hasattr(settings, 'TRACKERSERIALNUMBER'): # Search for a specific eye tracker self.eyetrackers = [t for t in tr.find_all_eyetrackers() if t.serial_number == settings.TRACKERSERIALNUMBER] else: # Search for all eye trackers (The first one found will be selected) self.eyetrackers = tr.find_all_eyetrackers() if self.eyetrackers: self.eyetracker = self.eyetrackers[0] else: print("WARNING! libtobii.TobiiProTracker.__init__: no eye trackers found!") self.LEFT_EYE = 0 self.RIGHT_EYE = 1 self.BINOCULAR = 2 self.eye_used = 0 # 0=left, 1=right, 2=binocular # calibration and validation points lb = 0.1 # left bound xc = 0.5 # horizontal center rb = 0.9 # right bound ub = 0.1 # upper bound yc = 0.5 # vertical center bb = 0.9 # bottom bound self.points_to_calibrate = [self._norm_2_px(p) for p in [(lb, ub), (xc,ub), (rb, ub), (lb,yc), (xc, yc),(rb,yc), (lb, bb),(xc,bb),(rb, bb)]] # event detection properties self.fixtresh = 1.5 # degrees; maximal distance from fixation start (if gaze wanders beyond this, fixation has stopped) self.fixtimetresh = 100 # milliseconds; amount of time gaze has to linger within self.fixtresh to be marked as a fixation self.spdtresh = saccade_velocity_threshold # degrees per second; saccade velocity threshold self.accthresh = saccade_acceleration_threshold # degrees per second**2; saccade acceleration threshold self.blinkthresh = blink_threshold # milliseconds; blink detection threshold used in PyGaze method self.eventdetection = eventdetection self.weightdist = 10 # weighted distance, used for determining whether a movement is due to measurement error (1 is ok, higher is more conservative and will result in only larger saccades to be detected) self.screensize = settings.SCREENSIZE # display size in cm self.pixpercm = (self.disp.dispsize[0] / float(self.screensize[0]) + self.disp.dispsize[1] / float(self.screensize[1])) / 2.0 self.errdist = 2 # degrees; maximal error for drift correction self.pxerrdist = self._deg2pix(self.screendist, self.errdist, self.pixpercm) self.event_data = [] self.t0 = None self._write_enabled = True self.datafile = open("{0}_TOBII_output.tsv".format(logfile), 'w') # initiation report self.datafile.write("pygaze initiation report start\n") self.datafile.write("display resolution: %sx%s\n" % (self.disp.dispsize[0], self.disp.dispsize[1])) self.datafile.write("display size in cm: %sx%s\n" % (self.screensize[0], self.screensize[1])) self.datafile.write("fixation threshold: %s degrees\n" % self.fixtresh) self.datafile.write("speed threshold: %s degrees/second\n" % self.spdtresh) self.datafile.write("acceleration threshold: %s degrees/second**2\n" % self.accthresh) self.datafile.write("pygaze initiation report end\n") def _norm_2_px(self, normalized_point): return (round(normalized_point[0] * self.disp.dispsize[0], 0), round(normalized_point[1] * self.disp.dispsize[1], 0)) def _px_2_norm(self, pixelized_point): return (pixelized_point[0] / self.disp.dispsize[0], pixelized_point[1] / self.disp.dispsize[1]) def _mean(self, array): if array: a = [s for s in array if s is not None] return sum(a) / float(len(a)) def _deg2pix(self, cmdist, angle, pixpercm): return pixpercm * math.tan(math.radians(angle)) * float(cmdist) def log_var(self, var, val): """Writes a variable to the log file arguments var -- variable name val -- variable value returns Nothing -- uses native log function to include a line in the log file in a "var NAME VALUE" layout """ self.log("var %s %s" % (var, val)) def set_eye_used(self): """Logs the eye_used variable, based on which eye was specified. arguments None returns Nothing -- logs which eye is used by calling self.log_var, e.g. self.log_var("eye_used", "right") """ if self.eye_used == self.BINOCULAR: self.log_var("eye_used", "binocular") elif self.eye_used == self.RIGHT_EYE: self.log_var("eye_used", "right") else: self.log_var("eye_used", "left") def is_valid_sample(self, sample): """Checks if the sample provided is valid, based on Tobii specific criteria (for internal use) arguments sample -- a (x,y) gaze position tuple, as returned by self.sample() returns valid -- a Boolean: True on a valid sample, False on an invalid sample """ return sample != (-1, -1) def _on_gaze_data(self, gaze_data): self.gaze.append(gaze_data) if self._write_enabled: self._write_sample(gaze_data) def start_recording(self): """Starts recording eye position arguments None returns None -- sets self.recording to True when recording is successfully started """ if not self.t0 and self._write_enabled: self.t0 = tr.get_system_time_stamp() self._write_header() if self.recording: print("WARNING! libtobii.TobiiProTracker.start_recording: Recording already started!") self.gaze = [] else: self.gaze = [] self.eyetracker.subscribe_to(tr.EYETRACKER_GAZE_DATA, self._on_gaze_data, as_dictionary=True) time.sleep(1) self.recording = True def stop_recording(self): """Stop recording eye position arguments None returns Nothing -- sets self.recording to False when recording is successfully started """ if self.recording: self.eyetracker.unsubscribe_from(tr.EYETRACKER_GAZE_DATA) self.recording = False self.event_data = [] else: print("WARNING! libtobii.TobiiProTracker.stop_recording: A recording has not been started!") def sample(self): """Returns newest available gaze position The gaze position is relative to the self.eye_used currently selected. If both eyes are selected, the gaze position is averaged from the data of both eyes. arguments None returns sample -- an (x,y) tuple or a (-1,-1) on an error """ gaze_sample = copy.copy(self.gaze[-1]) if self.eye_used == self.LEFT_EYE and gaze_sample["left_gaze_point_validity"]: return self._norm_2_px(gaze_sample["left_gaze_point_on_display_area"]) if self.eye_used == self.RIGHT_EYE and gaze_sample["right_gaze_point_validity"]: return self._norm_2_px(gaze_sample["right_gaze_point_on_display_area"]) if self.eye_used == self.BINOCULAR: if gaze_sample["left_gaze_point_validity"] and gaze_sample["right_gaze_point_validity"]: left_sample = self._norm_2_px(gaze_sample["left_gaze_point_on_display_area"]) right_sample = self._norm_2_px(gaze_sample["right_gaze_point_on_display_area"]) return (self._mean([left_sample[0], right_sample[0]]), self._mean([left_sample[1], right_sample[1]])) if gaze_sample["left_gaze_point_validity"]: return self._norm_2_px(gaze_sample["left_gaze_point_on_display_area"]) if gaze_sample["right_gaze_point_validity"]: return self._norm_2_px(gaze_sample["right_gaze_point_on_display_area"]) return (-1, -1) def pupil_size(self): """Returns newest available pupil size arguments None returns pupilsize -- a float if only eye is selected or only one eye has valid data. -- a tuple with two floats if both eyes are selected. -- -1 if there is no valid pupil data available. """ if self.gaze: gaze_sample = copy.copy(self.gaze[-1]) if self.eye_used == self.BINOCULAR: pupil_data = [-1, -1] if gaze_sample["left_pupil_validity"]: pupil_data[0] = gaze_sample["left_pupil_diameter"] if gaze_sample["right_pupil_validity"]: pupil_data[1] = gaze_sample["right_pupil_diameter"] return tuple(pupil_data) if self.eye_used == self.LEFT_EYE and gaze_sample["left_pupil_validity"]: return gaze_sample["left_pupil_diameter"] if self.eye_used == self.RIGHT_EYE and gaze_sample["right_pupil_validity"]: return gaze_sample["right_pupil_diameter"] return -1 def ReduceBall (self,point,facteur,colour): self.showPoint(point,colour,facteur) for i in range (0,200,3): self.screen.clear() self.screen.draw_circle(colour=colour, pos=point, r=int(self.disp.dispsize[0] / (facteur+i)), pw=5, fill=True) self.disp.fill(self.screen) self.disp.show() def showPoint( self,point,colour,facteur): self.screen.clear() self.screen.draw_circle(colour=colour, pos=point, r=int(self.disp.dispsize[0] / facteur), pw=5, fill=True) self.disp.fill(self.screen) self.disp.show() def calibrate(self, calibrate=True, validate=True): """Calibrates the eye tracker. arguments None keyword arguments calibrate -- Boolean indicating if calibration should be performed (default = True). validate -- Boolean indicating if validation should be performed (default = True). returns success -- returns True if calibration succeeded, or False if not; in addition a calibration log is added to the log file and some properties are updated (i.e. the thresholds for detection algorithms) """ self._write_enabled = False self.start_recording() self.screen.set_background_colour(colour=(0, 0, 0)) if calibrate: origin = (int(self.disp.dispsize[0] / 4), int(self.disp.dispsize[1] / 4)) size = (int(2 * self.disp.dispsize[0] / 4), int(2 * self.disp.dispsize[1] / 4)) while not self.kb.get_key(keylist=['space'], flush=False)[0]: gaze_sample = copy.copy(self.gaze[-1]) self.screen.clear() validity_colour = (255, 0, 0) if gaze_sample['right_gaze_origin_validity'] and gaze_sample['left_gaze_origin_validity']: left_validity = 0.15 < gaze_sample['left_gaze_origin_in_trackbox_coordinate_system'][2] < 0.85 right_validity = 0.15 < gaze_sample['right_gaze_origin_in_trackbox_coordinate_system'][2] < 0.85 if left_validity and right_validity: validity_colour = (0, 255, 0) self.screen.draw_text(text="When correctly positioned press \'space\' to start the calibration.", pos=(int(self.disp.dispsize[0] / 2), int(self.disp.dispsize[1] * 0.1)), colour=(255, 255, 255), fontsize=20) self.screen.draw_line(colour=validity_colour, spos=origin, epos=(origin[0] + size[0], origin[1]), pw=1) self.screen.draw_line(colour=validity_colour, spos=origin, epos=(origin[0], origin[1] + size[1]), pw=1) self.screen.draw_line(colour=validity_colour, spos=(origin[0], origin[1] + size[1]), epos=(origin[0] + size[0], origin[1] + size[1]), pw=1) self.screen.draw_line(colour=validity_colour, spos=(origin[0] + size[0], origin[1] + size[1]), epos=(origin[0] + size[0], origin[1]), pw=1) right_eye, left_eye, distance = None, None, [] if gaze_sample['right_gaze_origin_validity']: distance.append(round(gaze_sample['right_gaze_origin_in_user_coordinate_system'][2] / 10, 1)) right_eye = ((1 - gaze_sample['right_gaze_origin_in_trackbox_coordinate_system'][0]) * size[0] + origin[0], gaze_sample['right_gaze_origin_in_trackbox_coordinate_system'][1] * size[1] + origin[1]) self.screen.draw_circle(colour=validity_colour, pos=right_eye, r=int(self.disp.dispsize[0] / 100), pw=5, fill=True) if gaze_sample['left_gaze_origin_validity']: distance.append(round(gaze_sample['left_gaze_origin_in_user_coordinate_system'][2] / 10, 1)) left_eye = ((1 - gaze_sample['left_gaze_origin_in_trackbox_coordinate_system'][0]) * size[0] + origin[0], gaze_sample['left_gaze_origin_in_trackbox_coordinate_system'][1] * size[1] + origin[1]) self.screen.draw_circle(colour=validity_colour, pos=left_eye, r=int(self.disp.dispsize[0] / 100), pw=5, fill=True) self.screen.draw_text(text="Current distance to the eye tracker: {0} cm.".format(self._mean(distance)), pos=(int(self.disp.dispsize[0] / 2), int(self.disp.dispsize[1] * 0.9)), colour=(255, 255, 255), fontsize=20) self.disp.fill(self.screen) self.disp.show() # # # # # # # # calibration if not self.eyetracker: print("WARNING! libtobii.TobiiProTracker.calibrate: no eye trackers found for the calibration!") self.stop_recording() return False calibration = tr.ScreenBasedCalibration(self.eyetracker) calibrating = True while calibrating: calibration.enter_calibration_mode() for point in self.points_to_calibrate: self.screen.clear() # CDP : Changement couleur #self.screen.draw_circle(colour='yellow', pos=point, r=int(self.disp.dispsize[0] / 100.0), pw=5, fill=True) #self.screen.draw_circle(colour=(255, 0, 0), pos=point, r=int(self.disp.dispsize[0] / 400.0), pw=5, fill=True) #self.disp.fill(self.screen) #self.disp.show() # Wait a little for user to focus. # CDP : Ajout sasie clavier #clock.pause(1000) self.ReduceBall(point,30,'yellow') pressed_key = self.kb.get_key(keylist=['space', 'r'], flush=True, timeout=None) normalized_point = self._px_2_norm(point) if calibration.collect_data(normalized_point[0], normalized_point[1]) != tr.CALIBRATION_STATUS_SUCCESS: # Try again if it didn't go well the first time. # Not all eye tracker models will fail at this point, but instead fail on ComputeAndApply. calibration.collect_data(normalized_point[0], normalized_point[1]) self.screen.clear() self.screen.draw_text("Calculating calibration result....", colour=(255, 255, 255), fontsize=20) self.disp.fill(self.screen) self.disp.show() calibration_result = calibration.compute_and_apply() calibration.leave_calibration_mode() print "Compute and apply returned {0} and collected at {1} points.".\ format(calibration_result.status, len(calibration_result.calibration_points)) if calibration_result.status != tr.CALIBRATION_STATUS_SUCCESS: self.stop_recording() print("WARNING! libtobii.TobiiProTracker.calibrate: Calibration was unsuccessful!") return False self.screen.clear() for point in calibration_result.calibration_points: self.screen.draw_circle(colour=(255, 255, 255), pos=self._norm_2_px(point.position_on_display_area), r=self.disp.dispsize[0] / 200, pw=1, fill=False) for sample in point.calibration_samples: if sample.left_eye.validity == tr.VALIDITY_VALID_AND_USED: self.screen.draw_circle(colour=(255, 0, 0), pos=self._norm_2_px(sample.left_eye.position_on_display_area), r=self.disp.dispsize[0] / 450, pw=self.disp.dispsize[0] / 450, fill=False) self.screen.draw_line(colour=(255, 0, 0), spos=self._norm_2_px(point.position_on_display_area), epos=self._norm_2_px(sample.left_eye.position_on_display_area), pw=1) if sample.right_eye.validity == tr.VALIDITY_VALID_AND_USED: self.screen.draw_circle(colour=(0, 0, 255), pos=self._norm_2_px(sample.right_eye.position_on_display_area), r=self.disp.dispsize[0] / 450, pw=self.disp.dispsize[0] / 450, fill=False) self.screen.draw_line(colour=(0, 0, 255), spos=self._norm_2_px(point.position_on_display_area), epos=self._norm_2_px(sample.right_eye.position_on_display_area), pw=1) self.screen.draw_text("Press the \'R\' key to recalibrate or \'Space\' to continue....", pos=(0.5 * self.disp.dispsize[0], 0.95 * self.disp.dispsize[1]), colour=(255, 255, 255), fontsize=20) self.screen.draw_text("Left Eye", pos=(0.5 * self.disp.dispsize[0], 0.01 * self.disp.dispsize[1]), colour=(255, 0, 0), fontsize=20) self.screen.draw_text("Right Eye", pos=(0.5 * self.disp.dispsize[0], 0.03 * self.disp.dispsize[1]), colour=(0, 0, 255), fontsize=20) self.disp.fill(self.screen) self.disp.show() pressed_key = self.kb.get_key(keylist=['space', 'r'], flush=True, timeout=None) if pressed_key[0] == 'space': calibrating = False if validate: # # # show menu self.screen.clear() self.screen.draw_text(text="Press space to start validation", colour=(255, 255, 255), fontsize=20) self.disp.fill(self.screen) self.disp.show() # # # wait for spacepress self.kb.get_key(keylist=['space'], flush=True, timeout=None) # # # # # # # # validation # # # arrays for data storage lxacc, lyacc, rxacc, ryacc = [], [], [], [] # # loop through all calibration positions for pos in self.points_to_calibrate: # show validation point self.screen.clear() self.screen.draw_fixation(fixtype='dot', pos=pos, colour=(255, 255, 255)) self.disp.fill(self.screen) self.disp.show() # allow user some time to gaze at dot clock.pause(1000) lxsamples, lysamples, rxsamples, rysamples = [], [], [], [] for sample in self.gaze: if sample["left_gaze_point_validity"]: gaze_point = self._norm_2_px(sample["left_gaze_point_on_display_area"]) lxsamples.append(abs(gaze_point[0] - pos[0])) lysamples.append(abs(gaze_point[1] - pos[1])) if sample["right_gaze_point_validity"]: gaze_point = self._norm_2_px(sample["right_gaze_point_on_display_area"]) rxsamples.append(abs(gaze_point[0] - pos[0])) rysamples.append(abs(gaze_point[1] - pos[1])) # calculate mean deviation lxacc.append(self._mean(lxsamples)) lyacc.append(self._mean(lysamples)) rxacc.append(self._mean(rxsamples)) ryacc.append(self._mean(rysamples)) # wait for a bit to slow down validation process a bit clock.pause(1000) # calculate mean accuracy self.pxaccuracy = [(self._mean(lxacc), self._mean(lyacc)), (self._mean(rxacc), self._mean(ryacc))] # sample rate # calculate intersample times timestamps = [] gaze_samples = copy.copy(self.gaze) for i in xrange(0, len(gaze_samples) - 1): timestamps.append((gaze_samples[i + 1]['system_time_stamp'] - gaze_samples[i]['system_time_stamp']) / 1000.0) # mean intersample time self.sampletime = self._mean(timestamps) self.samplerate = int(1000.0 / self.sampletime) # # # # # # # # RMS noise # # present instructions self.screen.clear() self.screen.draw_text(text="Noise calibration: please look at the dot\n\n(press space to start)", pos=(self.disp.dispsize[0] / 2, int(self.disp.dispsize[1] * 0.2)), colour=(255, 255, 255), fontsize=20) self.screen.draw_fixation(fixtype='dot', colour=(255, 255, 255)) self.disp.fill(self.screen) self.disp.show() # # wait for spacepress self.kb.get_key(keylist=['space'], flush=True, timeout=None) # # show fixation self.screen.draw_fixation(fixtype='dot', colour=(255, 255, 255)) self.disp.fill(self.screen) self.disp.show() self.screen.clear() # # wait for a bit, to allow participant to fixate clock.pause(500) # # get samples sl = [self.sample()] # samplelist, prefilled with 1 sample to prevent sl[-1] from producing an error; first sample will be ignored for RMS calculation t0 = clock.get_time() # starting time while clock.get_time() - t0 < 1000: s = self.sample() # sample if s != sl[-1] and self.is_valid_sample(s) and s != (0, 0): sl.append(s) # # calculate RMS noise Xvar, Yvar = [], [] for i in xrange(2, len(sl)): Xvar.append((sl[i][0] - sl[i - 1][0])**2) Yvar.append((sl[i][1] - sl[i - 1][1])**2) XRMS = (self._mean(Xvar))**0.5 YRMS = (self._mean(Yvar))**0.5 self.pxdsttresh = (XRMS, YRMS) # # # # # # # # # # calibration report # # # # recalculate thresholds (degrees to pixels) self.pxfixtresh = self._deg2pix(self.screendist, self.fixtresh, self.pixpercm) self.pxspdtresh = self._deg2pix(self.screendist, self.spdtresh / 1000.0, self.pixpercm) # in pixels per millisecons self.pxacctresh = self._deg2pix(self.screendist, self.accthresh / 1000.0, self.pixpercm) # in pixels per millisecond**2 data_to_write = '' data_to_write += "pygaze calibration report start\n" data_to_write += "samplerate: %s Hz\n" % self.samplerate data_to_write += "sampletime: %s ms\n" % self.sampletime data_to_write += "accuracy (in pixels): LX=%s, LY=%s, RX=%s, RY=%s\n" % (self.pxaccuracy[0][0], self.pxaccuracy[0][1], self.pxaccuracy[1][0], self.pxaccuracy[1][1]) data_to_write += "precision (RMS noise in pixels): X=%s, Y=%s\n" % (self.pxdsttresh[0], self.pxdsttresh[1]) data_to_write += "distance between participant and display: %s cm\n" % self.screendist data_to_write += "fixation threshold: %s pixels\n" % self.pxfixtresh data_to_write += "speed threshold: %s pixels/ms\n" % self.pxspdtresh data_to_write += "accuracy threshold: %s pixels/ms**2\n" % self.pxacctresh data_to_write += "pygaze calibration report end\n" # # # # write report to log #self.datafile.write(data_to_write) self.screen.clear() self.screen.draw_text(text=data_to_write, pos=(self.disp.dispsize[0] / 2, int(self.disp.dispsize[1] / 2)), colour=(255, 255, 255), fontsize=20) self.disp.fill(self.screen) self.disp.show() self.kb.get_key(keylist=['space'], flush=True, timeout=None) self.stop_recording() self._write_enabled = True return True def fix_triggered_drift_correction(self, pos=None, min_samples=10, max_dev=60, reset_threshold=30): """Performs a fixation triggered drift correction by collecting a number of samples and calculating the average distance from the fixation position arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) min_samples -- minimal amount of samples after which an average deviation is calculated (default = 10) max_dev -- maximal deviation from fixation in pixels (default = 60) reset_threshold -- if the horizontal or vertical distance in pixels between two consecutive samples is larger than this threshold, the sample collection is reset (default = 30) returns checked -- Boolean indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ if pos is None: pos = self.disp.dispsize[0] / 2, self.disp.dispsize[1] / 2 # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False # loop until we have sufficient samples lx = [] ly = [] while len(lx) < min_samples: # pressing escape enters the calibration screen if self.kb.get_key()[0] in ['escape', 'q']: print("libtobii.TobiiTracker.fix_triggered_drift_correction: 'q' or 'escape' pressed") return self.calibrate(calibrate=True, validate=True) # collect a sample x, y = self.sample() if len(lx) == 0 or (x, y) != (lx[-1], ly[-1]): # if present sample deviates too much from previous sample, reset counting if len(lx) > 0 and (abs(x - lx[-1]) > reset_threshold or abs(y - ly[-1]) > reset_threshold): lx = [] ly = [] # collect samples else: lx.append(x) ly.append(y) if len(lx) == min_samples: avg_x = self._mean(lx) avg_y = self._mean(ly) d = ((avg_x - pos[0]) ** 2 + (avg_y - pos[1]) ** 2)**0.5 if d < max_dev: if stoprec: self.stop_recording() return True else: lx = [] ly = [] if stoprec: self.stop_recording() def drift_correction(self, pos=None, fix_triggered=False): """Performs a drift check arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) fix_triggered -- Boolean indicating if drift check should be performed based on gaze position (fix_triggered = True) or on spacepress (fix_triggered = False) (default = False) returns checked -- Boolean indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ if fix_triggered: return self.fix_triggered_drift_correction(pos) if pos is None: pos = self.disp.dispsize[0] / 2, self.disp.dispsize[1] / 2 # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False result = False pressed = False while not pressed: pressed, presstime = self.kb.get_key() if pressed: if pressed == 'escape' or pressed == 'q': print("libtobii.TobiiProTracker.drift_correction: 'q' or 'escape' pressed") return self.calibrate(calibrate=True, validate=True) gazepos = self.sample() if ((gazepos[0] - pos[0])**2 + (gazepos[1] - pos[1])**2)**0.5 < self.pxerrdist: result = True if stoprec: self.stop_recording() return result def wait_for_fixation_start(self): """Returns starting time and position when a fixation is started; function assumes a 'fixation' has started when gaze position remains reasonably stable (i.e. when most deviant samples are within self.pxfixtresh) for five samples in a row (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # Tobii method if self.eventdetection == 'native': # print warning, since Tobii does not have a fixation start # detection built into their API (only ending) print("WARNING! 'native' event detection has been selected, \ but Tobii does not offer fixation detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # function assumes a 'fixation' has started when gaze position # remains reasonably stable for self.fixtimetresh # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False # get starting position spos = self.sample() while not self.is_valid_sample(spos): spos = self.sample() # get starting time t0 = clock.get_time() # wait for reasonably stable position moving = True while moving: # get new sample npos = self.sample() # check if sample is valid if self.is_valid_sample(npos): # check if new sample is too far from starting position if (npos[0] - spos[0])**2 + (npos[1] - spos[1])**2 > self.pxfixtresh**2: # Pythagoras # if not, reset starting position and time spos = copy.copy(npos) t0 = clock.get_time() # if new sample is close to starting sample else: # get timestamp t1 = clock.get_time() # check if fixation time threshold has been surpassed if t1 - t0 >= self.fixtimetresh: if stoprec: self.stop_recording() # return time and starting position return t0, spos def wait_for_fixation_end(self): """Returns time and gaze position when a fixation has ended; function assumes that a 'fixation' has ended when a deviation of more than self.pxfixtresh from the initial fixation position has been detected (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # Tobii method if self.eventdetection == 'native': # print warning, since Tobii does not have a fixation detection # built into their API print("WARNING! 'native' event detection has been selected, \ but Tobii does not offer fixation detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method # function assumes that a 'fixation' has ended when a deviation of more than fixtresh # from the initial 'fixation' position has been detected # get starting time and position stime, spos = self.wait_for_fixation_start() # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False # loop until fixation has ended while True: # get new sample npos = self.sample() # get newest sample # check if sample is valid if self.is_valid_sample(npos): # check if sample deviates to much from starting position if (npos[0] - spos[0])**2 + (npos[1] - spos[1])**2 > self.pxfixtresh**2: # break loop if deviation is too high break if stoprec: self.stop_recording() return clock.get_time(), spos def wait_for_saccade_start(self): """Returns starting time and starting position when a saccade is started; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos -- endtime in milliseconds (from expbegintime); startpos is an (x,y) gaze position tuple """ # # # # # # Tobii method if self.eventdetection == 'native': # print warning, since Tobii does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but Tobii does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False # get starting position (no blinks) newpos = self.sample() while not self.is_valid_sample(newpos): newpos = self.sample() # get starting time, position, intersampledistance, and velocity t0 = clock.get_time() prevpos = newpos[:] s = 0 v0 = 0 # get samples saccadic = False while not saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # check if distance is larger than precision error sx = newpos[0] - prevpos[0] sy = newpos[1] - prevpos[1] if (sx / self.pxdsttresh[0])**2 + (sy / self.pxdsttresh[1])**2 > self.weightdist: # weigthed distance: (sx/tx)**2 + (sy/ty)**2 > 1 means movement larger than RMS noise # calculate distance s = ((sx)**2 + (sy)**2)**0.5 # intersampledistance = speed in pixels/ms # calculate velocity v1 = s / (t1 - t0) # calculate acceleration a = (v1 - v0) / (t1 - t0) # acceleration in pixels/ms**2 # check if either velocity or acceleration are above threshold values if v1 > self.pxspdtresh or a > self.pxacctresh: saccadic = True spos = prevpos[:] stime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] if stoprec: self.stop_recording() return stime, spos def wait_for_saccade_end(self): """Returns ending time, starting and end position when a saccade is ended; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos, endpos -- endtime in milliseconds (from expbegintime); startpos and endpos are (x,y) gaze position tuples """ # # # # # # Tobii method if self.eventdetection == 'native': # print warning, since Tobii does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but Tobii does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # get starting position (no blinks) t0, spos = self.wait_for_saccade_start() # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False # get valid sample prevpos = self.sample() while not self.is_valid_sample(prevpos): prevpos = self.sample() # get starting time, intersample distance, and velocity t1 = clock.get_time() s = ((prevpos[0] - spos[0])**2 + (prevpos[1] - spos[1])**2)**0.5 # = intersample distance = speed in px/sample v0 = s / (t1 - t0) # run until velocity and acceleration go below threshold saccadic = True while saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # calculate distance s = ((newpos[0] - prevpos[0])**2 + (newpos[1] - prevpos[1])**2)**0.5 # = speed in pixels/sample # calculate velocity v1 = s / (t1 - t0) # calculate acceleration a = (v1 - v0) / (t1 - t0) # acceleration in pixels/sample**2 (actually is v1-v0 / t1-t0; but t1-t0 = 1 sample) # check if velocity and acceleration are below threshold if v1 < self.pxspdtresh and (a > -1 * self.pxacctresh and a < 0): saccadic = False epos = newpos[:] etime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] if stoprec: self.stop_recording() return etime, spos, epos def wait_for_blink_start(self): """Waits for a blink start and returns the blink starting time arguments None returns timestamp -- blink starting time in milliseconds, as measured from experiment begin time """ # # # # # # Tobii method if self.eventdetection == 'native': # print warning, since Tobii does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but Tobii does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False blinking = False # loop until there is a blink while not blinking: # get newest sample gazepos = self.sample() # check if it's a valid sample if self.is_valid_sample(gazepos): # get timestamp for possible blink start t0 = clock.get_time() # loop until a blink is determined, or a valid sample occurs while not self.is_valid_sample(self.sample()): # check if time has surpassed BLINKTHRESH if clock.get_time() - t0 >= self.blinkthresh: if stoprec: self.stop_recording() # return timestamp of blink start return t0 def wait_for_blink_end(self): """Waits for a blink end and returns the blink ending time arguments None returns timestamp -- blink ending time in milliseconds, as measured from experiment begin time """ # # # # # # Tobii method if self.eventdetection == 'native': # print warning, since Tobii does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but Tobii does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method # start recording if recording has not yet started if not self.recording: self.start_recording() stoprec = True else: stoprec = False blinking = True # loop while there is a blink while blinking: # get newest sample gazepos = self.sample() # check if it's valid if self.is_valid_sample(gazepos): # if it is a valid sample, blinking has stopped blinking = False if stoprec: self.stop_recording() # return timestamp of blink end return clock.get_time() def log(self, msg): """Writes a message to the log file arguments msg -- a string to include in the log file returns Nothing -- uses native log function to include a line in the log file """ t = tr.get_system_time_stamp() if not self.t0: self.t0 = t self._write_header() self.datafile.write('%.4f\t%s\n' % ((t - self.t0) / 1000.0, msg)) self._flush_to_file() def _flush_to_file(self): # write data to disk self.datafile.flush() # internal buffer to RAM os.fsync(self.datafile.fileno()) # RAM file cache to disk def _write_header(self): # write header self.datafile.write('\t'.join(['TimeStamp', 'Event', 'GazePointXLeft', 'GazePointYLeft', 'ValidityLeft', 'GazePointXRight', 'GazePointYRight', 'ValidityRight', 'GazePointX', 'GazePointY', 'PupilSizeLeft', 'PupilValidityLeft', 'PupilSizeRight', 'PupilValidityRight']) + '\n') self._flush_to_file() def _write_sample(self, sample): # write timestamp and gaze position for both eyes to the datafile left_gaze_point = self._norm_2_px(sample['left_gaze_point_on_display_area']) if sample['left_gaze_point_validity'] else (-1, -1) right_gaze_point = self._norm_2_px(sample['right_gaze_point_on_display_area']) if sample['right_gaze_point_validity'] else (-1, -1) self.datafile.write('%.4f\t\t%d\t%d\t%d\t%d\t%d\t%d' % ( (sample['system_time_stamp'] - self.t0) / 1000.0, left_gaze_point[0], left_gaze_point[1], sample['left_gaze_point_validity'], right_gaze_point[0], right_gaze_point[1], sample['right_gaze_point_validity'])) # if no correct sample is available, data is missing if not (sample['left_gaze_point_validity'] or sample['right_gaze_point_validity']): # not detected ave = (-1.0, -1.0) # if the right sample is unavailable, use left sample elif not sample['right_gaze_point_validity']: ave = left_gaze_point # if the left sample is unavailable, use right sample elif not sample['left_gaze_point_validity']: ave = right_gaze_point # if we have both samples, use both samples else: ave = (int(round((left_gaze_point[0] + right_gaze_point[0]) / 2.0, 0)), (int(round(left_gaze_point[1] + right_gaze_point[1]) / 2.0))) # write gaze position to the datafile, based on the selected sample(s) self.datafile.write('\t%d\t%d' % ave) left_pupil = sample['left_pupil_diameter'] if sample['left_pupil_validity'] else -1 right_pupil = sample['right_pupil_diameter'] if sample['right_pupil_validity'] else -1 self.datafile.write('\t%.4f\t%d\t%.4f\t%d' % (left_pupil, sample['left_pupil_validity'], right_pupil, sample['right_pupil_validity'])) self.datafile.write('\n') self._flush_to_file() def close(self): """Closes the currently used log file. arguments None returns None -- closes the log file. """ self.datafile.close()
class OpenGazeTracker(BaseEyeTracker): """A class for OpenGazeTracker objects""" def __init__(self, display, logfile=settings.LOGFILE, \ eventdetection=settings.EVENTDETECTION, \ saccade_velocity_threshold=35, \ saccade_acceleration_threshold=9500, \ blink_threshold=settings.BLINKTHRESH, \ **args): """Initializes the OpenGazeTracker object arguments display -- a pygaze.display.Display instance keyword arguments logfile -- logfile name (string value); note that this is the name for the eye data log file (default = LOGFILE) """ # try to copy docstrings (but ignore it if it fails, as we do # not need it for actual functioning of the code) try: copy_docstr(BaseEyeTracker, OpenGazeTracker) except: # we're not even going to show a warning, since the copied # docstring is useful for code editors; these load the docs # in a non-verbose manner, so warning messages would be lost pass # object properties self.disp = display self.screen = Screen() self.dispsize = settings.DISPSIZE # display size in pixels self.screensize = settings.SCREENSIZE # display size in cm self.kb = Keyboard(keylist=['space', 'escape', 'q'], timeout=1) self.errorbeep = Sound(osc='saw', freq=100, length=100) # show a message self.screen.clear() self.screen.draw_text( text="Initialising the eye tracker, please wait...", fontsize=20) self.disp.fill(self.screen) self.disp.show() # output file properties self.outputfile = logfile + '.tsv' self.extralogname = logfile + '_log.txt' self.extralogfile = open(self.extralogname, 'w') # eye tracker properties self.has_been_calibrated_before = False self.connected = False self.recording = False self.errdist = 2 # degrees; maximal error for drift correction self.pxerrdist = 30 # initial error in pixels self.maxtries = 100 # number of samples obtained before giving up (for obtaining accuracy and tracker distance information, as well as starting or stopping recording) self.prevsample = (-1,-1) self.prevps = -1 # event detection properties self.fixtresh = 1.5 # degrees; maximal distance from fixation start (if gaze wanders beyond this, fixation has stopped) self.fixtimetresh = 100 # milliseconds; amount of time gaze has to linger within self.fixtresh to be marked as a fixation self.spdtresh = saccade_velocity_threshold # degrees per second; saccade velocity threshold self.accthresh = saccade_acceleration_threshold # degrees per second**2; saccade acceleration threshold self.blinkthresh = blink_threshold # milliseconds; blink detection threshold used in PyGaze method self.eventdetection = eventdetection self.set_detection_type(self.eventdetection) self.weightdist = 10 # weighted distance, used for determining whether a movement is due to measurement error (1 is ok, higher is more conservative and will result in only larger saccades to be detected) # connect to the tracker self.opengaze = OpenGaze(ip='127.0.0.1', port=4242, \ logfile=self.outputfile, debug=False) # get info on the sample rate # TODO: Compute after streaming some samples? self.samplerate = 60.0 self.sampletime = 1000.0 / self.samplerate # initiation report self._elog("pygaze initiation report start") self._elog("display resolution: %sx%s" % (self.dispsize[0], self.dispsize[1])) self._elog("display size in cm: %sx%s" % (self.screensize[0], self.screensize[1])) self._elog("samplerate: %.2f Hz" % self.samplerate) self._elog("sampletime: %.2f ms" % self.sampletime) self._elog("fixation threshold: %s degrees" % self.fixtresh) self._elog("speed threshold: %s degrees/second" % self.spdtresh) self._elog("acceleration threshold: %s degrees/second**2" % self.accthresh) self._elog("pygaze initiation report end") def _elog(self, msg): """Logs a message to the additional log. """ self.extralogfile.write(msg + '\n') def calibrate(self): """Calibrates the eye tracking system arguments None keyword arguments None returns success -- returns True if calibration succeeded, or False if not; in addition a calibration log is added to the log file and some properties are updated (i.e. the thresholds for detection algorithms) """ # show a message self.screen.clear() self.screen.draw_text( text="Preparing the calibration, please wait...", fontsize=20) self.disp.fill(self.screen) self.disp.show() # CALIBRATION # Set the duration of the calibration animation, and of the # calibration point. caldur = {'animation':1.5, 'point':1.0, 'timeout':10.0} self.opengaze.calibrate_delay(caldur['animation']) self.opengaze.calibrate_timeout(caldur['point']) # Determine the calibration points. #calibpoints = [(0.1,0.5), (0.5, 0.1), (0.5, 0.5), (0.5, 0.9), (0.9, 0.5)] calibpoints = [] for x in [0.1, 0.5, 0.9]: for y in [0.1, 0.5, 0.9]: calibpoints.append((x,y)) random.shuffle(calibpoints) # Clear the OpenGaze calibration. self.opengaze.calibrate_clear() # Add all new points (as proportions of the display resolution). for x, y in calibpoints: self.opengaze.calibrate_addpoint(x, y) # show a message self.screen.clear() self.screen.draw_text( text="Press Space to calibrate, S to skip, and Q to quit", fontsize=20) self.disp.fill(self.screen) self.disp.show() # wait for keyboard input key, keytime = self.kb.get_key(keylist=['q', 's', 'space'], timeout=None, flush=True) if key == 's': return True if key == 'q': quited = True else: quited = False # Run until the user is statisfied, or quits. calibrated = False while not quited and not calibrated: # CALIBRATE # Run a new calibration. The result is the latest available # calibration results as a list of dicts, each with the # following keys: # CALX: Calibration point's horizontal coordinate. # CALY: Calibration point's vertical coordinate # LX: Left eye's recorded horizontal point of gaze. # LY: Left eye's recorded vertical point of gaze. # LV: Left eye's validity status (1=valid, 0=invalid) # RX: Right eye's recorded horizontal point of gaze. # RY: Right eye's recorded vertical point of gaze. # RV: Right eye's validity status (1=valid, 0=invalid) # Clear the existing calibration results. self.opengaze.clear_calibration_result() # Make sure we have the right calibration points. # NOTE: Somehow polling this results in no weird OpenGaze errors # on calibrations that occur after the first one. # (WTF, Gazepoint?!) calibpoints = self.opengaze.get_calibration_points() # Show the calibration screen. # NOTE: THIS DOESN'T WORK IN FULL SCREEN MODE :( #self.opengaze.calibrate_show(True) # Start the calibration. self.opengaze.calibrate_start(True) # Show the calibration dots. The strategy is to wait for the # next calibration point start, then to show that dot, and # then to show the animation (hoping to Godzilla that the # timing roughly matches that of the OpenGaze server), and # then to keep the target on-screen until the start of the # next calibration point. pointnr = 0 n_points = len(calibpoints) # On a restart, the calibration starts with the last point, # before looping through all the other points. (DAMN YOU, # GAZEPOINT, THAT DOES NOT MAKE SENSE!) if self.has_been_calibrated_before: n_points += 1 # Loop through all the points. for i in range(n_points): # Wait for the next calibration point. pointnr, pos = self.opengaze.wait_for_calibration_point_start( \ timeout=caldur['timeout']) # The wait_for_calibration_point_start function returns # None if no point was started before a timeout. We # should panic if no calibration point was started. if pointnr is None: # Break the calibration loop, and quit the current # calibration. quited = True break # Compute the point in display coordinates. x = int(pos[0] * self.dispsize[0]) y = int(pos[1] * self.dispsize[1]) # Get a timestamp for the start of the animation. t1 = clock.get_time() t = clock.get_time() # Show the animation. while t - t1 < caldur['animation']*1000: # Check if the Q key has been pressed, and break # if it has. if self.kb.get_key(keylist=['q'], timeout=10, \ flush=False)[0] == 'q': quited = True break # Clear the screen. self.screen.clear(colour=(0,0,0)) # Caculate at which point in the animation we are. p = 1.0 - float(t-t1) / (caldur['animation']*1000) # Draw the animated disk. self.screen.draw_circle(colour=(255,255,255), \ pos=(x, y), r=max(1, int(30*p)), fill=True) # Draw the calibration target. self.screen.draw_circle(colour=(255,0,0), \ pos=(x, y), r=3, fill=True) # Show the screen. self.disp.fill(self.screen) t = self.disp.show() # Check if the Q key has been pressed, and break # if it has. if self.kb.get_key(keylist=['q'], timeout=1, \ flush=False)[0] == 'q': quited = True # Don't show the other points if Q was pressed. if quited: break # Wait for the calibration result. calibresult = None while (calibresult is None) and (not quited): # Check if there is a result yet (returns None if there # isn't). calibresult = self.opengaze.get_calibration_result() # Check if the Q key has been pressed, and break if it # is. if self.kb.get_key(keylist=['q'], timeout=100, \ flush=False)[0] == 'q': quited = True break # Hide the calibration window. # NOTE: No need for this in full-screen mode. #self.opengaze.calibrate_show(False) # Retry option if the calibration was aborted if quited: # show retry message self.screen.clear() self.screen.draw_text( \ text="Calibration aborted. Press Space to restart or 'Q' to quit", \ fontsize=20) self.disp.fill(self.screen) self.disp.show() # get input key, keytime = self.kb.get_key(keylist=['q','space'], \ timeout=None, flush=True) if key == 'space': # unset quited Boolean quited = False # skip further processing continue # Empty display. self.disp.fill() self.disp.show() # RESULTS # Clear the screen. self.screen.clear() # draw results for each point if calibresult is not None: # Loop through all points. for p in calibresult: # Convert the points (relative coordinates) to # display coordinates. for param in ['CALX', 'LX', 'RX']: p[param] *= self.dispsize[0] for param in ['CALY', 'LY', 'RY']: p[param] *= self.dispsize[1] # Draw the target. self.screen.draw_fixation(fixtype='dot', colour=(115,210,22), \ pos=(p['CALX'], p['CALY'])) # If the calibration for this target is valid, # draw the estimated point. We have two points: # one for left and one for right. col = {'L':(32,74,135), 'R':(92,53,102)} for eye in ['L', 'R']: # Check if the eye is valid, and choose the # position and colour accordingly. if p['%sV' % (eye)]: x = p['%sX' % (eye)] y = p['%sY' % (eye)] c = col[eye] else: x = p['CALX'] y = p['CALY'] c = (204,0,0) # Draw a line between the estimated and the # actual point. if p['%sV' % (eye)]: self.screen.draw_line(colour=c, \ spos=(p['CALX'], p['CALY']), \ epos=(x,y), \ pw=3) # Draw the estimated gaze point. self.screen.draw_fixation( \ fixtype='dot', pos=(x, y), colour=c) # Annotate which eye this is. self.screen.draw_text(text=eye, \ pos=(x+10, y+10), colour=c, \ fontsize=20) # Draw input options. self.screen.draw_text( text="Press Space to continue or 'R' to restart", pos=(int(self.dispsize[0]*0.5), \ int(self.dispsize[1]*0.25+60)), \ fontsize=20) else: self.screen.draw_text( text="Calibration failed. Press 'R' to try again.", fontsize=20) # Show the results. self.disp.fill(self.screen) self.disp.show() # Wait for input. key, keytime = self.kb.get_key(keylist=['space','r'], \ timeout=None, flush=True) # Process input. if key == 'space': calibrated = True # Set the 'restart' flag to True, because everything that # happens after this will be a repeated calibration or # will have noting to do with the calibration. self.has_been_calibrated_before = True # Calibration failed if the user quited. if quited: return False # NOISE CALIBRATION # Get all error estimates (distance between the real and the # estimated points in pixels). err = {'LX':[], 'LY':[], 'RX':[], 'RY':[]} var = {'LX':[], 'LY':[], 'RX':[], 'RY':[]} for p in calibresult: # Only use the point if it was valid. for eye in ['L', 'R']: for dim in ['X', 'Y']: if p['%sV' % (eye)]: # Compute the distance between the points. d = p['%s%s' % (eye, dim)] - \ p['CAL%s' % (dim)] # Store the distance. err['%s%s' % (eye, dim)].append(abs(d)) # Store the squared distance. var['%s%s' % (eye, dim)].append(d**2) # Compute the RMS noise for the calibration points. xnoise = (math.sqrt(sum(var['LX']) / float(len(var['LX']))) + \ math.sqrt(sum(var['RX']) / float(len(var['RX'])))) / 2.0 ynoise = (math.sqrt(sum(var['LY']) / float(len(var['LY']))) + \ math.sqrt(sum(var['RY']) / float(len(var['RY'])))) / 2.0 self.pxdsttresh = (xnoise, ynoise) # AFTERMATH # store some variables pixpercm = (self.dispsize[0] / float(self.screensize[0]) + \ self.dispsize[1]/float(self.screensize[1])) / 2 screendist = settings.SCREENDIST # calculate thresholds based on tracker settings self.accuracy = ( \ (pix2deg(screendist, sum(err['LX']) / float(len(err['LX'])), pixpercm), \ pix2deg(screendist, sum(err['LY']) / float(len(err['LY'])), pixpercm)), \ (pix2deg(screendist, sum(err['RX']) / float(len(err['RX'])), pixpercm), \ pix2deg(screendist, sum(err['RY']) / float(len(err['RY'])), pixpercm))) self.pxerrdist = deg2pix(screendist, self.errdist, pixpercm) self.pxfixtresh = deg2pix(screendist, self.fixtresh, pixpercm) self.pxaccuracy = ( \ (sum(err['LX']) / float(len(err['LX'])), \ sum(err['LY']) / float(len(err['LY']))), \ (sum(err['RX']) / float(len(err['RX'])), \ sum(err['RY']) / float(len(err['RY'])))) self.pxspdtresh = deg2pix(screendist, self.spdtresh/1000.0, pixpercm) # in pixels per millisecond self.pxacctresh = deg2pix(screendist, self.accthresh/1000.0, pixpercm) # in pixels per millisecond**2 # calibration report self._elog("pygaze calibration report start") self._elog("accuracy (degrees): LX=%s, LY=%s, RX=%s, RY=%s" % (self.accuracy[0][0],self.accuracy[0][1],self.accuracy[1][0],self.accuracy[1][1])) self._elog("accuracy (in pixels): LX=%s, LY=%s, RX=%s, RY=%s" % (self.pxaccuracy[0][0],self.pxaccuracy[0][1],self.pxaccuracy[1][0],self.pxaccuracy[1][1])) self._elog("precision (RMS noise in pixels): X=%s, Y=%s" % (self.pxdsttresh[0],self.pxdsttresh[1])) self._elog("distance between participant and display: %s cm" % screendist) self._elog("fixation threshold: %s pixels" % self.pxfixtresh) self._elog("speed threshold: %s pixels/ms" % self.pxspdtresh) self._elog("acceleration threshold: %s pixels/ms**2" % self.pxacctresh) self._elog("pygaze calibration report end") return True def close(self): """Neatly close connection to tracker arguments None returns Nothing -- saves data and sets self.connected to False """ # Close additional log file. self.extralogfile.close() # close connection self.opengaze.close() self.connected = False def connected(self): """Checks if the tracker is connected arguments None returns connected -- True if connection is established, False if not; sets self.connected to the same value """ self.connected = self.opengaze._connected.is_set() return self.connected def drift_correction(self, pos=None, fix_triggered=False): """Performs a drift check arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) fix_triggered -- Boolean indicating if drift check should be performed based on gaze position (fix_triggered = True) or on spacepress (fix_triggered = False) (default = False) returns checked -- Boolaan indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ if pos == None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 if fix_triggered: return self.fix_triggered_drift_correction(pos) # DEBUG # print(("Running drift correction, pos=(%d, %d)" % (pos[0], pos[1]))) # # # # # self.draw_drift_correction_target(pos[0], pos[1]) pressed = False while not pressed: pressed, presstime = self.kb.get_key() if pressed: if pressed == 'escape' or pressed == 'q': print("libopengaze.OpenGazeTracker.drift_correction: 'q' or 'escape' pressed") return self.calibrate() gazepos = self.sample() if ((gazepos[0]-pos[0])**2 + (gazepos[1]-pos[1])**2)**0.5 < self.pxerrdist: return True else: self.errorbeep.play() return False def draw_drift_correction_target(self, x, y): """ Draws the drift-correction target. arguments x -- The X coordinate y -- The Y coordinate """ self.screen.clear() self.screen.draw_fixation(fixtype='dot', colour=settings.FGC, pos=(x,y), pw=0, diameter=12) self.disp.fill(self.screen) self.disp.show() def draw_calibration_target(self, x, y): self.draw_drift_correction_target(x, y) def fix_triggered_drift_correction(self, pos=None, min_samples=4, max_dev=120, timeout=10000): """Performs a fixation triggered drift correction by collecting a number of samples and calculating the average distance from the fixation position arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) min_samples -- minimal amount of samples after which a fixation is accepted (default = 4) max_dev -- maximal deviation from fixation in pixels (default = 120) timeout -- Time in milliseconds until fixation-triggering is given up on, and calibration is started (default = 10000) returns checked -- Boolean indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ if pos == None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 self.draw_drift_correction_target(pos[0], pos[1]) t0 = clock.get_time() consecutive_count = 0 while consecutive_count < min_samples: # Get new sample. x, y = self.sample() # Ignore empty samples. if (x is None) or (y is None): continue # Measure the distance to the target position. d = ((x-pos[0])**2 + (y-pos[1])**2)**0.5 # Check whether the distance is below the allowed distance. if d <= max_dev: # Increment count. consecutive_count += 1 else: # Reset count. consecutive_count = 0 # Check for a timeout. if clock.get_time() - t0 > timeout: print("libopengaze.OpenGazeTracker.fix_triggered_drift_correction: timeout during fixation-triggered drift check") return self.calibrate() # Pressing escape enters the calibration screen. if self.kb.get_key()[0] in ['escape','q']: print("libopengaze.OpenGazeTracker.fix_triggered_drift_correction: 'q' or 'escape' pressed") return self.calibrate() return True def get_eyetracker_clock_async(self): """Not supported for OpenGazeTracker (yet)""" print("function not supported yet") def log(self, msg): """Writes a message to the log file arguments ms -- a string to include in the log file returns Nothing -- uses native log function of iViewX to include a line in the log file """ self._elog(msg) if self.recording: self.opengaze.log(msg) def prepare_drift_correction(self, pos): """Not supported for OpenGazeTracker (yet)""" print("function not supported yet") def pupil_size(self): """Return pupil size arguments None returns pupil size -- returns pupil diameter for the eye that is currently being tracked (as specified by self.eye_used) or -1 when no data is obtainable """ # get newest pupil size ps = self.opengaze.pupil_size() # invalid data if ps == None: return -1 # check if the new pupil size is the same as the previous if ps != self.prevps: # update the pupil size self.prevps = copy.copy(ps) return self.prevps def sample(self): """Returns newest available gaze position arguments None returns sample -- an (x,y) tuple or a (-1,-1) on an error """ # Get newest sample. rs = self.opengaze.sample() # Invalid data. if rs == (None, None): return (-1,-1) # Convert relative coordinates to display coordinates. s = (rs[0]*self.dispsize[0], rs[1]*self.dispsize[1]) # Check if the new sample is the same as the previous. if s != self.prevsample: # Update the current sample. self.prevsample = copy.copy(s) return self.prevsample def send_command(self, cmd): """Function not supported. Use self.opengaze instead; it supports all possible API calls. """ print("send_command function not supported; use self.opengaze instead") def start_recording(self): """Starts recording eye position arguments None returns Nothing -- sets self.recording to True when recording is successfully started """ self.opengaze.start_recording() self.recording = True def status_msg(self, msg): """Not supported for OpenGazeTracker (yet)""" print("function not supported yet") def stop_recording(self): """Stop recording eye position arguments None returns Nothing -- sets self.recording to False when recording is successfully started """ self.opengaze.stop_recording() self.recording = False def set_detection_type(self, eventdetection): """Set the event detection type to either PyGaze algorithms, or native algorithms as provided by the manufacturer (only if available: detection type will default to PyGaze if no native functions are available) arguments eventdetection -- a string indicating which detection type should be employed: either 'pygaze' for PyGaze event detection algorithms or 'native' for manufacturers algorithms (only if available; will default to 'pygaze' if no native event detection is available) returns -- detection type for saccades, fixations and blinks in a tuple, e.g. ('pygaze','native','native') when 'native' was passed, but native detection was not available for saccade detection """ if eventdetection in ['pygaze','native']: self.eventdetection = eventdetection return ('pygaze','pygaze','pygaze') def wait_for_event(self, event): """Waits for event arguments event -- an integer event code, one of the following: 3 = STARTBLINK 4 = ENDBLINK 5 = STARTSACC 6 = ENDSACC 7 = STARTFIX 8 = ENDFIX returns outcome -- a self.wait_for_* method is called, depending on the specified event; the return values of corresponding method are returned """ if event == 5: outcome = self.wait_for_saccade_start() elif event == 6: outcome = self.wait_for_saccade_end() elif event == 7: outcome = self.wait_for_fixation_start() elif event == 8: outcome = self.wait_for_fixation_end() elif event == 3: outcome = self.wait_for_blink_start() elif event == 4: outcome = self.wait_for_blink_end() else: raise Exception("Error in libopengaze.OpenGazeTracker.wait_for_event: eventcode %s is not supported" % event) return outcome def wait_for_blink_end(self): """Waits for a blink end and returns the blink ending time arguments None returns timestamp -- blink ending time in milliseconds, as measured from experiment begin time """ # # # # # # OpenGaze method if self.eventdetection == 'native': # print warning, since OpenGaze does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but OpenGaze does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method blinking = True # loop while there is a blink while blinking: # get newest sample gazepos = self.sample() # check if it's valid if self.is_valid_sample(gazepos): # if it is a valid sample, blinking has stopped blinking = False # return timestamp of blink end return clock.get_time() def wait_for_blink_start(self): """Waits for a blink start and returns the blink starting time arguments None returns timestamp -- blink starting time in milliseconds, as measured from experiment begin time """ # # # # # # OpenGaze method if self.eventdetection == 'native': # print warning, since OpenGaze does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but OpenGaze does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method blinking = False # loop until there is a blink while not blinking: # get newest sample gazepos = self.sample() # check if it's a valid sample if not self.is_valid_sample(gazepos): # get timestamp for possible blink start t0 = clock.get_time() # loop until a blink is determined, or a valid sample occurs while not self.is_valid_sample(self.sample()): # check if time has surpassed BLINKTHRESH if clock.get_time()-t0 >= self.blinkthresh: # return timestamp of blink start return t0 def wait_for_fixation_end(self): """Returns time and gaze position when a fixation has ended; function assumes that a 'fixation' has ended when a deviation of more than self.pxfixtresh from the initial fixation position has been detected (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # OpenGaze method if self.eventdetection == 'native': # print warning, since OpenGaze does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but OpenGaze does not offer fixation detection; \ PyGaze algorithm will be used") # # # # # # PyGaze method # function assumes that a 'fixation' has ended when a deviation of more than fixtresh # from the initial 'fixation' position has been detected # get starting time and position stime, spos = self.wait_for_fixation_start() # loop until fixation has ended while True: # get new sample npos = self.sample() # get newest sample # check if sample is valid if self.is_valid_sample(npos): # check if sample deviates to much from starting position if (npos[0]-spos[0])**2 + (npos[1]-spos[1])**2 > self.pxfixtresh**2: # Pythagoras # break loop if deviation is too high break return clock.get_time(), spos def wait_for_fixation_start(self): """Returns starting time and position when a fixation is started; function assumes a 'fixation' has started when gaze position remains reasonably stable (i.e. when most deviant samples are within self.pxfixtresh) for five samples in a row (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # OpenGaze method if self.eventdetection == 'native': # print warning, since OpenGaze does not have a fixation start # detection built into their API (only ending) print("WARNING! 'native' event detection has been selected, \ but OpenGaze does not offer fixation detection; \ PyGaze algorithm will be used") # # # # # # PyGaze method # function assumes a 'fixation' has started when gaze position # remains reasonably stable for self.fixtimetresh # get starting position spos = self.sample() while not self.is_valid_sample(spos): spos = self.sample() # get starting time t0 = clock.get_time() # wait for reasonably stable position moving = True while moving: # get new sample npos = self.sample() # check if sample is valid if self.is_valid_sample(npos): # check if new sample is too far from starting position if (npos[0]-spos[0])**2 + (npos[1]-spos[1])**2 > self.pxfixtresh**2: # Pythagoras # if not, reset starting position and time spos = copy.copy(npos) t0 = clock.get_time() # if new sample is close to starting sample else: # get timestamp t1 = clock.get_time() # check if fixation time threshold has been surpassed if t1 - t0 >= self.fixtimetresh: # return time and starting position return t1, spos def wait_for_saccade_end(self): """Returns ending time, starting and end position when a saccade is ended; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos, endpos -- endtime in milliseconds (from expbegintime); startpos and endpos are (x,y) gaze position tuples """ # # # # # # OpenGaze method if self.eventdetection == 'native': # print warning, since OpenGaze does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but OpenGaze does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # get starting position (no blinks) t0, spos = self.wait_for_saccade_start() # get valid sample prevpos = self.sample() while not self.is_valid_sample(prevpos): prevpos = self.sample() # get starting time, intersample distance, and velocity t1 = clock.get_time() s = ((prevpos[0]-spos[0])**2 + (prevpos[1]-spos[1])**2)**0.5 # = intersample distance = speed in px/sample v0 = s / (t1-t0) # run until velocity and acceleration go below threshold saccadic = True while saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # calculate distance s = ((newpos[0]-prevpos[0])**2 + (newpos[1]-prevpos[1])**2)**0.5 # = speed in pixels/sample # calculate velocity v1 = s / (t1-t0) # calculate acceleration a = (v1-v0) / (t1-t0) # acceleration in pixels/sample**2 (actually is v1-v0 / t1-t0; but t1-t0 = 1 sample) # check if velocity and acceleration are below threshold if v1 < self.pxspdtresh and (a > -1*self.pxacctresh and a < 0): saccadic = False epos = newpos[:] etime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] return etime, spos, epos def wait_for_saccade_start(self): """Returns starting time and starting position when a saccade is started; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos -- endtime in milliseconds (from expbegintime); startpos is an (x,y) gaze position tuple """ # # # # # # OpenGaze method if self.eventdetection == 'native': # print warning, since OpenGaze does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but OpenGaze does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # get starting position (no blinks) newpos = self.sample() while not self.is_valid_sample(newpos): newpos = self.sample() # get starting time, position, intersampledistance, and velocity t0 = clock.get_time() prevpos = newpos[:] s = 0 v0 = 0 # get samples saccadic = False while not saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # check if distance is larger than precision error sx = newpos[0]-prevpos[0]; sy = newpos[1]-prevpos[1] if (sx/self.pxdsttresh[0])**2 + (sy/self.pxdsttresh[1])**2 > self.weightdist: # weigthed distance: (sx/tx)**2 + (sy/ty)**2 > 1 means movement larger than RMS noise # calculate distance s = ((sx)**2 + (sy)**2)**0.5 # intersampledistance = speed in pixels/ms # calculate velocity v1 = s / (t1-t0) # calculate acceleration a = (v1-v0) / (t1-t0) # acceleration in pixels/ms**2 # check if either velocity or acceleration are above threshold values if v1 > self.pxspdtresh or a > self.pxacctresh: saccadic = True spos = prevpos[:] stime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] return stime, spos def is_valid_sample(self, gazepos): """Checks if the sample provided is valid, based on OpenGaze specific criteria (for internal use) arguments gazepos -- a (x,y) gaze position tuple, as returned by self.sample() returns valid -- a Boolean: True on a valid sample, False on an invalid sample """ # return False if a sample is invalid if gazepos == (None,None) or gazepos == (-1,-1): return False # in any other case, the sample is valid return True
tracker.calibrate() # starting screen scr.clear() scr.draw_text(text="Press Space to start") disp.fill(scr) disp.show() kb.get_key(keylist=['space'], timeout=None, flush=True) # # # # # # VALIDATION horizontal scr.clear() scr.draw_line((0,0,0), (int(0.05*DISPSIZE[0]),int(0.15*DISPSIZE[1])), (int(0.95*DISPSIZE[0]),int(0.15*DISPSIZE[1])), 1) scr.draw_line((0,0,0), (int(0.95*DISPSIZE[0]),int(0.15*DISPSIZE[1])), (int(0.95*DISPSIZE[0]),int(0.5*DISPSIZE[1])), 1) scr.draw_line((0,0,0), (int(0.95*DISPSIZE[0]),int(0.5*DISPSIZE[1])), (int(0.05*DISPSIZE[0]),int(0.5*DISPSIZE[1])), 1) scr.draw_line((0,0,0), (int(0.05*DISPSIZE[0]),int(0.5*DISPSIZE[1])), (int(0.05*DISPSIZE[0]),int(0.85*DISPSIZE[1])), 1) scr.draw_line((0,0,0), (int(0.05*DISPSIZE[0]),int(0.85*DISPSIZE[1])), (int(0.95*DISPSIZE[0]),int(0.85*DISPSIZE[1])), 1) # loop through points for i in range(len(CALIBPOINTShor)): # get coordinate x, y = CALIBPOINTShor[i] # draw calibration point scr.draw_fixation(fixtype='cross', pos=(x,y)) disp.fill(scr) disp.show()
class EyeTribeTracker(BaseEyeTracker): """A class for EyeTribeTracker objects""" def __init__(self, display, logfile=settings.LOGFILE, eventdetection=settings.EVENTDETECTION, saccade_velocity_threshold=35, saccade_acceleration_threshold=9500, blink_threshold=settings.BLINKTHRESH, **args): """Initializes the EyeTribeTracker object arguments display -- a pygaze.display.Display instance keyword arguments logfile -- logfile name (string value); note that this is the name for the eye data log file (default = LOGFILE) """ # try to copy docstrings (but ignore it if it fails, as we do # not need it for actual functioning of the code) try: copy_docstr(BaseEyeTracker, EyeTribeTracker) except: # we're not even going to show a warning, since the copied # docstring is useful for code editors; these load the docs # in a non-verbose manner, so warning messages would be lost pass # object properties self.disp = display self.screen = Screen() self.dispsize = settings.DISPSIZE # display size in pixels self.screensize = settings.SCREENSIZE # display size in cm self.kb = Keyboard(keylist=['space', 'escape', 'q'], timeout=1) self.errorbeep = Sound(osc='saw',freq=100, length=100) # output file properties self.outputfile = logfile # eye tracker properties self.connected = False self.recording = False self.errdist = 2 # degrees; maximal error for drift correction self.pxerrdist = 30 # initial error in pixels self.maxtries = 100 # number of samples obtained before giving up (for obtaining accuracy and tracker distance information, as well as starting or stopping recording) self.prevsample = (-1,-1) self.prevps = -1 # event detection properties self.fixtresh = 1.5 # degrees; maximal distance from fixation start (if gaze wanders beyond this, fixation has stopped) self.fixtimetresh = 100 # milliseconds; amount of time gaze has to linger within self.fixtresh to be marked as a fixation self.spdtresh = saccade_velocity_threshold # degrees per second; saccade velocity threshold self.accthresh = saccade_acceleration_threshold # degrees per second**2; saccade acceleration threshold self.blinkthresh = blink_threshold # milliseconds; blink detection threshold used in PyGaze method self.eventdetection = eventdetection self.set_detection_type(self.eventdetection) self.weightdist = 10 # weighted distance, used for determining whether a movement is due to measurement error (1 is ok, higher is more conservative and will result in only larger saccades to be detected) # connect to the tracker self.eyetribe = EyeTribe(logfilename=logfile) # get info on the sample rate self.samplerate = self.eyetribe._samplefreq self.sampletime = 1000.0 * self.eyetribe._intsampletime # initiation report self.log("pygaze initiation report start") self.log("display resolution: %sx%s" % (self.dispsize[0],self.dispsize[1])) self.log("display size in cm: %sx%s" % (self.screensize[0],self.screensize[1])) self.log("samplerate: %.2f Hz" % self.samplerate) self.log("sampletime: %.2f ms" % self.sampletime) self.log("fixation threshold: %s degrees" % self.fixtresh) self.log("speed threshold: %s degrees/second" % self.spdtresh) self.log("acceleration threshold: %s degrees/second**2" % self.accthresh) self.log("pygaze initiation report end") def calibrate(self): """Calibrates the eye tracking system arguments None keyword arguments None returns success -- returns True if calibration succeeded, or False if not; in addition a calibration log is added to the log file and some properties are updated (i.e. the thresholds for detection algorithms) """ # CALIBRATION # determine the calibration points calibpoints = [] for x in [0.1,0.5,0.9]: for y in [0.1,0.5,0.9]: calibpoints.append((int(x*self.dispsize[0]),int(y*self.dispsize[1]))) random.shuffle(calibpoints) # show a message self.screen.clear() self.screen.draw_text( text="Press Space to calibrate, S to skip, and Q to quit", fontsize=20) self.disp.fill(self.screen) self.disp.show() # wait for keyboard input key, keytime = self.kb.get_key(keylist=['q', 's', 'space'], timeout=None, flush=True) if key == 's': return True if key == 'q': quited = True else: quited = False # Pause the processing of samples during the calibration. # self.eyetribe._pause_sample_processing() # run until the user is statisfied, or quits calibrated = False calibresult = None while not quited and not calibrated: # Clear the existing calibration. if self.eyetribe._tracker.get_iscalibrated(): self.eyetribe._lock.acquire(True) self.eyetribe.calibration.clear() self.eyetribe._lock.release() # Wait for a bit. clock.pause(1500) # start a new calibration if not self.eyetribe._tracker.get_iscalibrating(): self.eyetribe._lock.acquire(True) self.eyetribe.calibration.start(pointcount=len(calibpoints)) self.eyetribe._lock.release() # loop through calibration points for cpos in calibpoints: # Check whether the calibration is already done. # (Not sure how or why, but for some reason some data # can persist between calbrations, and the tracker will # simply stop allowing further pointstart requests.) if self.eyetribe._tracker.get_iscalibrated(): break # Draw a calibration target. self.draw_calibration_target(cpos[0], cpos[1]) # wait for a bit to allow participant to start looking at # the calibration point (#TODO: space press?) clock.pause(settings.EYETRIBEPRECALIBDUR) # start calibration of point self.eyetribe._lock.acquire(True) self.eyetribe.calibration.pointstart(cpos[0],cpos[1]) self.eyetribe._lock.release() # wait for a second clock.pause(settings.EYETRIBECALIBDUR) # stop calibration of this point self.eyetribe._lock.acquire(True) self.eyetribe.calibration.pointend() self.eyetribe._lock.release() # check if the Q key has been pressed if self.kb.get_key(keylist=['q'],timeout=10,flush=False)[0] == 'q': # abort calibration self.eyetribe._lock.acquire(True) self.eyetribe.calibration.abort() self.eyetribe._lock.release() # set quited variable and break this for loop quited = True break # retry option if the calibration was aborted if quited: # show retry message self.screen.clear() self.screen.draw_text( "Calibration aborted. Press Space to restart or 'Q' to quit", fontsize=20) self.disp.fill(self.screen) self.disp.show() # get input key, keytime = self.kb.get_key(keylist=['q','space'], timeout=None, flush=True) if key == 'space': # unset quited Boolean quited = False # skip further processing continue # empty display self.disp.fill() self.disp.show() # allow for a bit of calculation time # (this is waaaaaay too much) clock.pause(1000) # get the calibration result self.eyetribe._lock.acquire(True) calibresult = self.eyetribe._tracker.get_calibresult() self.eyetribe._lock.release() # results # clear the screen self.screen.clear() # draw results for each point if type(calibresult) == dict: for p in calibresult['calibpoints']: # only draw the point if data was obtained if p['state'] > 0: # draw the mean error # self.screen.draw_circle(colour=(252,233,79), # pos=(p['cpx'],p['cpy']), r=p['mepix'], pw=0, # fill=True) self.screen.draw_line(spos=(p['cpx'],p['cpy']), epos=(p['mecpx'],p['mecpy']), pw=2) # draw the point self.screen.draw_fixation(fixtype='dot', colour=(115,210,22), pos=(p['cpx'],p['cpy'])) # draw the estimated point self.screen.draw_fixation(fixtype='dot', colour=(32,74,135), pos=(p['mecpx'],p['mecpy'])) # annotate accuracy self.screen.draw_text(text='%.2f' % p['acd'], pos=(p['cpx']+10,p['cpy']+10), fontsize=20) # if no data was obtained, draw the point in red else: self.screen.draw_fixation(fixtype='dot', colour=(204,0,0), pos=(p['cpx'],p['cpy'])) # draw box for averages # self.screen.draw_rect(colour=(238,238,236), x=int(self.dispsize[0]*0.15), y=int(self.dispsize[1]*0.2), w=400, h=200, pw=0, fill=True) # draw result if calibresult['result']: self.screen.draw_text(text="Calibration successful", colour='green', pos=(int(self.dispsize[0]*0.5), int(self.dispsize[1]*0.25)), fontsize=20) else: self.screen.draw_text(text="Calibration failed", colour='red', pos=(int(self.dispsize[0]*0.5),int(self.dispsize[1]*0.25)), fontsize=20) # draw average accuracy self.screen.draw_text( text="Average error = %.2f degrees" % (calibresult['deg']), pos=(int(self.dispsize[0]*0.5),int(self.dispsize[1]*0.25+30)), fontsize=20) # draw input options self.screen.draw_text( text="Press Space to continue or 'R' to restart", pos=(int(self.dispsize[0]*0.5),int(self.dispsize[1]*0.25+60)), fontsize=20) else: self.screen.draw_text( text="Calibration failed. Press 'R' to try again.", fontsize=20) # show the results self.disp.fill(self.screen) self.disp.show() # wait for input key, keytime = self.kb.get_key(keylist=['space','r'], timeout=None, flush=True) # process input if key == 'space': calibrated = True # Continue the processing of samples after the calibration. # self.eyetribe._unpause_sample_processing() # calibration failed if the user quited if quited: return False # NOISE CALIBRATION # get all error estimates (pixels) var = [] for p in calibresult['calibpoints']: # only draw the point if data was obtained if p['state'] > 0: var.append(p['mepix']) noise = sum(var) / float(len(var)) self.pxdsttresh = (noise, noise) # AFTERMATH # store some variables pixpercm = (self.dispsize[0]/float(self.screensize[0]) + self.dispsize[1]/float(self.screensize[1])) / 2 screendist = settings.SCREENDIST # calculate thresholds based on tracker settings self.accuracy = ((calibresult['Ldeg'],calibresult['Ldeg']), (calibresult['Rdeg'],calibresult['Rdeg'])) self.pxerrdist = deg2pix(screendist, self.errdist, pixpercm) self.pxfixtresh = deg2pix(screendist, self.fixtresh, pixpercm) self.pxaccuracy = ((deg2pix(screendist, self.accuracy[0][0], pixpercm),deg2pix(screendist, self.accuracy[0][1], pixpercm)), (deg2pix(screendist, self.accuracy[1][0], pixpercm),deg2pix(screendist, self.accuracy[1][1], pixpercm))) self.pxspdtresh = deg2pix(screendist, self.spdtresh/1000.0, pixpercm) # in pixels per millisecond self.pxacctresh = deg2pix(screendist, self.accthresh/1000.0, pixpercm) # in pixels per millisecond**2 # calibration report self.log("pygaze calibration report start") self.log("accuracy (degrees): LX=%s, LY=%s, RX=%s, RY=%s" % (self.accuracy[0][0],self.accuracy[0][1],self.accuracy[1][0],self.accuracy[1][1])) self.log("accuracy (in pixels): LX=%s, LY=%s, RX=%s, RY=%s" % (self.pxaccuracy[0][0],self.pxaccuracy[0][1],self.pxaccuracy[1][0],self.pxaccuracy[1][1])) self.log("precision (RMS noise in pixels): X=%s, Y=%s" % (self.pxdsttresh[0],self.pxdsttresh[1])) self.log("distance between participant and display: %s cm" % screendist) self.log("fixation threshold: %s pixels" % self.pxfixtresh) self.log("speed threshold: %s pixels/ms" % self.pxspdtresh) self.log("acceleration threshold: %s pixels/ms**2" % self.pxacctresh) self.log("pygaze calibration report end") return True def close(self): """Neatly close connection to tracker arguments None returns Nothing -- saves data and sets self.connected to False """ # close connection self.eyetribe.close() self.connected = False def connected(self): """Checks if the tracker is connected arguments None returns connected -- True if connection is established, False if not; sets self.connected to the same value """ res = self.eyetribe._tracker.get_trackerstate() if res == 0: self.connected = True else: self.connected = False return self.connected def drift_correction(self, pos=None, fix_triggered=False): """Performs a drift check arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) fix_triggered -- Boolean indicating if drift check should be performed based on gaze position (fix_triggered = True) or on spacepress (fix_triggered = False) (default = False) returns checked -- Boolaan indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ if pos == None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 if fix_triggered: return self.fix_triggered_drift_correction(pos) self.draw_drift_correction_target(pos[0], pos[1]) pressed = False while not pressed: pressed, presstime = self.kb.get_key() if pressed: if pressed == 'escape' or pressed == 'q': print("libeyetribe.EyeTribeTracker.drift_correction: 'q' or 'escape' pressed") return self.calibrate() gazepos = self.sample() if ((gazepos[0]-pos[0])**2 + (gazepos[1]-pos[1])**2)**0.5 < self.pxerrdist: return True else: self.errorbeep.play() return False def draw_drift_correction_target(self, x, y): """ Draws the drift-correction target. arguments x -- The X coordinate y -- The Y coordinate """ self.screen.clear() self.screen.draw_fixation(fixtype='dot', colour=settings.FGC, pos=(x,y), pw=0, diameter=12) self.disp.fill(self.screen) self.disp.show() def draw_calibration_target(self, x, y): self.draw_drift_correction_target(x, y) def fix_triggered_drift_correction(self, pos=None, min_samples=10, max_dev=60, reset_threshold=30): """Performs a fixation triggered drift correction by collecting a number of samples and calculating the average distance from the fixation position arguments None keyword arguments pos -- (x, y) position of the fixation dot or None for a central fixation (default = None) min_samples -- minimal amount of samples after which an average deviation is calculated (default = 10) max_dev -- maximal deviation from fixation in pixels (default = 60) reset_threshold -- if the horizontal or vertical distance in pixels between two consecutive samples is larger than this threshold, the sample collection is reset (default = 30) returns checked -- Boolaan indicating if drift check is ok (True) or not (False); or calls self.calibrate if 'q' or 'escape' is pressed """ self.draw_drift_correction_target(pos[0], pos[1]) if pos == None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 # loop until we have sufficient samples lx = [] ly = [] while len(lx) < min_samples: # pressing escape enters the calibration screen if self.kb.get_key()[0] in ['escape','q']: print("libeyetribe.EyeTribeTracker.fix_triggered_drift_correction: 'q' or 'escape' pressed") return self.calibrate() # collect a sample x, y = self.sample() if len(lx) == 0 or x != lx[-1] or y != ly[-1]: # if present sample deviates too much from previous sample, reset counting if len(lx) > 0 and (abs(x - lx[-1]) > reset_threshold or abs(y - ly[-1]) > reset_threshold): lx = [] ly = [] # collect samples else: lx.append(x) ly.append(y) if len(lx) == min_samples: avg_x = sum(lx) / len(lx) avg_y = sum(ly) / len(ly) d = ((avg_x - pos[0]) ** 2 + (avg_y - pos[1]) ** 2)**0.5 if d < max_dev: return True else: lx = [] ly = [] def get_eyetracker_clock_async(self): """Not supported for EyeTribeTracker (yet)""" print("function not supported yet") def log(self, msg): """Writes a message to the log file arguments ms -- a string to include in the log file returns Nothing -- uses native log function of iViewX to include a line in the log file """ self.eyetribe.log_message(msg) def prepare_drift_correction(self, pos): """Not supported for EyeTribeTracker (yet)""" print("function not supported yet") def pupil_size(self): """Return pupil size arguments None returns pupil size -- returns pupil diameter for the eye that is currently being tracked (as specified by self.eye_used) or -1 when no data is obtainable """ # get newest pupil size ps = self.eyetribe.pupil_size() # invalid data if ps == None: return -1 # check if the new pupil size is the same as the previous if ps != self.prevps: # update the pupil size self.prevps = copy.copy(ps) return self.prevps def sample(self): """Returns newest available gaze position arguments None returns sample -- an (x,y) tuple or a (-1,-1) on an error """ # get newest sample s = self.eyetribe.sample() # invalid data if s == (None,None): return (-1,-1) # check if the new sample is the same as the previous if s != self.prevsample: # update the current sample self.prevsample = copy.copy(s) return self.prevsample def send_command(self, cmd): """Sends a command to the eye tracker arguments cmd -- the command to be sent to the EyeTribe, which should be a list with the following information: [category, request, values] returns Nothing """ self.eyetribe._connection.request(cmd) def start_recording(self): """Starts recording eye position arguments None returns Nothing -- sets self.recording to True when recording is successfully started """ self.eyetribe.start_recording() self.recording = True def status_msg(self, msg): """Not supported for EyeTribeTracker (yet)""" print("function not supported yet") def stop_recording(self): """Stop recording eye position arguments None returns Nothing -- sets self.recording to False when recording is successfully started """ self.eyetribe.stop_recording() self.recording = False def set_detection_type(self, eventdetection): """Set the event detection type to either PyGaze algorithms, or native algorithms as provided by the manufacturer (only if available: detection type will default to PyGaze if no native functions are available) arguments eventdetection -- a string indicating which detection type should be employed: either 'pygaze' for PyGaze event detection algorithms or 'native' for manufacturers algorithms (only if available; will default to 'pygaze' if no native event detection is available) returns -- detection type for saccades, fixations and blinks in a tuple, e.g. ('pygaze','native','native') when 'native' was passed, but native detection was not available for saccade detection """ if eventdetection in ['pygaze','native']: self.eventdetection = eventdetection return ('pygaze','pygaze','pygaze') def wait_for_event(self, event): """Waits for event arguments event -- an integer event code, one of the following: 3 = STARTBLINK 4 = ENDBLINK 5 = STARTSACC 6 = ENDSACC 7 = STARTFIX 8 = ENDFIX returns outcome -- a self.wait_for_* method is called, depending on the specified event; the return values of corresponding method are returned """ if event == 5: outcome = self.wait_for_saccade_start() elif event == 6: outcome = self.wait_for_saccade_end() elif event == 7: outcome = self.wait_for_fixation_start() elif event == 8: outcome = self.wait_for_fixation_end() elif event == 3: outcome = self.wait_for_blink_start() elif event == 4: outcome = self.wait_for_blink_end() else: raise Exception("Error in libsmi.SMItracker.wait_for_event: eventcode %s is not supported" % event) return outcome def wait_for_blink_end(self): """Waits for a blink end and returns the blink ending time arguments None returns timestamp -- blink ending time in milliseconds, as measured from experiment begin time """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method blinking = True # loop while there is a blink while blinking: # get newest sample gazepos = self.sample() # check if it's valid if self.is_valid_sample(gazepos): # if it is a valid sample, blinking has stopped blinking = False # return timestamp of blink end return clock.get_time() def wait_for_blink_start(self): """Waits for a blink start and returns the blink starting time arguments None returns timestamp -- blink starting time in milliseconds, as measured from experiment begin time """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer blink detection; PyGaze algorithm \ will be used") # # # # # # PyGaze method blinking = False # loop until there is a blink while not blinking: # get newest sample gazepos = self.sample() # check if it's a valid sample if not self.is_valid_sample(gazepos): # get timestamp for possible blink start t0 = clock.get_time() # loop until a blink is determined, or a valid sample occurs while not self.is_valid_sample(self.sample()): # check if time has surpassed BLINKTHRESH if clock.get_time()-t0 >= self.blinkthresh: # return timestamp of blink start return t0 def wait_for_fixation_end(self): """Returns time and gaze position when a fixation has ended; function assumes that a 'fixation' has ended when a deviation of more than self.pxfixtresh from the initial fixation position has been detected (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer fixation detection; \ PyGaze algorithm will be used") # # # # # # PyGaze method # function assumes that a 'fixation' has ended when a deviation of more than fixtresh # from the initial 'fixation' position has been detected # get starting time and position stime, spos = self.wait_for_fixation_start() # loop until fixation has ended while True: # get new sample npos = self.sample() # get newest sample # check if sample is valid if self.is_valid_sample(npos): # check if sample deviates to much from starting position if (npos[0]-spos[0])**2 + (npos[1]-spos[1])**2 > self.pxfixtresh**2: # Pythagoras # break loop if deviation is too high break return clock.get_time(), spos def wait_for_fixation_start(self): """Returns starting time and position when a fixation is started; function assumes a 'fixation' has started when gaze position remains reasonably stable (i.e. when most deviant samples are within self.pxfixtresh) for five samples in a row (self.pxfixtresh is created in self.calibration, based on self.fixtresh, a property defined in self.__init__) arguments None returns time, gazepos -- time is the starting time in milliseconds (from expstart), gazepos is a (x,y) gaze position tuple of the position from which the fixation was initiated """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a fixation start # detection built into their API (only ending) print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer fixation detection; \ PyGaze algorithm will be used") # # # # # # PyGaze method # function assumes a 'fixation' has started when gaze position # remains reasonably stable for self.fixtimetresh # get starting position spos = self.sample() while not self.is_valid_sample(spos): spos = self.sample() # get starting time t0 = clock.get_time() # wait for reasonably stable position moving = True while moving: # get new sample npos = self.sample() # check if sample is valid if self.is_valid_sample(npos): # check if new sample is too far from starting position if (npos[0]-spos[0])**2 + (npos[1]-spos[1])**2 > self.pxfixtresh**2: # Pythagoras # if not, reset starting position and time spos = copy.copy(npos) t0 = clock.get_time() # if new sample is close to starting sample else: # get timestamp t1 = clock.get_time() # check if fixation time threshold has been surpassed if t1 - t0 >= self.fixtimetresh: # return time and starting position return t1, spos def wait_for_saccade_end(self): """Returns ending time, starting and end position when a saccade is ended; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos, endpos -- endtime in milliseconds (from expbegintime); startpos and endpos are (x,y) gaze position tuples """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # get starting position (no blinks) t0, spos = self.wait_for_saccade_start() # get valid sample prevpos = self.sample() while not self.is_valid_sample(prevpos): prevpos = self.sample() # get starting time, intersample distance, and velocity t1 = clock.get_time() s = ((prevpos[0]-spos[0])**2 + (prevpos[1]-spos[1])**2)**0.5 # = intersample distance = speed in px/sample v0 = s / (t1-t0) # run until velocity and acceleration go below threshold saccadic = True while saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # calculate distance s = ((newpos[0]-prevpos[0])**2 + (newpos[1]-prevpos[1])**2)**0.5 # = speed in pixels/sample # calculate velocity v1 = s / (t1-t0) # calculate acceleration a = (v1-v0) / (t1-t0) # acceleration in pixels/sample**2 (actually is v1-v0 / t1-t0; but t1-t0 = 1 sample) # check if velocity and acceleration are below threshold if v1 < self.pxspdtresh and (a > -1*self.pxacctresh and a < 0): saccadic = False epos = newpos[:] etime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] return etime, spos, epos def wait_for_saccade_start(self): """Returns starting time and starting position when a saccade is started; based on Dalmaijer et al. (2013) online saccade detection algorithm arguments None returns endtime, startpos -- endtime in milliseconds (from expbegintime); startpos is an (x,y) gaze position tuple """ # # # # # # EyeTribe method if self.eventdetection == 'native': # print warning, since EyeTribe does not have a blink detection # built into their API print("WARNING! 'native' event detection has been selected, \ but EyeTribe does not offer saccade detection; PyGaze \ algorithm will be used") # # # # # # PyGaze method # get starting position (no blinks) newpos = self.sample() while not self.is_valid_sample(newpos): newpos = self.sample() # get starting time, position, intersampledistance, and velocity t0 = clock.get_time() prevpos = newpos[:] s = 0 v0 = 0 # get samples saccadic = False while not saccadic: # get new sample newpos = self.sample() t1 = clock.get_time() if self.is_valid_sample(newpos) and newpos != prevpos: # check if distance is larger than precision error sx = newpos[0]-prevpos[0]; sy = newpos[1]-prevpos[1] if (sx/self.pxdsttresh[0])**2 + (sy/self.pxdsttresh[1])**2 > self.weightdist: # weigthed distance: (sx/tx)**2 + (sy/ty)**2 > 1 means movement larger than RMS noise # calculate distance s = ((sx)**2 + (sy)**2)**0.5 # intersampledistance = speed in pixels/ms # calculate velocity v1 = s / (t1-t0) # calculate acceleration a = (v1-v0) / (t1-t0) # acceleration in pixels/ms**2 # check if either velocity or acceleration are above threshold values if v1 > self.pxspdtresh or a > self.pxacctresh: saccadic = True spos = prevpos[:] stime = clock.get_time() # update previous values t0 = copy.copy(t1) v0 = copy.copy(v1) # udate previous sample prevpos = newpos[:] return stime, spos def is_valid_sample(self, gazepos): """Checks if the sample provided is valid, based on EyeTribe specific criteria (for internal use) arguments gazepos -- a (x,y) gaze position tuple, as returned by self.sample() returns valid -- a Boolean: True on a valid sample, False on an invalid sample """ # return False if a sample is invalid if gazepos == (None,None) or gazepos == (-1,-1): return False # in any other case, the sample is valid return True