tracker.start_recording() # show the image pygame.display.flip() tracker.log_message("image_on") tracker.log_message(imgname) # wait for a bit pygame.time.wait(5000) # show a blank screen disp.fill(BGC) pygame.display.flip() tracker.log_message("image_off") # stop recording tracker.stop_recording() # wait for a bit pygame.time.wait(2000) # # # # # # CLOSE # close connection to the tracker tracker.close() # close the display pygame.quit()
class EyeTribeTracker(BaseEyeTracker): """A class for EyeTribeTracker objects""" def __init__(self, display, logfile=LOGFILE, eventdetection=EVENTDETECTION, \ saccade_velocity_threshold=35, saccade_acceleration_threshold=9500, \ **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 = DISPSIZE # display size in pixels self.screensize = 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.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 start the calibration or Q to quit.") self.disp.fill(self.screen) self.disp.show() # wait for keyboard input key, keytime = self.kb.get_key(keylist=['q','space'], timeout=None, flush=True) if key == 'q': quited = True else: quited = False # run until the user is statisfied, or quits calibrated = False calibresult = None while not quited and not calibrated: # start a new calibration self.eyetribe.calibration.start(pointcount=len(calibpoints)) # loop through calibration points for cpos in calibpoints: 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(1000) # start calibration of point self.eyetribe.calibration.pointstart(cpos[0],cpos[1]) # wait for a second clock.pause(1000) # stop calibration of this point result = self.eyetribe.calibration.pointend() # the final calibration point returns a dict (does it?) if type(result) == dict: calibresult = copy.deepcopy(result) # 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.calibration.abort() # 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.") 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 # get the calibration result if it was not obtained yet if type(calibresult) != dict: # empty display self.disp.fill() self.disp.show() # allow for a bit of calculation time clock.pause(2000) # get the result calibresult = self.eyetribe._tracker.get_calibresult() # 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) # 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=str(p['acd']), pos=(p['cpx']+10,p['cpy']+10), fontsize=12) # 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 is successful", colour=(115,210,22), pos=(int(self.dispsize[0]*0.25),int(self.dispsize[1]*0.25)), fontsize=12) else: self.screen.draw_text(text="calibration failed", colour=(204,0,0), pos=(int(self.dispsize[0]*0.25),int(self.dispsize[1]*0.25)), fontsize=12) # draw average accuracy self.screen.draw_text(text="average error = %.2f degrees" % (calibresult['deg']), colour=(211,215,207), pos=(int(self.dispsize[0]*0.25),int(self.dispsize[1]*0.25+20)), fontsize=12) # draw input options self.screen.draw_text(text="Press Space to continue, or 'R' to restart.", colour=(211,215,207), pos=(int(self.dispsize[0]*0.25),int(self.dispsize[1]*0.25+40)), fontsize=12) else: self.screen.draw_text(text="Calibration failed, press 'R' to try again.") # 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 # 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 = 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=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 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 of iViewX to include a line in the log file in a "var NAME VALUE" layout """ msg = "var %s %s" % (var, val) self.log(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 150 ms if clock.get_time()-t0 >= 150: # 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
size = -999 print("ERROR in Pupil size: failed to obtain sample") # remove potential None if size == None: x = -999 # send message conn.send("success;s=%.4f" % (size)) # CLOSE elif message == 'Close': if tracker == None: print('ERROR in Close: tracker was not initialized.') conn.send('ERROR in Close: tracker was not initialized.') else: try: tracker.close() conn.send('success') stopped = True except: print("ERROR in Close: failed to close connection.") conn.send("ERROR in Close: failed to close connection.") # UNKNOWN else: print("WARNING: message was not recognized!") conn.send("WARNING: message was not recognized!") print("original message: '%s'" % message) # short pause time.sleep(0.5)
class EyeTribeTracker(BaseEyeTracker): """A class for EyeTribeTracker objects""" def __init__(self, presenter, screen_size, screen_dist, logfile, eventdetection='pygaze', saccade_velocity_threshold=35, saccade_acceleration_threshold=9500, blink_threshold=130): """Initializes the EyeTribeTracker object arguments window -- a Presenter instance screen_size -- display size in cm screen_dist -- distance to the display in cm 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.presenter = presenter self.dispsize = presenter.window.size # display size in pixels self.screensize = screen_size # display size in cm self.screendist = screen_dist # distance to the display in cm 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, pre_calib_wait=500, calib_wait=1000): """Calibrates the eye tracking system 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) """ def get_psychopy_pos(x, y): return (x - self.dispsize[0] / 2), (y - self.dispsize[1] / 2) # CALIBRATION # determine the calibration points calibpoints = [] margin = int(self.dispsize[1] / 10) gap_x = int((self.dispsize[0] - 2 * margin) / 3) gap_y = int((self.dispsize[1] - 2 * margin) / 3) for x in range(4): for y in range(4): calibpoints.append((margin + gap_x * x, margin + gap_y * y)) random.shuffle(calibpoints) # shuffle two lists together # show a message black_bg = visual.Rect(self.presenter.window, width=2.1, height=2.1, fillColor='black') self.presenter.show_instructions( 'Press space to calibrate\n\nFollow circles with your eyes', other_stim=[black_bg], next_instr_text=None) 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. point = get_psychopy_pos(cpos[0], cpos[1]) self.draw_calibration_target(point[0], point[1], black_bg) # wait for a bit to allow participant to start looking at # the calibration point clock.pause(pre_calib_wait) # 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(calib_wait) # stop calibration of this point self.eyetribe._lock.acquire(True) self.eyetribe.calibration.pointend() self.eyetribe._lock.release() # empty display self.presenter.draw_stimuli_for_duration([black_bg], duration=None) # allow for a bit of calculation time # (this is waaaaaay too much) clock.pause(1000) def show_calibration(self): def get_psychopy_pos(x, y): return (x - self.dispsize[0] / 2), (y - self.dispsize[1] / 2) # 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: stimuli = [ visual.TextStim(self.presenter.window, text='Calibration failed.') ] else: stimuli = [] 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) line = visual.Line( win=self.presenter.window, units='pix', lineWidth=0.5, start=get_psychopy_pos(p['cpx'], p['cpy']), end=get_psychopy_pos(p['mecpx'], p['mecpy'])) # draw the point point = visual.Circle(self.presenter.window, units='pix', lineWidth=1, lineColor='white', edges=128, radius=3, opacity=1, pos=get_psychopy_pos( p['cpx'], p['cpy'])) # draw the estimated point est_point = visual.Circle(self.presenter.window, units='pix', lineWidth=1, lineColor='red', edges=128, radius=3, opacity=1, pos=get_psychopy_pos( p['mecpx'], p['mecpy'])) # annotate accuracy text = visual.TextStim(self.presenter.window, text='%.2f' % p['acd'], pos=get_psychopy_pos( p['cpx'] + 15, p['cpy'] + 15), height=0.07) stimuli += [text, line, point, est_point] # if no data was obtained, draw the point in red else: stimuli.append( visual.Circle(self.presenter.window, units='pix', lineWidth=1, lineColor='red', edges=4, radius=3, opacity=1, pos=get_psychopy_pos(p['cpx'], p['cpy']))) # draw result if calibresult['result']: stimuli.append( visual.TextStim(self.presenter.window, text='Calibration successful', color='#84ff84', pos=(0, -0.4), height=0.05)) else: stimuli.append( visual.TextStim(self.presenter.window, text='Calibration failed', color='red', pos=(0, -0.4), height=0.05)) # draw average accuracy stimuli.append( visual.TextStim(self.presenter.window, pos=(0, -0.5), height=0.08, text='Average error = %.2f degrees' % (calibresult['deg']))) # show the results self.presenter.draw_stimuli_for_response(stimuli, response_keys=['space']) # 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 = self.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=True): """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 is None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 if fix_triggered: return self.fix_triggered_drift_correction(pos) raise RuntimeError('Don\'t trigger drift correction by key alright?') # 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, bg=None): """ Draws the drift-correction target. arguments x -- The X coordinate y -- The Y coordinate """ center = visual.Circle(self.presenter.window, units='pix', lineWidth=0, fillColor='white', edges=128, radius=2, pos=(x, y)) border = visual.Circle(self.presenter.window, units='pix', lineWidth=0, fillColor='white', edges=128, radius=4, opacity=0.5, pos=(x, y)) out_border = visual.Circle(self.presenter.window, units='pix', lineWidth=3, fillColor='white', edges=128, radius=22, opacity=0.1, pos=(x, y)) stims = [center, border, out_border ] if bg is None else [bg, center, border, out_border] self.presenter.draw_stimuli_for_duration(stims, duration=None) def draw_calibration_target(self, x, y, bg=None): self.draw_drift_correction_target(x, y, bg) 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 """ if pos is None: pos = self.dispsize[0] / 2, self.dispsize[1] / 2 self.draw_drift_correction_target(pos[0], pos[1]) # 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 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 is 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 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