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
8 changes: 4 additions & 4 deletions src/__tests__/utils/detectSaccade.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { detectSaccade, calculateAdaptiveThreshold, detectSaccadeRaw } from '../../views/GameTest/utils/detectSaccade';
import { VelocityConfig } from '../../views/GameTest/utils/velocityConfig';
import { MetricConfig } from '../../views/GameTest/utils/metricConfig';

// Mock VelocityConfig to have predictable values
jest.mock('../../views/GameTest/utils/velocityConfig', () => {
const originalModule = jest.requireActual('../../views/GameTest/utils/velocityConfig');
// Mock MetricConfig to have predictable values
jest.mock('../../views/GameTest/utils/metricConfig', () => {
const originalModule = jest.requireActual('../../views/GameTest/utils/metricConfig');
return {
...originalModule,
VelocityConfig: {
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/utils/saccadeData.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { analyzeSaccadeData, aggregateTrialStatistics, compareProVsAnti } from '../../views/GameTest/utils/saccadeData';
import { VelocityConfig } from '../../views/GameTest/utils/velocityConfig';
import { MetricConfig } from '../../views/GameTest/utils/metricConfig';

jest.mock('../../views/GameTest/utils/accuracyAdjust', () => ({
calculateAccuracy: jest.fn(() => ({
Expand All @@ -18,8 +18,8 @@ jest.mock('../../store/calibrationSlice', () => ({
}
}));

jest.mock('../../views/GameTest/utils/velocityConfig', () => {
const originalModule = jest.requireActual('../../views/GameTest/utils/velocityConfig');
jest.mock('../../views/GameTest/utils/metricConfig', () => {
const originalModule = jest.requireActual('../../views/GameTest/utils/metricConfig');
return {
...originalModule,
VelocityConfig: {
Expand Down
49 changes: 36 additions & 13 deletions src/views/Calibration/utils/modules/display.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Calibration\display.js
import { ridgeRegression, findOptimalLambda, leastSquares} from "./mathUtils.js";
import { ridgeRegression, findOptimalLambda, leastSquares, lambdaGrid } from "./mathUtils.js";
import { gazeData, calibrationModel } from "./dotCalibration.js";

/**
Expand Down Expand Up @@ -31,15 +31,16 @@ export function displayPredictionModel(useRidge = true) {
// const px = leastSquares(A,bx);
// const py = leastSquares(A,by);

let lambdaX = 0;
let lambdaY = 0;
if (useRidge) {
// 🔧 RIDGE REGRESSION with cross-validation
console.log(`Finding optimal lambda for ${eye} eye...`);
const lambdaX = findOptimalLambda(A, bx, [0.001, 0.01, 0.1, 1.0, 10.0], 5);
const lambdaY = findOptimalLambda(A, by, [0.001, 0.01, 0.1, 1.0, 10.0], 5);
lambdaX = findOptimalLambda(A, bx, lambdaGrid, 5);
lambdaY = findOptimalLambda(A, by, lambdaGrid, 5);

px = ridgeRegression(A, bx, lambdaX);
py = ridgeRegression(A, by, lambdaY);
lambda = (lambdaX + lambdaY) / 2;

console.log(`${eye} eye: λ_x=${lambdaX}, λ_y=${lambdaY}`);
} else {
Expand All @@ -50,8 +51,6 @@ export function displayPredictionModel(useRidge = true) {

if (!px || !py) return {success: false, reason: "matrix failure"};

if(!px || !py) return {success:false, reason:"matrix failure"};

let errSum= 0;
for(let i= 0; i < A.length; i++){
const [_, ix, iy] = A[i];
Expand All @@ -63,7 +62,17 @@ export function displayPredictionModel(useRidge = true) {
const rmse = Math.sqrt(errSum / A.length);
const accuracy = Math.max(0, 1 - rmse);

return {success:true, px, py, rmse, accuracy, samples:A.length, lambda: lambda, method: useRidge ? 'ridge' : 'ols'};
return {
success:true,
samples: A.length,
px,
py,
rmse,
accuracy,
lambdaX: lambdaX,
lambdaY: lambdaY,
method: useRidge ? 'ridge' : 'ols'
};
};

console.group('📊 Per-Eye Movement Analysis');
Expand Down Expand Up @@ -94,7 +103,6 @@ export function displayPredictionModel(useRidge = true) {
const rangeDiff = Math.abs((leftMaxX - leftMinX) - (rightMaxX - rightMinX));
console.log('Range difference:', {
diff: rangeDiff.toFixed(4),
status: rangeDiff < 0.005 ? '✅ SYMMETRIC' : '⚠️ ASYMMETRIC (possible bug)'
});

console.groupEnd();
Expand All @@ -108,10 +116,14 @@ export function displayPredictionModel(useRidge = true) {
if(leftRes.success){
calibrationModel.left.coefX = leftRes.px;
calibrationModel.left.coefY = leftRes.py;
calibrationModel.left.lambdaX = leftRes.lambdaX;
calibrationModel.left.lambdaY = leftRes.lambdaY
}
if(rightRes.success){
calibrationModel.right.coefX = rightRes.px;
calibrationModel.right.coefY = rightRes.py;
calibrationModel.right.lambdaX = rightRes.lambdaX;
calibrationModel.right.lambdaY = rightRes.lambdaY;
}

// debug: Store calibration metadata - UPDATED
Expand All @@ -131,21 +143,32 @@ export function displayPredictionModel(useRidge = true) {
height: window.innerHeight
},
timestamp: Date.now(),
version: '1.0.2'
version: '1.0.3'
};

console.log('📊 Calibration complete:', {
method: useRidge ? 'Ridge Regression' : 'Ordinary Least Squares',
leftRMSE: leftRes.rmse?.toFixed(4),
rightRMSE: rightRes.rmse?.toFixed(4),
leftLambda: leftRes.lambda,
rightLambda: rightRes.lambda
left: {
rmse: leftRes.rmse?.toFixed(4),
// Proof that X and Y are different:
lambdaX: leftRes.lambdaX,
lambdaY: leftRes.lambdaY
},
right: {
rmse: rightRes.rmse?.toFixed(4),
lambdaX: rightRes.lambdaX,
lambdaY: rightRes.lambdaY
}
});

return {
sucess: {left: leftRes.success, right: rightRes.success },
rmse: {left: leftRes.rmse, right: rightRes.rmse },
accuracy: { left: leftRes.accuracy, right: rightRes.accuracy },
lambda: {
left: {x: leftRes.lambdaX, y: leftRes.lambdaY},
right: {x: rightRes.lambdaX, y: rightRes.lambdaY}
},
method: useRidge ? 'ridge' : 'ols'
};
}
5 changes: 4 additions & 1 deletion src/views/Calibration/utils/modules/mathUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ function gaussianElimination(A, b) {
* @param {number} [k=5] - Number of folds for cross-validation.
* @returns {number} The lambda value that resulted in the lowest average Mean Squared Error.
*/
export function findOptimalLambda(A, b, lambdaRange = [0.001, 0.01, 0.1, 1.0, 10.0], k = 5) {
export const lambdaGrid = [
0.001, 0.002, 0.003, 0.005, 0.007,
0.01, 0.015, 0.02, 0.03, 0.05, 0.07, 0.1
];export function findOptimalLambda(A, b, lambdaRange = lambdaGrid, k = 5) {
const m = A.length;
const foldSize = Math.floor(m / k);

Expand Down
18 changes: 8 additions & 10 deletions src/views/GameTest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
compareProVsAnti
} from './utils/saccadeData';
import IrisFaceMeshTracker from "./utils/iris-facemesh";
import {calculatePerTrialThreshold} from "./utils/velocityConfig";
import {calculatePerTrialThreshold, MetricConfig} from "./utils/metricConfig";


const GameTest = () => {
Expand Down Expand Up @@ -65,7 +65,7 @@ const GameTest = () => {
const breakTime = 60000;
const interTrialInterval = 1000;

// test parameters
// test parameters (dot time)
const gapTimeBetweenCenterDot = 200;
const maxFixation = 2500;
const minFixation = 1000;
Expand Down Expand Up @@ -269,7 +269,7 @@ const GameTest = () => {
// Record when fixation ends
const fixationEndTime = irisTracker.current.getRelativeTime();

let trialThreshold = 30;
let trialThreshold = MetricConfig.SACCADE.STATIC_THRESHOLD_DEG_PER_SEC; // 30 degree

if (irisTracker.current) {
const allData = irisTracker.current.getTrackingData();
Expand All @@ -284,14 +284,14 @@ const GameTest = () => {
frame.velocity !== undefined &&
frame.velocity !== null &&
!isNaN(frame.velocity) &&
frame.velocity < 300
frame.velocity < trialThreshold
)
.map(frame => frame.velocity);

if (fixationVelocities.length >= 20) {
if (fixationVelocities.length >= 15) { // ~ 500 ms
trialThreshold = calculatePerTrialThreshold(fixationVelocities);
} else {
console.warn(`Trial ${i+1}: Insufficient fixation data (${fixationVelocities.length} samples). Using default threshold: 30 deg/s`);
console.warn(`Trial ${i+1}: Insufficient fixation data (${fixationVelocities.length} samples). Using default threshold: 25 deg/s`);
}
}

Expand All @@ -311,9 +311,7 @@ const GameTest = () => {

const dotAppearanceTime = irisTracker.current.getRelativeTime();

// if (irisTracker.current) {
// irisTracker.current.addTrialContext(i + 1, side);
// }

if (irisTracker.current) {
irisTracker.current.addTrialContext(i + 1, `${testPhase}-${side}`);
}
Expand Down Expand Up @@ -372,7 +370,7 @@ const GameTest = () => {

if (testPhase === 'pro') {
// If we just finished Pro-Saccade, trigger break time
console.log("Saccade Analysis Data:", currentPhaseTrials);
console.log("Pro-Saccade Analysis Data:", currentPhaseTrials);
setShowBreak(true);
} else {
// If we just finished Anti-Saccade, Finish the game
Expand Down
34 changes: 22 additions & 12 deletions src/views/GameTest/utils/accuracyAdjust.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
// GameTest\utils\accuracyAdjust.js

/**
* This implementation to generate a accuracy metrics for saccade tasks that take into accounts of
* webcam, low frame rate and calibration drift
* Compare to professional Eye Tracker
**/
import {selectCalibrationMetrics} from "../../../store/calibrationSlice";
import {MetricConfig as metricConfig, MetricConfig} from "./metricConfig";

/**
* Calculate adaptive ROI based on calibration accuracy and tracker FPS
* Base start at 10% of screen (~4 - 5 degree)
* Calibration accuracy penalty -> Roi expands
* Fps at 30 fps, expands further
* Return about 5-8 degree (0.12 - 0.25 normalized screen)
*/

const calculateAdaptiveROI = (calibrationAccuracy, trackerFPS) => {
// Base ROI for perfect calibration: 3° (0.1 screen width)
const baseROI = 0.10;
Expand Down Expand Up @@ -35,7 +43,8 @@ const calculateAdaptiveROI = (calibrationAccuracy, trackerFPS) => {
const assessFrameQuality = (frame) => {
let qualityScore = 1.0;

// Check 1: Do we have binocular data?
// Check 1: Do we have binocular data
// If only left OR right, quality down by half
if (!frame.calibrated?.left || !frame.calibrated?.right) {
qualityScore *= 0.5; // Monocular is less reliable
}
Expand All @@ -54,8 +63,8 @@ const assessFrameQuality = (frame) => {
}
}

// Check 3: Velocity plausibility (during fixation should be <20°/s)
if (frame.velocity && frame.velocity > 20 && !frame.isSaccade) {
// Check 3: Velocity plausibility (during fixation should be <30°/s)
if (frame.velocity && frame.velocity > MetricConfig.SACCADE.STATIC_THRESHOLD_DEG_PER_SEC && !frame.isSaccade) {
qualityScore *= 0.5; // High velocity during "fixation" = artifact
}

Expand All @@ -65,6 +74,7 @@ const assessFrameQuality = (frame) => {
/**
* WEBCAM-ADJUSTED ACCURACY CALCULATION
* Uses relaxed thresholds appropriate for low-fps noisy tracking
* Landing point + gain score + fixation (stability)
*/
export const calculateAccuracy = (
recordingData,
Expand All @@ -74,11 +84,10 @@ export const calculateAccuracy = (
) => {
const {
calibrationAccuracy = selectCalibrationMetrics?.accuracy?.left || 0.91, // MediaPipe hardcode accuracy MediaPipe accuracy is 1-3° in ideal conditions
trackerFPS = 30, // Webcam frame rate
roiRadius = null, // Auto-calculate if null
fixationDuration = 300, // Keep standard 300ms
saccadicGainWindow = 100, // INCREASED from 67ms to 100ms
minLatency = 100,
trackerFPS = 30, // Webcam frame rate - NEED TO FIX for real but most likely 30 fps
roiRadius = null, // Auto-calculate if null
fixationDuration = metricConfig.FIXATION.DURATION, // defines the post-saccade analysis window
saccadicGainWindow = metricConfig.FIXATION.SACCADE_GAIN_WINDOW, // INCREASED from 67ms to 100ms
fixationStabilityThreshold = 0.70 // RELAXED from 0.80 to 0.70
} = options;

Expand All @@ -97,7 +106,7 @@ export const calculateAccuracy = (
};
}

// Find fixation point before saccade
// Find fixation point before saccade - before the dot appears 500ms to 1000ms
let fixationPoint = null;
for (const frame of recordingData) {
if (frame.timestamp < dotAppearanceTime - 500 &&
Expand All @@ -108,7 +117,8 @@ export const calculateAccuracy = (
}
}
}
if (!fixationPoint) fixationPoint = { x: 0.5, y: 0.5 };

if (!fixationPoint) fixationPoint = { x: 0.5, y: 0.5 }; //fallbacks to perfect value

// Get target coordinates
const targetFrame = recordingData.find(f =>
Expand Down
16 changes: 8 additions & 8 deletions src/views/GameTest/utils/detectSaccade.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { VelocityConfig, getPixelsPerDegree } from './velocityConfig';
import { MetricConfig, getPixelsPerDegree } from './metricConfig';

/**
* Validates time delta between frames
*/
const isValidTimeDelta = (timeDeltaSec) => {
return timeDeltaSec > 0 && timeDeltaSec <= VelocityConfig.TIME.MAX_DELTA_SEC * 2;
return timeDeltaSec > 0 && timeDeltaSec <= MetricConfig.TIME.MAX_DELTA_SEC * 2;
};

/**
Expand All @@ -16,7 +16,7 @@ const isValidTimeDelta = (timeDeltaSec) => {
* @returns {Object} { distanceDegrees: number, dx: number, dy: number }
*/
const calculateVisualAngleDistance = (point1, point2) => {
const { SCREEN, HORIZONTAL_FOV_DEGREES, VERTICAL_FOV_DEGREES } = VelocityConfig;
const { SCREEN, HORIZONTAL_FOV_DEGREES, VERTICAL_FOV_DEGREES } = MetricConfig;

// Convert normalized difference to pixel difference
const dxPixels = (point2.x - point1.x) * SCREEN.WIDTH;
Expand Down Expand Up @@ -77,7 +77,7 @@ const validateBinocularData = (currentPoint, prevPoint, timeDeltaSec) => {
let disparity = 0;
if (leftVelocity !== null && rightVelocity !== null) {
disparity = Math.abs(leftVelocity - rightVelocity);
const maxDisparity = VelocityConfig.SACCADE.MAX_BINOCULAR_DISPARITY_DEG_PER_SEC;
const maxDisparity = MetricConfig.SACCADE.MAX_BINOCULAR_DISPARITY_DEG_PER_SEC;

if (disparity > maxDisparity) {
// We allow the data but mark it with a reason.
Expand Down Expand Up @@ -151,7 +151,7 @@ const calculateSimpleDifferentiationVelocity = (currentPoint, prevPoint) => {
* @returns {number} Threshold in degrees/second
*/
export const calculateAdaptiveThreshold = (recentVelocities = []) => {
const { ADAPTIVE, STATIC_THRESHOLD_DEG_PER_SEC } = VelocityConfig.SACCADE;
const { ADAPTIVE, STATIC_THRESHOLD_DEG_PER_SEC } = MetricConfig.SACCADE;

if (!ADAPTIVE.ENABLED || recentVelocities.length < ADAPTIVE.MIN_FIXATION_SAMPLES) {
return STATIC_THRESHOLD_DEG_PER_SEC;
Expand Down Expand Up @@ -231,7 +231,7 @@ export const detectSaccade = (currentPoint, prevPoint, options = {}) => {
const velocity = calculateCyclopeanVelocity(currentPoint, prevPoint, timeDeltaSec);

// STEP 5: Apply threshold (adaptive or static)
const threshold = options.adaptiveThreshold || VelocityConfig.SACCADE.STATIC_THRESHOLD_DEG_PER_SEC;
const threshold = options.adaptiveThreshold || MetricConfig.SACCADE.STATIC_THRESHOLD_DEG_PER_SEC;
const isSaccade = velocity > threshold;

return {
Expand Down Expand Up @@ -263,7 +263,7 @@ export const detectSaccadeRaw = (currentPoint, prevPoint) => {
return { velocity: 0, isSaccade: false, isValid: false };
}

const { HORIZONTAL_FOV_DEGREES, VERTICAL_FOV_DEGREES, RAW_IRIS_GAIN } = VelocityConfig;
const { HORIZONTAL_FOV_DEGREES, VERTICAL_FOV_DEGREES, RAW_IRIS_GAIN } = MetricConfig;

// Calculate for left eye
const dLx = currentPoint.leftIris.x - prevPoint.leftIris.x;
Expand All @@ -283,7 +283,7 @@ export const detectSaccadeRaw = (currentPoint, prevPoint) => {

// Average velocities
const velocity = (leftDistDeg + rightDistDeg) / 2 / timeDeltaSec;
const threshold = VelocityConfig.SACCADE.STATIC_THRESHOLD_DEG_PER_SEC;
const threshold = MetricConfig.SACCADE.STATIC_THRESHOLD_DEG_PER_SEC;

return {
velocity,
Expand Down
Loading