feat: add corner gaze detection

This commit is contained in:
kushal.saha 2026-08-12 11:23:46 +00:00
parent 71b5c30113
commit e48f193a64
3 changed files with 182 additions and 1 deletions

View File

@ -26,6 +26,9 @@ const HEAD_PITCH_DOWN_THRESHOLD = 0.74;
const GAZE_HORIZONTAL_LEFT_THRESHOLD = 0.28;
const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72;
const GAZE_DOWN_THRESHOLD = 0.62;
const CORNER_FIXATION_MS = 18000;
const CORNER_RECHECK_MS = 20000;
const CORNER_JITTER_GRACE_MS = 3000;
// --- State Variables for Violation & Focus Detection ---
let isTabSwitchScreenshotEnabled = true;
@ -848,6 +851,135 @@ class ProlongedLookingAwayTracker {
}
}
class CornerGazeClassifier {
classify(face) {
const pose = face.pose;
const gaze = face.gaze;
if (!pose || !gaze) return null;
const horiz = gaze.horizontal;
const vert = gaze.vertical;
const yaw = pose.yaw;
const pitch = pose.pitch;
const isLeft = (horiz <= 0.42 || yaw <= -0.16);
const isRight = (horiz >= 0.58 || yaw >= 0.16);
const isTop = (vert <= 0.44 || pitch <= 0.38);
const isBottom = (vert >= 0.56 || pitch >= 0.62);
let corner = null;
let cornerLabel = null;
if (isLeft && isTop) {
corner = 'top_left';
cornerLabel = 'Top-Left Screen Corner';
} else if (isRight && isTop) {
corner = 'top_right';
cornerLabel = 'Top-Right Screen Corner';
} else if (isLeft && isBottom) {
corner = 'bottom_left';
cornerLabel = 'Bottom-Left Screen Corner';
} else if (isRight && isBottom) {
corner = 'bottom_right';
cornerLabel = 'Bottom-Right Screen Corner';
} else if (isBottom) {
corner = 'bottom_center';
cornerLabel = 'Screen Bottom / Downside';
} else if (isLeft) {
corner = 'screen_left';
cornerLabel = 'Screen Left Side';
} else if (isRight) {
corner = 'screen_right';
cornerLabel = 'Screen Right Side';
}
if (!corner) return null;
const confidence = Math.min(1, 0.5 + Math.max(Math.abs(horiz - 0.5), Math.abs(vert - 0.5)) * 1.2);
return {
corner,
corner_label: cornerLabel,
confidence,
details: {
corner,
corner_label: cornerLabel,
yaw,
pitch,
gaze_horizontal: horiz,
gaze_vertical: vert,
}
};
}
}
class CornerGazeTracker {
constructor({ thresholdMs, recheckMs, graceMs }) {
this.thresholdMs = thresholdMs;
this.recheckMs = recheckMs;
this.graceMs = graceMs;
this.currentCorner = null;
this.startedAt = null;
this.lastActiveAt = null;
this.nextCheckAt = null;
}
update(cornerResult, now) {
if (!cornerResult) {
if (this.lastActiveAt && (now - this.lastActiveAt) > this.graceMs) {
this.reset();
}
return null;
}
const activeCorner = cornerResult.corner;
const isRelatedZone = (this.currentCorner && (
(this.currentCorner.startsWith('bottom_') && activeCorner.startsWith('bottom_')) ||
(this.currentCorner.startsWith('top_') && activeCorner.startsWith('top_')) ||
(this.currentCorner === activeCorner)
));
if (!this.currentCorner || !isRelatedZone) {
this.currentCorner = activeCorner;
this.startedAt = now;
this.nextCheckAt = null;
} else {
this.currentCorner = activeCorner;
}
this.lastActiveAt = now;
const durationMs = now - this.startedAt;
if (durationMs < this.thresholdMs || (this.nextCheckAt !== null && now < this.nextCheckAt)) {
return null;
}
this.nextCheckAt = now + this.recheckMs;
return {
corner: activeCorner,
corner_label: cornerResult.corner_label,
duration_ms: Math.round(durationMs),
duration_s: Math.round(durationMs / 1000),
confidence: cornerResult.confidence,
details: {
...cornerResult.details,
duration_ms: Math.round(durationMs),
duration_s: Math.round(durationMs / 1000),
}
};
}
reset() {
this.currentCorner = null;
this.startedAt = null;
this.lastActiveAt = null;
this.nextCheckAt = null;
}
}
class CandidateProctoring {
constructor({ sessionId, video, canvas }) {
this.sessionId = sessionId;
@ -862,6 +994,12 @@ class CandidateProctoring {
thresholdMs: PROLONGED_LOOKING_AWAY_MS,
recheckMs: PROLONGED_LOOKING_AWAY_RECHECK_MS,
});
this.cornerGazeClassifier = new CornerGazeClassifier();
this.cornerGazeTracker = new CornerGazeTracker({
thresholdMs: CORNER_FIXATION_MS,
recheckMs: CORNER_RECHECK_MS,
graceMs: CORNER_JITTER_GRACE_MS,
});
this.startedAt = new Date().toISOString();
this.lastSampleAt = 0;
this.lastPhoneEventAt = 0;
@ -965,13 +1103,17 @@ class CandidateProctoring {
const noFace = result.count === 0;
const multipleFaces = result.count > 1;
const lookingAwayResult = this.lookingAwayClassifier.classify(result);
const cornerGazeResult = this.cornerGazeClassifier.classify(result);
this.durations.update('noFace', noFace, now);
this.durations.update('multipleFaces', multipleFaces, now);
this.durations.update('lookingAway', lookingAwayResult.active, now);
this.transition('noFace', noFace, noFace ? 0.95 : 0, { face_count: result.count }, NO_FACE_GRACE_MS, 'Candidate face is not visible');
this.transition('multipleFaces', multipleFaces, multipleFaces ? 0.95 : 0, { face_count: result.count }, 0, 'Multiple faces detected');
this.transition('lookingAway', lookingAwayResult.active, lookingAwayResult.confidence, lookingAwayResult.details, LOOKING_AWAY_GRACE_MS, 'Please look toward the camera');
this.trackProlongedLookingAway(lookingAwayResult, now);
this.trackCornerGaze(cornerGazeResult, now);
}
transition(name, active, confidence, details, graceMs, message) {
@ -996,6 +1138,15 @@ class CandidateProctoring {
}, this.snapshot.capture());
}
trackCornerGaze(cornerResult, now) {
const cornerEvent = this.cornerGazeTracker.update(cornerResult, now);
if (!cornerEvent) return;
this.logger.record('prolonged_corner_gaze', cornerEvent.confidence, {
...cornerEvent.details,
}, this.snapshot.capture());
}
processPhone(phone, now) {
if (!phone || now - this.lastPhoneEventAt < PHONE_EVENT_COOLDOWN_MS) return;
this.lastPhoneEventAt = now;

View File

@ -160,6 +160,20 @@ export function formatProctoringLogDetails(l) {
return `Possible mobile phone detected in camera frame (${label})`;
}
if (type === 'prolonged_corner_gaze') {
if (typeof detailsObj === 'object' && detailsObj && (detailsObj.corner_label || detailsObj.corner)) {
const cornerLabel = detailsObj.corner_label || (detailsObj.corner || 'screen corner').replace(/_/g, ' ');
const dur = detailsObj.duration_s || Math.round((detailsObj.duration_ms || 25000) / 1000);
const parts = [`Fixated on ${cornerLabel} for ${dur}s`];
if (typeof detailsObj.yaw === 'number') parts.push(`Yaw: ${detailsObj.yaw > 0 ? '+' : ''}${detailsObj.yaw.toFixed(2)}`);
if (typeof detailsObj.pitch === 'number') parts.push(`Pitch: ${detailsObj.pitch > 0 ? '+' : ''}${detailsObj.pitch.toFixed(2)}`);
if (typeof detailsObj.gaze_horizontal === 'number') parts.push(`Gaze H: ${detailsObj.gaze_horizontal.toFixed(2)}`);
if (typeof detailsObj.gaze_vertical === 'number') parts.push(`Gaze V: ${detailsObj.gaze_vertical.toFixed(2)}`);
return parts.join(' | ');
}
return 'Candidate fixated on screen corner for prolonged period (25+ seconds)';
}
if (type === 'talking_secondary_person') {
return typeof l.details === 'string' && !l.details.startsWith('{') ? l.details : 'Candidate detected speaking out loud to a secondary person';
}
@ -994,6 +1008,7 @@ export function pollReviewData() {
else if (l.type === 'multiple_faces') { icoClass = 'red'; faIcon = 'fa-solid fa-users-viewfinder'; isFlag = true; }
else if (l.type === 'prolonged_looking_away') { icoClass = 'red'; faIcon = 'fa-solid fa-clock-rotate-left'; isFlag = true; }
else if (l.type === 'phone_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen-button'; isFlag = true; }
else if (l.type === 'prolonged_corner_gaze') { icoClass = 'amber'; faIcon = 'fa-solid fa-arrows-to-eye'; isFlag = true; }
else if (l.type === 'talking_secondary_person') { icoClass = 'red'; faIcon = 'fa-solid fa-user-group'; isFlag = true; }
else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; }
else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; }
@ -1003,7 +1018,7 @@ export function pollReviewData() {
else if (l.type === 'paste_event') { icoClass = 'red'; faIcon = 'fa-solid fa-paste'; isFlag = true; }
else if (l.type === 'copy_event') { icoClass = 'amber'; faIcon = 'fa-solid fa-copy'; }
if (['paste_event', 'copy_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device', 'looking_away', 'face_missing', 'multiple_faces', 'prolonged_looking_away', 'phone_detected'].includes(l.type)) {
if (['paste_event', 'copy_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device', 'looking_away', 'face_missing', 'multiple_faces', 'prolonged_looking_away', 'prolonged_corner_gaze', 'phone_detected'].includes(l.type)) {
currentViolations.push(l);
}

View File

@ -25,6 +25,8 @@
$icoClass = 'red'; $faIcon = 'fa-solid fa-clock-rotate-left'; $isFlag = true;
} elseif ($type === 'phone_detected') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen-button'; $isFlag = true;
} elseif ($type === 'prolonged_corner_gaze') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-arrows-to-eye'; $isFlag = true;
} elseif ($type === 'talking_secondary_person') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-user-group'; $isFlag = true;
} elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) {
@ -81,6 +83,19 @@
} elseif ($type === 'phone_detected') {
$label = (is_array($detailsObj) && isset($detailsObj['label'])) ? $detailsObj['label'] : 'cell phone';
$humanDetails = "Possible mobile phone detected in camera frame ({$label})";
} elseif ($type === 'prolonged_corner_gaze') {
if (is_array($detailsObj) && (isset($detailsObj['corner_label']) || isset($detailsObj['corner']))) {
$cornerLabel = $detailsObj['corner_label'] ?? str_replace('_', ' ', $detailsObj['corner'] ?? 'screen corner');
$dur = $detailsObj['duration_s'] ?? round(($detailsObj['duration_ms'] ?? 25000) / 1000);
$parts = ["Fixated on {$cornerLabel} for {$dur}s"];
if (isset($detailsObj['yaw']) && is_numeric($detailsObj['yaw'])) $parts[] = 'Yaw: ' . ($detailsObj['yaw'] > 0 ? '+' : '') . number_format((float)$detailsObj['yaw'], 2);
if (isset($detailsObj['pitch']) && is_numeric($detailsObj['pitch'])) $parts[] = 'Pitch: ' . ($detailsObj['pitch'] > 0 ? '+' : '') . number_format((float)$detailsObj['pitch'], 2);
if (isset($detailsObj['gaze_horizontal']) && is_numeric($detailsObj['gaze_horizontal'])) $parts[] = 'Gaze H: ' . number_format((float)$detailsObj['gaze_horizontal'], 2);
if (isset($detailsObj['gaze_vertical']) && is_numeric($detailsObj['gaze_vertical'])) $parts[] = 'Gaze V: ' . number_format((float)$detailsObj['gaze_vertical'], 2);
$humanDetails = implode(' | ', $parts);
} else {
$humanDetails = 'Candidate fixated on screen corner for prolonged period (25+ seconds)';
}
} elseif ($type === 'talking_secondary_person') {
$humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : 'Candidate detected speaking out loud to a secondary person';
} else {