Spaces:
Sleeping
Sleeping
| # MODULE 3: Eye Contact Detection | |
| import cv2 | |
| import numpy as np | |
| from collections import deque | |
| # CONFIGURATION | |
| GAZE_THRESHOLD = 0.25 # iris offset ratio — beyond this = looking away | |
| HISTORY_WINDOW = 150 # frames to track eye contact % over | |
| MIN_EYE_CONTACT_SCORE = 40 # below this % = poor eye contact | |
| # EYE CONTACT DETECTOR | |
| class EyeContactDetector: | |
| def __init__(self): | |
| self.history = deque(maxlen=HISTORY_WINDOW) | |
| def detect(self, key_points, frame_shape): | |
| """ | |
| Estimates gaze direction using iris position relative to eye corners. | |
| Returns dict: | |
| looking_at_camera : bool | |
| gaze_direction : Left / Right / Up / Down / Center | |
| eye_contact_pct : rolling % of frames with eye contact | |
| left_offset : (x, y) iris offset ratio for left eye | |
| right_offset : (x, y) iris offset ratio for right eye | |
| score : 0-100 | |
| """ | |
| left_offset = self._iris_offset( | |
| key_points.get("left_eye", []), | |
| key_points.get("left_iris", []) | |
| ) | |
| right_offset = self._iris_offset( | |
| key_points.get("right_eye", []), | |
| key_points.get("right_iris", []) | |
| ) | |
| gaze_dir = self._gaze_direction(left_offset, right_offset) | |
| at_camera = gaze_dir == "Center" | |
| self.history.append(1 if at_camera else 0) | |
| eye_contact_pct = round(sum(self.history) / len(self.history) * 100, 1) if self.history else 0.0 | |
| score = int(eye_contact_pct) | |
| return { | |
| "looking_at_camera": at_camera, | |
| "gaze_direction": gaze_dir, | |
| "eye_contact_pct": eye_contact_pct, | |
| "left_offset": left_offset, | |
| "right_offset": right_offset, | |
| "score": score, | |
| } | |
| def _iris_offset(self, eye_pts, iris_pts): | |
| """ | |
| Measures how far the iris has shifted from the center of the eye. | |
| Returns (x_ratio, y_ratio) — values near 0 = centered = looking at camera. | |
| """ | |
| if len(eye_pts) < 4 or not iris_pts: | |
| return (0.0, 0.0) | |
| eye_left = np.array(eye_pts[0]) | |
| eye_right = np.array(eye_pts[3]) | |
| eye_top = np.array(eye_pts[1]) | |
| eye_bottom = np.array(eye_pts[5]) if len(eye_pts) > 5 else np.array(eye_pts[-1]) | |
| eye_center_x = (eye_left[0] + eye_right[0]) / 2 | |
| eye_center_y = (eye_top[1] + eye_bottom[1]) / 2 | |
| eye_width = np.linalg.norm(eye_right - eye_left) | |
| eye_height = abs(eye_bottom[1] - eye_top[1]) | |
| iris_x, iris_y = iris_pts[0] | |
| x_offset = (iris_x - eye_center_x) / eye_width if eye_width > 0 else 0.0 | |
| y_offset = (iris_y - eye_center_y) / eye_height if eye_height > 0 else 0.0 | |
| return (round(x_offset, 3), round(y_offset, 3)) | |
| def _gaze_direction(self, left_offset, right_offset): | |
| # Average offset from both eyes | |
| avg_x = (left_offset[0] + right_offset[0]) / 2 | |
| avg_y = (left_offset[1] + right_offset[1]) / 2 | |
| if abs(avg_x) < GAZE_THRESHOLD and abs(avg_y) < GAZE_THRESHOLD: | |
| return "Center" | |
| if avg_x > GAZE_THRESHOLD: | |
| return "Right" | |
| if avg_x < -GAZE_THRESHOLD: | |
| return "Left" | |
| if avg_y < -GAZE_THRESHOLD: | |
| return "Up" | |
| if avg_y > GAZE_THRESHOLD: | |
| return "Down" | |
| return "Center" | |
| # DRAW OVERLAY | |
| def draw_eye_contact_overlay(frame, result): | |
| at_cam = result["looking_at_camera"] | |
| gaze = result["gaze_direction"] | |
| pct = result["eye_contact_pct"] | |
| score = result["score"] | |
| color = (0, 255, 0) if at_cam else (0, 100, 255) | |
| label = f"Gaze: {gaze} ({'ON camera' if at_cam else 'OFF camera'})" | |
| cv2.putText(frame, label, (10, 130), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) | |
| cv2.putText(frame, f"Eye Contact: {pct}% | Score: {score}/100", (10, 165), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.65, (200, 200, 200), 1) | |
| # Mini gaze indicator box (top right corner) | |
| bx, by, bw, bh = frame.shape[1] - 90, 10, 80, 60 | |
| cv2.rectangle(frame, (bx, by), (bx + bw, by + bh), (60, 60, 60), -1) | |
| cv2.rectangle(frame, (bx, by), (bx + bw, by + bh), (120, 120, 120), 1) | |
| cx = bx + bw // 2 | |
| cy = by + bh // 2 | |
| lo = result["left_offset"] | |
| dot_x = int(cx + lo[0] * 25) | |
| dot_y = int(cy + lo[1] * 20) | |
| dot_x = max(bx + 5, min(bx + bw - 5, dot_x)) | |
| dot_y = max(by + 5, min(by + bh - 5, dot_y)) | |
| cv2.circle(frame, (cx, cy), 3, (80, 80, 80), -1) | |
| cv2.circle(frame, (dot_x, dot_y), 6, color, -1) | |
| cv2.putText(frame, "gaze", (bx + 22, by + bh + 12), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.35, (150, 150, 150), 1) | |
| return frame | |
| # TEST: IMAGE | |
| def test_on_image(image_path: str): | |
| import sys, os | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from face_landmarks import FaceLandmarkExtractor | |
| frame = cv2.imread(image_path) | |
| if frame is None: | |
| print(f"[ERROR] Cannot load: {image_path}") | |
| return | |
| extractor = FaceLandmarkExtractor() | |
| lm_result = extractor.extract_image(frame) | |
| if not lm_result["face_detected"]: | |
| print("[ERROR] No face detected.") | |
| return | |
| detector = EyeContactDetector() | |
| result = detector.detect(lm_result["key_points"], frame.shape) | |
| print("\n" + "="*45) | |
| print(" MODULE 3 — IMAGE TEST RESULT") | |
| print("="*45) | |
| print(f" Gaze Direction : {result['gaze_direction']}") | |
| print(f" Looking at Camera: {result['looking_at_camera']}") | |
| print(f" Eye Contact % : {result['eye_contact_pct']}%") | |
| print(f" Score : {result['score']}/100") | |
| print(f" Left Iris Offset : {result['left_offset']}") | |
| print(f" Right Iris Offset: {result['right_offset']}") | |
| out = lm_result["annotated_frame"].copy() | |
| out = draw_eye_contact_overlay(out, result) | |
| cv2.imshow("Module 3 - Eye Contact (any key to close)", out) | |
| cv2.waitKey(0) | |
| cv2.destroyAllWindows() | |
| extractor.release() | |
| # TEST: WEBCAM | |
| def test_webcam(): | |
| import sys, os | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from face_landmarks import FaceLandmarkExtractor | |
| extractor = FaceLandmarkExtractor() | |
| detector = EyeContactDetector() | |
| cap = cv2.VideoCapture(0) | |
| if not cap.isOpened(): | |
| print("[ERROR] Cannot open webcam.") | |
| return | |
| print("[INFO] Webcam started. Press Q to quit.") | |
| while True: | |
| ret, frame = cap.read() | |
| if not ret: | |
| break | |
| lm_result = extractor.extract(frame) | |
| disp = lm_result["annotated_frame"].copy() | |
| if lm_result["face_detected"]: | |
| result = detector.detect(lm_result["key_points"], frame.shape) | |
| disp = draw_eye_contact_overlay(disp, result) | |
| cv2.imshow("Module 3 - Eye Contact (Q to quit)", disp) | |
| if cv2.waitKey(1) & 0xFF == ord('q'): | |
| break | |
| cap.release() | |
| extractor.release() | |
| cv2.destroyAllWindows() | |
| # ENTRY POINT | |
| if __name__ == "__main__": | |
| import sys | |
| if len(sys.argv) >= 3 and sys.argv[1] == "--image": | |
| test_on_image(sys.argv[2]) | |
| sys.exit(0) | |
| elif len(sys.argv) >= 2 and sys.argv[1] == "--webcam": | |
| test_webcam() | |
| sys.exit(0) | |
| print("\n" + "="*45) | |
| print(" MODULE 3 - Eye Contact Detection") | |
| print("="*45) | |
| print(" [1] Test on IMAGE") | |
| print(" [2] Live WEBCAM") | |
| print("="*45) | |
| choice = input(" Choice (1 or 2): ").strip() | |
| if choice == "1": | |
| path = input(" Image path: ").strip().strip('"') | |
| test_on_image(path) | |
| elif choice == "2": | |
| test_webcam() | |
| else: | |
| print(" Invalid choice.") |