feat: add corner gaze detection
This commit is contained in:
parent
71b5c30113
commit
e48f193a64
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user