diff --git a/resources/js/candidate-proctor.js b/resources/js/candidate-proctor.js index ac648f2..67d1aac 100644 --- a/resources/js/candidate-proctor.js +++ b/resources/js/candidate-proctor.js @@ -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; diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index 8d6faa9..2a41506 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -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); } diff --git a/resources/views/components/audit-log-item.blade.php b/resources/views/components/audit-log-item.blade.php index ac5bdfa..a652369 100644 --- a/resources/views/components/audit-log-item.blade.php +++ b/resources/views/components/audit-log-item.blade.php @@ -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 {