webgazer
Version:
> **Note:** As of February 24, 2026, WebGazer is fully functional and works as intended, but updates are no longer guaranteed. Official maintenance has ended, but community support continues via [GitHub Issues](https://github.com/brownhci/WebGazer/issues)
195 lines (165 loc) • 6.03 kB
JavaScript
import * as faceLandmarksDetection from '@tensorflow-models/face-landmarks-detection';
import params from './params.mjs';
// Eye landmark indices for MediaPipe FaceMesh (468 landmarks)
// Reference: https://github.com/tensorflow/tfjs-models/blob/master/face-landmarks-detection/mesh_map.jpg
const EYE_INDICES = {
// Note: "left" and "right" are from the subject's perspective
leftEyeUpper0: [466, 388, 387, 386, 385, 384, 398],
leftEyeLower0: [263, 249, 390, 373, 374, 380, 381, 382, 362],
rightEyeUpper0: [246, 161, 160, 159, 158, 157, 173],
rightEyeLower0: [33, 7, 163, 144, 145, 153, 154, 155, 133],
};
/**
* Constructor of TFFaceMesh object
* @constructor
* */
const TFFaceMesh = function() {
this.model = faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh;
this.detector = null;
this.predictionReady = false;
};
// Global variable for face landmark positions array
TFFaceMesh.prototype.positionsArray = null;
/**
* Initialize the face detector with local MediaPipe solution files
* @return {Promise} resolves when detector is ready
*/
TFFaceMesh.prototype.init = async function() {
if (this.detector) {
return this.detector;
}
const detectorConfig = {
runtime: 'mediapipe',
solutionPath: params.faceMeshSolutionPath,
};
this.detector = await faceLandmarksDetection.createDetector(this.model, detectorConfig);
return this.detector;
};
/**
* Isolates the two patches that correspond to the user's eyes
* @param {Object} video - the video element itself
* @param {Canvas} imageCanvas - canvas corresponding to the webcam stream
* @param {Number} width - of imageCanvas
* @param {Number} height - of imageCanvas
* @return {Object} the two eye-patches, first left, then right eye
*/
TFFaceMesh.prototype.getEyePatches = async function(video, imageCanvas, width, height) {
if (imageCanvas.width === 0) {
return null;
}
// Initialize detector if not already done
await this.init();
// Pass in a video stream to obtain an array of detected faces
const predictions = await this.detector.estimateFaces(video);
if (predictions.length == 0){
return false;
}
// MediaPipe runtime returns keypoints as array of {x, y, z, name?} objects
// Convert to [x, y, z] array format for compatibility with rest of codebase
const keypoints = predictions[0].keypoints;
this.positionsArray = keypoints.map(kp => [kp.x, kp.y, kp.z || 0]);
// Helper function to get landmark coordinates by indices
const getPointsByIndices = (indices) => {
return indices.map(idx => [keypoints[idx].x, keypoints[idx].y, keypoints[idx].z || 0]);
};
const [leftBBox, rightBBox] = [
// left (from subject's perspective)
{
eyeTopArc: getPointsByIndices(EYE_INDICES.leftEyeUpper0),
eyeBottomArc: getPointsByIndices(EYE_INDICES.leftEyeLower0)
},
// right (from subject's perspective)
{
eyeTopArc: getPointsByIndices(EYE_INDICES.rightEyeUpper0),
eyeBottomArc: getPointsByIndices(EYE_INDICES.rightEyeLower0)
},
].map(({ eyeTopArc, eyeBottomArc }) => {
const topLeftOrigin = {
x: Math.round(Math.min(...eyeTopArc.map(v => v[0]))),
y: Math.round(Math.min(...eyeTopArc.map(v => v[1]))),
};
const bottomRightOrigin = {
x: Math.round(Math.max(...eyeBottomArc.map(v => v[0]))),
y: Math.round(Math.max(...eyeBottomArc.map(v => v[1]))),
};
return {
origin: topLeftOrigin,
width: bottomRightOrigin.x - topLeftOrigin.x,
height: bottomRightOrigin.y - topLeftOrigin.y,
}
});
var leftOriginX = leftBBox.origin.x;
var leftOriginY = leftBBox.origin.y;
var leftWidth = leftBBox.width;
var leftHeight = leftBBox.height;
var rightOriginX = rightBBox.origin.x;
var rightOriginY = rightBBox.origin.y;
var rightWidth = rightBBox.width;
var rightHeight = rightBBox.height;
if (leftWidth === 0 || rightWidth === 0){
console.log('an eye patch had zero width');
return null;
}
if (leftHeight === 0 || rightHeight === 0){
console.log('an eye patch had zero height');
return null;
}
// Start building object to be returned
var eyeObjs = {};
var leftImageData = imageCanvas.getContext('2d', { willReadFrequently: true }).getImageData(leftOriginX, leftOriginY, leftWidth, leftHeight);
eyeObjs.left = {
patch: leftImageData,
imagex: leftOriginX,
imagey: leftOriginY,
width: leftWidth,
height: leftHeight
};
var rightImageData = imageCanvas.getContext('2d', { willReadFrequently: true }).getImageData(rightOriginX, rightOriginY, rightWidth, rightHeight);
eyeObjs.right = {
patch: rightImageData,
imagex: rightOriginX,
imagey: rightOriginY,
width: rightWidth,
height: rightHeight
};
this.predictionReady = true;
return eyeObjs;
};
/**
* Returns the positions array corresponding to the last call to getEyePatches.
* Requires that getEyePatches() was called previously, else returns null.
*/
TFFaceMesh.prototype.getPositions = function () {
return this.positionsArray;
}
/**
* Reset the tracker to default values
*/
TFFaceMesh.prototype.reset = function(){
console.log( "Unimplemented; Tracking.js has no obvious reset function" );
}
/**
* Draw TF_FaceMesh_Overlay
*/
TFFaceMesh.prototype.drawFaceOverlay = function(ctx, keypoints){
// If keypoints is falsy, don't do anything
if (keypoints) {
ctx.fillStyle = '#32EEDB';
ctx.strokeStyle = '#32EEDB';
ctx.lineWidth = 0.5;
for (let i = 0; i < keypoints.length; i++) {
const x = keypoints[i][0];
const y = keypoints[i][1];
ctx.beginPath();
ctx.arc(x, y, 1 /* radius */, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
}
}
}
/**
* The TFFaceMesh object name
* @type {string}
*/
TFFaceMesh.prototype.name = 'TFFaceMesh';
export default TFFaceMesh;