Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
451 changes: 379 additions & 72 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/views/Calibration/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { selectUser } from "../../store/authSlice";
import Button from "../../components/Button";
import "./Calibration.css";

/**
* Calibration Component.
*
* This is the main view for the eye-tracking calibration process.
* It manages the UI states (idle, success, failed), initializes the webcam and MediaPipe models via helper utilities,
* verifies calibration accuracy against a threshold, and persists successful models to Firebase and Redux.
*/
export default function Calibration() {
const dispatch = useDispatch();
const navigate = useNavigate();
Expand Down
7 changes: 7 additions & 0 deletions src/views/Calibration/utils/calibration.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { initFaceLandmarker } from "./modules/faceModel.js";
import { startDistanceCheck, stopDistanceCheck } from "./modules/video.js";
import { runDotCalibration } from "./modules/dotCalibration.js";

/**
* Initializes the entire calibration subsystem.
* Sets up DOM references, loads the AI model, and attaches event listeners to UI buttons.
*
* @param {Function} onComplete - Callback executed when the calibration sequence finishes successfully.
* @returns {Promise<Function>} A cleanup function that removes event listeners.
*/
export async function initCalibration(onComplete) {
initDomRefs();
await initFaceLandmarker();
Expand Down
10 changes: 8 additions & 2 deletions src/views/Calibration/utils/modules/display.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { refs } from "./domRefs.js";
import { CALIBRATION_POINTS, gazeData, calibrationModel } from "./dotCalibration.js";
import { gazeData, calibrationModel } from "./dotCalibration.js";
import { leastSquares } from "./mathUtils.js";

/**
* Calculates the prediction model based on collected gaze data.
* It builds matrices from the gaze points and iris positions, performs a least-squares fit,
* validates the model accuracy, and updates the global calibrationModel object.
*
* @returns {Object} An object containing success flags, RMSE values, and accuracy scores for both eyes.
*/
export function displayPredictionModel() {

const buildMatrices = (eye) => {
Expand Down
18 changes: 18 additions & 0 deletions src/views/Calibration/utils/modules/distance.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
import { refs } from "./domRefs.js";

/**
* Global flag indicating if the user is currently at the correct distance.
* @type {boolean}
*/
export let distanceOK = false;

const FAR = 0.12;
const CLOSE = 0.25;

/**
* Computes the Euclidean distance between the left and right eye landmarks.
* Used as a proxy for the user's physical distance from the camera.
*
* @param {Array} lm - The array of face landmarks from MediaPipe.
* @returns {number} The hypotenuse distance between the two eye points.
*/
export function computeEyeDistance(lm) {
const l = lm[33];
const r = lm[263];
return Math.hypot(l.x - r.x, l.y - r.y);
}

/**
* Analyzes landmarks to determine if the user is too close, too far, or correctly positioned.
* Updates the DOM overlay text and buttons accordingly.
*
* @param {Array} landmarks - The face landmarks detected by the model.
* @returns {boolean} True if distance is acceptable, false otherwise.
*/
export function handleDistanceState(landmarks) {
if (!landmarks) {
distanceOK = false;
Expand Down
9 changes: 9 additions & 0 deletions src/views/Calibration/utils/modules/domRefs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
/**
* Global object to store references to DOM elements used throughout the calibration process.
* Populated by initDomRefs().
* @type {Object}
*/
export const refs = {};

/**
* Selects and assigns all necessary DOM elements to the global 'refs' object.
* This should be called once when the component mounts.
*/
export function initDomRefs() {
refs.video = document.getElementById("calibration-video");
refs.canvas = document.getElementById("calibration-canvas");
Expand Down
58 changes: 54 additions & 4 deletions src/views/Calibration/utils/modules/dotCalibration.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,26 @@ import { distanceOK } from "./distance.js";
import { stopDistanceCheck } from "./video.js";
import {displayPredictionModel} from "./display";

/**
* Array storing collected raw gaze samples.
* Each entry contains screen target coordinates and iris positions.
* @type {Array<Object>}
*/
export let gazeData = [];

/**
* The resulting calibration coefficients for left and right eyes after 'fitting'.
* @type {Object}
*/
export let calibrationModel = {
left: { coefX: [0,0,0,0,0,0], coefY: [0,0,0,0,0,0] },
right: { coefX: [0,0,0,0,0,0], coefY: [0,0,0,0,0,0] }
};

/** @type {boolean} Flag indicating if the dot calibration sequence is active. */
export let runningDot = false;

/** @type {boolean} Flag used to signal the calibration loop to abort. */
export let abortDot = false;

// utils points (screen ratios)
Expand All @@ -33,6 +46,9 @@ const WAIT_AFTER = 200;

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

/**
* Displays the full-screen warning overlay when distance or face detection fails during calibration.
*/
export function showFsWarning() {
refs.fsWarning.style.display = "flex";
refs.fsWarningPanel.style.opacity = "1";
Expand All @@ -49,18 +65,34 @@ export function showFsWarning() {
}
}

/**
* Hides the full-screen warning overlay.
*/
export function hideFsWarning() {
refs.fsWarning.style.display = "none";
refs.fsWarningPanel.style.opacity = "0";
}

// dot animation and placement
export function placeDot(x, y, visible = true) {
/**
* Immediately positions the calibration dot element at specific pixel coordinates.
* @param {number} x - Left offset in pixels.
* @param {number} y - Top offset in pixels.
* @param {boolean} [visible=true] - Whether the dot should be visible.
*/export function placeDot(x, y, visible = true) {
refs.calDot.style.left = `${x}px`;
refs.calDot.style.top = `${y}px`;
refs.calDot.style.opacity = visible ? "1" : "0";
}

/**
* Animates the calibration dot from its current position to target coordinates.
* Uses a cubic ease-out function for smooth movement.
*
* @param {number} tx - Target X coordinate in pixels.
* @param {number} ty - Target Y coordinate in pixels.
* @param {number} [duration=TRANSITION_MS] - Animation duration in milliseconds.
* @returns {Promise<void>} Resolves when animation completes.
*/
export function animateDotTo(tx, ty, duration = TRANSITION_MS) {
return new Promise(resolve => {
const startX = parseFloat(refs.calDot.style.left || "-1000");
Expand Down Expand Up @@ -92,7 +124,11 @@ function computeCenterAndRadius(points) {
return { x: cx, y: cy, r };
}

// dot positions in pixels
/**
* Calculates the exact screen pixel coordinates for the calibration points.
* Applies a margin to ensure points are not too close to the screen edge.
* @returns {Array<Object>} Array of objects with x and y properties.
*/
export function getDotPoints() {
const w = window.innerWidth, h = window.innerHeight;
const margin = Math.max(60, Math.min(200, Math.round(Math.min(w,h)*0.08)));
Expand All @@ -109,7 +145,15 @@ export function getDotPoints() {
];
}

// collect iris samples for a point
/**
* Collects a specified number of gaze samples (iris positions) for a specific calibration point.
* Handles pausing if face detection or distance fails.
*
* @param {number} idx - The index of the calibration point.
* @param {number} screenX - The normalized X screen coordinate (0-1).
* @param {number} screenY - The normalized Y screen coordinate (0-1).
* @returns {Promise<boolean|string>} Returns "RESTART" if flow was interrupted, true on success, false on abort.
*/
export async function collectSamplesForPoint(idx, screenX, screenY) {
let count = 0;
while (count < SAMPLES_PER_POINT) {
Expand Down Expand Up @@ -152,6 +196,12 @@ export async function collectSamplesForPoint(idx, screenX, screenY) {
return true;
}

/**
* Orchestrates the full dot calibration sequence.
* Enters fullscreen, animates the dot through points, collects data, and computes the model.
*
* @param {Function} onComplete - Callback function invoked with gazeData, model, and metrics upon success.
*/
export async function runDotCalibration(onComplete) {
if (!distanceOK) {
alert("Distance not OK");
Expand Down
11 changes: 10 additions & 1 deletion src/views/Calibration/utils/modules/faceModel.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import vision from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3";
import { refs } from "./domRefs.js";

const { FaceLandmarker, FilesetResolver } = vision;

/**
* Global reference to the MediaPipe FaceLandmarker instance.
* @type {FaceLandmarker|null}
*/
export let faceLandmarker = null;

/**
* Initializes the MediaPipe FaceLandmarker with GPU delegation.
* Loads the WASM files and the specific face landmarker model asset.
*
* @returns {Promise<void>} Resolves when the model is ready.
*/
export async function initFaceLandmarker() {

const resolver = await FilesetResolver.forVisionTasks(
Expand Down
23 changes: 23 additions & 0 deletions src/views/Calibration/utils/modules/mathUtils.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
/**
* Transposes a matrix.
* @param {Array<Array<number>>} m - The input matrix.
* @returns {Array<Array<number>>} The transposed matrix.
*/
export function transpose(m) {
return m[0].map((_, i) => m.map(r => r[i]));
}

/**
* Multiplies two matrices (A * B).
* @param {Array<Array<number>>} a - Matrix A.
* @param {Array<Array<number>>} b - Matrix B.
* @returns {Array<Array<number>>} The result matrix.
*/
export function multiply(a, b) {
const r = [];
for (let i = 0; i < a.length; i++) {
Expand All @@ -17,6 +28,11 @@ export function multiply(a, b) {
return r;
}

/**
* Calculates the inverse of a square matrix using Gaussian elimination.
* @param {Array<Array<number>>} m - The input square matrix.
* @returns {Array<Array<number>>|null} The inverted matrix, or null if singular.
*/
export function invert(m) {
const n = m.length;
const I = m.map((row, i) =>
Expand Down Expand Up @@ -46,6 +62,13 @@ export function invert(m) {
return I;
}

/**
* Solves the linear least squares problem Ax = b using the Normal Equation: x = (A^T A)^-1 A^T b.
*
* @param {Array<Array<number>>} A - Design matrix (samples x features).
* @param {Array<number>} b - Target vector.
* @returns {Array<number>|null} The coefficient vector x, or null if the matrix cannot be inverted.
*/
export function leastSquares(A, b) {
const AT = transpose(A);
const ATA = multiply(AT, A);
Expand Down
14 changes: 14 additions & 0 deletions src/views/Calibration/utils/modules/video.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ import { faceLandmarker } from "./faceModel.js";
import { handleDistanceState } from "./distance.js";
import { runningDot, abortDot, showFsWarning } from "./dotCalibration.js";

/** @type {boolean} Flag indicating if the camera loop is currently active. */
export let runningCamera = false;
let lastTime = -1;

/**
* The main animation loop for processing camera frames.
* Captures frames from the video element, runs the face landmarker, and checks distance.
* Also handles warning overlays during calibration if the face is lost.
*/
export function cameraLoop() {
if (!runningCamera) return;
requestAnimationFrame(cameraLoop);
Expand All @@ -31,6 +37,11 @@ export function cameraLoop() {
if (runningDot && !ok && !abortDot) showFsWarning();
}

/**
* Requests camera permissions, initializes the video stream, and starts the processing loop.
* Also manages UI state to show video elements and hide static previews.
* @returns {Promise<void>}
*/
export async function startDistanceCheck() {
if (runningCamera) return;

Expand Down Expand Up @@ -70,6 +81,9 @@ export async function startDistanceCheck() {
}
}

/**
* Stops the camera stream, releases tracks, and resets the UI to the static preview state.
*/
export function stopDistanceCheck() {
runningCamera = false;

Expand Down