eyetracking with eyelink in psychtoolbox, now with oop
I’ve started trying out MATLAB’s OOP after mounting suspicion that the way I’d been coding experiments basically involved making something that looked and behaved like an object–but did so in a convoluted and inefficient way. See this post on eyetracking with PTB as proof.
This post is brief, and is about as well thought out as a github gist/gitlab snippet.
The two classes I’ll work with here is a Window class and a Tracker class. The window class has 3 methods. The first constructor method exists just to create the object. The constructed object will have a few default properties of the class. The second method is open, which (can you guess?) calls the PTB functions to open an onscreen window. The open method is fancier than it needs to be for this post (note the PsychImaging configuration, and the optional debugLevel flag). The final window method is the desctructor. The destructor method is one of the advantages of leaning on MATLAB’s OOP syntax. That method will get called whenever the Window object’s lifecycle has ended (which might happen from explicit deletion of the object, closing MATLAB, the object is no longer referenced in the call stack, etc).
The second class is the Tracker class, which interfaces with Eyelink. The Window class is only present here because Eyelink needs an open window to run calibration. There are five Tracker methods, but they are either analogous to the Window objects methods (constructor, destructor) or were largely presented in the previous post.
Window Object
classdef Window < handle
% Window handles opening and closing of screen
properties (Constant)
screenNumber = 0
% background color of screen
background = GrayIndex(Window.screenNumber)
end
properties
pointer
winRect
end
methods
function obj = Window()
end
function open(obj, skipsynctests, debuglevel)
PsychImaging('PrepareConfiguration');
PsychImaging('AddTask', 'General', 'FloatingPoint16Bit');
Screen('Preference', 'SkipSyncTests', skipsynctests);
switch debuglevel
% no debug. run as usual, without listening to keyboard input
% and also hiding the cursor
case 0
ListenChar(-1);
HideCursor;
[obj.pointer, obj.winRect] = ...
PsychImaging('OpenWindow', obj.screenNumber, obj.background);
% light debug: still open fullscreen window, but keep keyboard input
case 1
[obj.pointer, obj.winRect] = ...
PsychImaging('OpenWindow', obj.screenNumber, obj.background);
% full debug: only open transparent window
case 10
PsychDebugWindowConfiguration(0, .5)
[obj.pointer, obj.winRect] = ...
PsychImaging('OpenWindow', obj.screenNumber, obj.background);
end
% Turn on blendfunction for antialiasing of drawing dots
Screen('BlendFunction', obj.pointer, 'GL_SRC_ALPHA', 'GL_ONE_MINUS_SRC_ALPHA');
topPriorityLevel = MaxPriority(obj.pointer);
Priority(topPriorityLevel);
end
% will auto-close open windows and return keyboard control when
% this object is deleted
function delete(obj) %#ok<INUSD>
ListenChar(0);
Priority(0);
sca;
end
end
end
Tracker Object
The tracker object will mostly do what it did in the previous post. Same functionality, but the syntax is much cleaner than the heavy use of switch/case conditionals.
classdef Tracker < handle
properties
% flag to be called in scripts which enable turning on or off the tracker
% in an experiment (e.g., when debug mode is on)
using_tracker logical = false
% name of the write. must follow eyelink conventions. alphanumeric only, no
% more than 8 characters
filename char = ''
% eyelink object structure. stores many relevant parameters
el
end
methods
function obj = Tracker(using_tracker, filename, window)
obj.using_tracker = using_tracker;
obj.filename = filename;
% run calibration for tracker (see method below)
calibrate(obj, window);
end
function calibrate(obj, window)
if obj.using_tracker
% Provide Eyelink with details about the graphics environment
% and perform some initializations. The information is returned
% in a structure that also contains useful defaults
% and control codes (e.g. tracker state bit and Eyelink key values).
obj.el = EyelinkInitDefaults(window.pointer);
if ~EyelinkInit(0, 1)
error('\n Eyelink Init aborted \n');
end
%Reduce FOV for calibration and validation. Helpful when the
% the stimulus is only in the center of the screen, or at places
% like the fMRI scanner at UMass where the eyes have a lot in front
% of them
Eyelink('Command','calibration_area_proportion = 0.5 0.5');
Eyelink('Command','validation_area_proportion = 0.5 0.5');
% open file to record data to
status = Eyelink('Openfile', obj.filename);
if status ~= 0
error('\n Eyelink Init aborted \n');
end
% Setting the proper recording resolution, proper calibration type,
% as well as the data file content;
Eyelink('Command','screen_pixel_coords = %ld %ld %ld %ld', 0, 0, window.winRect(3)-1, window.winRect(4)-1);
Eyelink('message', 'DISPLAY_COORDS %ld %ld %ld %ld', 0, 0, window.winRect(3)-1, window.winRect(4)-1);
% set calibration type to 5 point.
Eyelink('Command', 'calibration_type = HV5');
% set EDF file contents using the file_sample_data and
% file-event_filter commands
% set link data thtough link_sample_data and link_event_filter
Eyelink('Command', 'file_event_filter = RIGHT,FIXATION,SACCADE,BLINK,MESSAGE,BUTTON,INPUT');
Eyelink('Command', 'link_event_filter = RIGHT,FIXATION,SACCADE,BLINK,MESSAGE,BUTTON,INPUT');
% check the software version
% add "HTARGET" to record possible target data for EyeLink Remote
Eyelink('command', 'file_sample_data = RIGHT,GAZE,HREF,GAZERES,AREA,HTARGET,STATUS,INPUT');
Eyelink('command', 'link_sample_data = RIGHT,GAZE,HREF,GAZERES,AREA,HTARGET,STATUS,INPUT');
% make sure we're still connected.
if Eyelink('IsConnected')~=1
error('\n Eyelink Init aborted \n');
end
% set sample rate in camera setup screen
Eyelink('Command', 'sample_rate = %d', 1000);
% opens up main calibration scheme
EyelinkDoTrackerSetup(obj.el);
end
end
function status = eyelink(obj, varargin)
% calls main Eyelink routines only when
% this tracker object property using_tracker==true.
status = [];
if obj.using_tracker
if nargin==2
% construct calls to eyelink that don't output any
% status
if strcmp(varargin{1}, 'StopRecording') || ...
strcmp(varargin{1}, 'Shutdown') ||...
strcmp(varargin{1}, 'SetOfflineMode')
% magic happens here, where the variable argument input
% is expanded an repassed through to Eyelink()
Eyelink(varargin{:});
else
status = Eyelink(varargin{:});
end
% all calls to Eyelink that have more than two inputs (e.g., the
% name of a function with some parameters to that function) return
% some status
else
status = Eyelink(varargin{:});
end
end
end
% starts up the eyelink machine. call this once the start of each
% experiment. could modify function to also draw something special
% to the screen (e.g., a background image). this might be the kind
% of function to modify if you wanted to draw trial-by-trial material
% to the eyetracking computer
function startup(obj)
% Must be offline to draw to EyeLink screen
obj.eyelink('SetOfflineMode');
% clear tracker display and draw background img to host pc
obj.eyelink('Command', 'clear_screen 0');
% draw simple fixation cross as later reference
obj.eyelink('command', 'draw_cross %d %d', 1920/2, 1080/2);
% give image transfer time to finish
WaitSecs(0.1);
end
% destructor function will get called whenever tracker object is deleted (e.g.,
% this function is automatically called when MATLAB closes, meaning you can't
% forget to close the file connection with the tracker computer).
function delete(obj)
% waitsecs occur because the filetransfer often takes a moment, and moving
% on too quickly will result in an error
% End of Experiment; close the file first
% close graphics window, close data file and shut down tracker
obj.eyelink('StopRecording');
WaitSecs(0.1); % Slack to let stop definitely happen
obj.eyelink('SetOfflineMode');
obj.eyelink('CloseFile');
WaitSecs(0.1);
obj.eyelink('ReceiveFile', obj.filename, fullfile(pwd,'events'), 1);
WaitSecs(0.2);
obj.eyelink('Shutdown');
end
end
end
Run the calibration (and use the tracker)
Putting this together, the following script starts calibration, and outlines how this tracker could be used in an experiment.
%% input
% ------------------
skipsynctests = 2;
debuglevel = 0;
using_tracker = true;
%% setup
% ------------------
% boilerplate setup
PsychDefaultSetup(2);
% initialize window
window = Window();
% open that window
open(window, skipsynctests, debuglevel)
% Initialize tracker object
tracker = Tracker(using_tracker, 'OOPDEMO.edf', window);
% run calibration
tracker.startup();
% Let Eyelink know that the experiment starts now
tracker.eyelink('message', 'SYNCTIME');
%% Experiment/trial code
% ------------------
% note that we should not need to wait to start recording,
% given that the stimulus will always be drawn a bit later
% (determined by how often phase changes occur)
tracker.eyelink('StartRecording');
% trial/experiment happens here ...
tracker.eyelink('StopRecording');
% Wait moment to ensure that tracker is definitely finished with the last few samples
WaitSecs(0.001);
%% Cleanup
% ------------------
% closes connection to Eyelink system, saves file
delete(tracker);
% closes window, restores keyboard input
delete(window);
What’s nice about this syntax (as before) is that only very minimal changes are required you don’t want to call the Eyelink functions (e.g., if you’re testing on a computer that doesn’t have the Eyelink system connected, or you’re debugging other parts of the experiment). By changing just the input, the Eyelink functions won’t be called.
using_tracker = false;
% all the rest as above
% ...
Summary
There’s not much to summarize because I haven’t explained much! Again, this post is largely just an attempt to revise what I now think is a poor implementation, presented in an earlier post.