Getting started
Serve this file over https:// or from localhost and open it in Chrome or Edge.
<!doctype html>
<html>
<head>
<script src="https://unpkg.com/jspsych@8"></script>
<script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response@2"></script>
<script src="https://unpkg.com/@saccadejs/core@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/extension@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-preview@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-time-sync@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-calibrate@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-validate@0.1.0"></script>
<link rel="stylesheet" href="https://unpkg.com/jspsych@8/css/jspsych.css" />
</head>
<body></body>
<script>
const jsPsych = initJsPsych({
extensions: [{ type: jsPsychExtensionSaccade }],
on_finish: () => jsPsych.data.displayData(),
});
const timeline = [
// Camera permission, model download, and head positioning.
{ type: jsPsychSaccadePreview },
// Measure this participant's screen-to-camera lag and apply it to every later `t`.
{ type: jsPsychSaccadeTimeSync },
// Fit the gaze model on 13 points, then check it on 9 held-out points.
{ type: jsPsychSaccadeCalibrate },
{ type: jsPsychSaccadeValidate },
// A trial that records gaze. `extensions` is what turns recording on.
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `
<div style="display:flex; gap:20vw; justify-content:center">
<img id="left" src="left.png" width="300" />
<img id="right" src="right.png" width="300" />
</div>
<p>Look at whichever picture you prefer, then press any key.</p>`,
extensions: [
{ type: jsPsychExtensionSaccade, params: { targets: ["#left", "#right"] } },
],
},
];
jsPsych.run(timeline);
</script>
</html>
Load @saccadejs/core before the other packages, and pin every version.
The setup trials
Register the extension
Add it to
initJsPsychonce. This does not touch the camera.saccade-preview
Asks for camera permission, downloads the model, and shows the participant the eye crop the model sees so they can position themselves. Put it early: this is where the download happens.
saccade-time-sync
Fifteen seconds of the screen stepping between black and white while the camera watches. This measures how far behind the camera clock is, and subtracts it from every later gaze timestamp. See Timing and synchrony.
saccade-calibrate
Thirteen points, about twenty seconds. Nothing predicts gaze until this finishes.
saccade-validate
Nine points the calibration never saw. Read
median_error_viewportfrom its data and report it.
Then attach the extension to any trial you want gaze recorded on.
What the data looks like
The extension adds three fields to every trial it is attached to.
{
"rt": 3184,
"saccade_data": [
{ "x": 412, "y": 388, "t": 51 },
{ "x": 418, "y": 381, "t": 85 },
{ "x": 903, "y": 402, "t": 118 }
// one row per camera frame in which a face was found
],
"saccade_targets": {
"#left": { "x": 260, "y": 300, "width": 300, "height": 200,
"top": 300, "bottom": 500, "left": 260, "right": 560 },
"#right": { "x": 900, "y": 300, "width": 300, "height": 200,
"top": 300, "bottom": 500, "left": 900, "right": 1200 }
},
"saccade_timing": {
"offset_ms": 78, "corrected": true, "clock": "captureTime",
"dropped_frames": 1, "fps": 29.6, "tta": 5
}
}
x and y are viewport pixels in the participant's own window, so they hit-test directly
against saccade_targets. t is milliseconds since the trial started, on the same clock as
rt, with the measured timing offset already subtracted.
const data = jsPsych.data.get().filter({ trial_type: "html-keyboard-response" }).values()[0];
const inBox = (s, r) => s.x >= r.left && s.x <= r.right && s.y >= r.top && s.y <= r.bottom;
const left = data.saccade_data.filter((s) => inBox(s, data.saccade_targets["#left"])).length;
const right = data.saccade_data.filter((s) => inBox(s, data.saccade_targets["#right"])).length;
With npm and a bundler
npm install jspsych @saccadejs/extension @saccadejs/plugin-preview \
@saccadejs/plugin-time-sync @saccadejs/plugin-calibrate @saccadejs/plugin-validate
import { initJsPsych } from "jspsych";
import jsPsychExtensionSaccade from "@saccadejs/extension";
import jsPsychSaccadePreview from "@saccadejs/plugin-preview";
import jsPsychSaccadeTimeSync from "@saccadejs/plugin-time-sync";
import jsPsychSaccadeCalibrate from "@saccadejs/plugin-calibrate";
import jsPsychSaccadeValidate from "@saccadejs/plugin-validate";
The timeline is identical. @saccadejs/core arrives as a dependency of the others; install it
explicitly only if you use the core API directly.
Without jsPsych
import { SaccadeTracker, runCalibration, defaultGrid13, runLoopback } from "@saccadejs/core";
const tracker = new SaccadeTracker();
await tracker.init(); // camera prompt and model load
document.body.appendChild(tracker.video);
tracker.start();
const { lagMs, verdict } = await runLoopback(tracker);
await runCalibration(tracker, defaultGrid13(), { settleMs: 1000, captureMs: 500 }, {
showTarget: (target, phase) => drawYourOwnTarget(target, phase),
});
tracker.fitCalibration();
tracker.onFrame((frame) => {
if (!frame.gaze) return;
const t = (frame.time.meanCapture ?? frame.time.capture) - (verdict === "OK" ? lagMs : 0);
console.log(frame.gaze.x, frame.gaze.y, t); // gaze is viewport fractions, 0-1
});
Next steps
- Hosting the assets. Read this before collecting real data.
- Timing and synchrony. What
tmeans. - Reference. Every parameter and data field.