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_LEFT_THRESHOLD = 0.28;
|
||||||
const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72;
|
const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72;
|
||||||
const GAZE_DOWN_THRESHOLD = 0.62;
|
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 ---
|
// --- State Variables for Violation & Focus Detection ---
|
||||||
let isTabSwitchScreenshotEnabled = true;
|
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 {
|
class CandidateProctoring {
|
||||||
constructor({ sessionId, video, canvas }) {
|
constructor({ sessionId, video, canvas }) {
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
@ -862,6 +994,12 @@ class CandidateProctoring {
|
|||||||
thresholdMs: PROLONGED_LOOKING_AWAY_MS,
|
thresholdMs: PROLONGED_LOOKING_AWAY_MS,
|
||||||
recheckMs: PROLONGED_LOOKING_AWAY_RECHECK_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.startedAt = new Date().toISOString();
|
||||||
this.lastSampleAt = 0;
|
this.lastSampleAt = 0;
|
||||||
this.lastPhoneEventAt = 0;
|
this.lastPhoneEventAt = 0;
|
||||||
@ -965,13 +1103,17 @@ class CandidateProctoring {
|
|||||||
const noFace = result.count === 0;
|
const noFace = result.count === 0;
|
||||||
const multipleFaces = result.count > 1;
|
const multipleFaces = result.count > 1;
|
||||||
const lookingAwayResult = this.lookingAwayClassifier.classify(result);
|
const lookingAwayResult = this.lookingAwayClassifier.classify(result);
|
||||||
|
const cornerGazeResult = this.cornerGazeClassifier.classify(result);
|
||||||
|
|
||||||
this.durations.update('noFace', noFace, now);
|
this.durations.update('noFace', noFace, now);
|
||||||
this.durations.update('multipleFaces', multipleFaces, now);
|
this.durations.update('multipleFaces', multipleFaces, now);
|
||||||
this.durations.update('lookingAway', lookingAwayResult.active, 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('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('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.transition('lookingAway', lookingAwayResult.active, lookingAwayResult.confidence, lookingAwayResult.details, LOOKING_AWAY_GRACE_MS, 'Please look toward the camera');
|
||||||
this.trackProlongedLookingAway(lookingAwayResult, now);
|
this.trackProlongedLookingAway(lookingAwayResult, now);
|
||||||
|
this.trackCornerGaze(cornerGazeResult, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
transition(name, active, confidence, details, graceMs, message) {
|
transition(name, active, confidence, details, graceMs, message) {
|
||||||
@ -996,6 +1138,15 @@ class CandidateProctoring {
|
|||||||
}, this.snapshot.capture());
|
}, 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) {
|
processPhone(phone, now) {
|
||||||
if (!phone || now - this.lastPhoneEventAt < PHONE_EVENT_COOLDOWN_MS) return;
|
if (!phone || now - this.lastPhoneEventAt < PHONE_EVENT_COOLDOWN_MS) return;
|
||||||
this.lastPhoneEventAt = now;
|
this.lastPhoneEventAt = now;
|
||||||
|
|||||||
@ -160,6 +160,20 @@ export function formatProctoringLogDetails(l) {
|
|||||||
return `Possible mobile phone detected in camera frame (${label})`;
|
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') {
|
if (type === 'talking_secondary_person') {
|
||||||
return typeof l.details === 'string' && !l.details.startsWith('{') ? l.details : 'Candidate detected speaking out loud to a 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 === '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 === '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 === '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 === '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 === '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; }
|
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 === 'paste_event') { icoClass = 'red'; faIcon = 'fa-solid fa-paste'; isFlag = true; }
|
||||||
else if (l.type === 'copy_event') { icoClass = 'amber'; faIcon = 'fa-solid fa-copy'; }
|
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);
|
currentViolations.push(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,8 @@
|
|||||||
$icoClass = 'red'; $faIcon = 'fa-solid fa-clock-rotate-left'; $isFlag = true;
|
$icoClass = 'red'; $faIcon = 'fa-solid fa-clock-rotate-left'; $isFlag = true;
|
||||||
} elseif ($type === 'phone_detected') {
|
} elseif ($type === 'phone_detected') {
|
||||||
$icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen-button'; $isFlag = true;
|
$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') {
|
} elseif ($type === 'talking_secondary_person') {
|
||||||
$icoClass = 'red'; $faIcon = 'fa-solid fa-user-group'; $isFlag = true;
|
$icoClass = 'red'; $faIcon = 'fa-solid fa-user-group'; $isFlag = true;
|
||||||
} elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) {
|
} elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) {
|
||||||
@ -81,6 +83,19 @@
|
|||||||
} elseif ($type === 'phone_detected') {
|
} elseif ($type === 'phone_detected') {
|
||||||
$label = (is_array($detailsObj) && isset($detailsObj['label'])) ? $detailsObj['label'] : 'cell phone';
|
$label = (is_array($detailsObj) && isset($detailsObj['label'])) ? $detailsObj['label'] : 'cell phone';
|
||||||
$humanDetails = "Possible mobile phone detected in camera frame ({$label})";
|
$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') {
|
} elseif ($type === 'talking_secondary_person') {
|
||||||
$humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : 'Candidate detected speaking out loud to a secondary person';
|
$humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : 'Candidate detected speaking out loud to a secondary person';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user