| """ |
| EyeGuard 20-20-20: Robust Eye Rest Enforcement System |
| ======================================================= |
| |
| Enforces the 20-20-20 rule for eye health: |
| - Every 20 minutes of screen time |
| - Look at something 20 feet away |
| - For at least 20 seconds |
| |
| Anti-spoofing features: |
| - BOTH eyes must be closed for the full 20 seconds |
| - One-eye-closed tricks are detected and rejected |
| - Face must remain visible throughout the rest period |
| - Blink detection prevents counting short blinks as rest |
| |
| Uses MediaPipe Face Mesh for precise per-eye landmark tracking, |
| with EAR (Eye Aspect Ratio) geometric calculation for each eye independently. |
| """ |
|
|
| import cv2 |
| import mediapipe as mp |
| import numpy as np |
| import time |
| from dataclasses import dataclass, field |
| from enum import Enum, auto |
| from typing import Optional, Tuple, List |
| import warnings |
| warnings.filterwarnings("ignore") |
|
|
| |
| |
| |
|
|
| |
| LEFT_EYE_IDX = [362, 385, 387, 263, 373, 380] |
| RIGHT_EYE_IDX = [33, 160, 158, 133, 153, 144] |
|
|
| |
| EAR_OPEN_THRESHOLD = 0.22 |
| EAR_CLOSED_THRESHOLD = 0.18 |
|
|
| |
| SCREEN_TIME_ALERT_SECONDS = 20 * 60 |
| REST_DURATION_SECONDS = 20 |
| MIN_CONSECUTIVE_CLOSED_FRAMES = 15 |
| BLINK_MAX_DURATION_FRAMES = 10 |
|
|
|
|
| class Status(Enum): |
| SCREENING = auto() |
| ALERT = auto() |
| RESTING = auto() |
| REST_COMPLETE = auto() |
| SPOOFING = auto() |
| NO_FACE = auto() |
|
|
|
|
| |
| |
| |
|
|
| def euclidean(p1, p2) -> float: |
| return np.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) |
|
|
|
|
| def eye_aspect_ratio(landmarks, eye_indices, img_w, img_h) -> float: |
| pts = [(landmarks[i].x * img_w, landmarks[i].y * img_h) for i in eye_indices] |
| v1 = euclidean(pts[1], pts[5]) |
| v2 = euclidean(pts[2], pts[4]) |
| h = euclidean(pts[0], pts[3]) |
| if h < 1e-6: |
| return 0.0 |
| return (v1 + v2) / (2.0 * h) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class EyeState: |
| ear: float = 1.0 |
| is_closed: bool = False |
| closed_start: Optional[float] = None |
| closed_frames: int = 0 |
|
|
|
|
| @dataclass |
| class TrackerState: |
| status: Status = Status.SCREENING |
| screen_timer_start: float = field(default_factory=time.time) |
| rest_start_time: Optional[float] = None |
| rest_completed_time: Optional[float] = None |
| last_face_seen: float = field(default_factory=time.time) |
| left_eye: EyeState = field(default_factory=EyeState) |
| right_eye: EyeState = field(default_factory=EyeState) |
| ear_history_left: List[float] = field(default_factory=list) |
| ear_history_right: List[float] = field(default_factory=list) |
| history_maxlen: int = 5 |
| spoofing_detected_at: Optional[float] = None |
| spoofing_count: int = 0 |
| blink_count: int = 0 |
| last_blink_end: float = 0.0 |
| total_rests_completed: int = 0 |
| total_rest_seconds: float = 0.0 |
| failed_rests: int = 0 |
| fps: float = 30.0 |
| frame_times: List[float] = field(default_factory=list) |
|
|
|
|
| def update_eye_state(eye, ear, threshold_open, threshold_closed): |
| eye.ear = ear |
| if eye.is_closed: |
| if ear > threshold_open: |
| eye.is_closed = False |
| eye.closed_start = None |
| eye.closed_frames = 0 |
| else: |
| if ear < threshold_closed: |
| eye.is_closed = True |
| eye.closed_start = time.time() |
| eye.closed_frames = 1 |
| else: |
| eye.closed_frames = 0 |
| if eye.is_closed: |
| eye.closed_frames += 1 |
| return eye |
|
|
|
|
| |
| |
| |
|
|
| class EyeGuard2020: |
| def __init__(self, ear_open=EAR_OPEN_THRESHOLD, ear_closed=EAR_CLOSED_THRESHOLD): |
| self.ear_open = ear_open |
| self.ear_closed = ear_closed |
| self.state = TrackerState() |
| self.mp_face_mesh = mp.solutions.face_mesh |
| self.face_mesh = self.mp_face_mesh.FaceMesh( |
| max_num_faces=1, refine_landmarks=True, |
| min_detection_confidence=0.5, min_tracking_confidence=0.5 |
| ) |
| self.mp_drawing = mp.solutions.drawing_utils |
| self.mp_drawing_styles = mp.solutions.drawing_styles |
| self.running = False |
|
|
| def _smooth_ear(self, history, new_val): |
| history.append(new_val) |
| if len(history) > self.state.history_maxlen: |
| history.pop(0) |
| return np.median(history) |
|
|
| def process_frame(self, frame: np.ndarray) -> Tuple[np.ndarray, TrackerState]: |
| h, w = frame.shape[:2] |
| now = time.time() |
| self.state.frame_times.append(now) |
| if len(self.state.frame_times) > 30: |
| self.state.frame_times.pop(0) |
| if len(self.state.frame_times) > 1: |
| self.state.fps = len(self.state.frame_times) / (self.state.frame_times[-1] - self.state.frame_times[0]) |
|
|
| rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) |
| rgb.flags.writeable = False |
| results = self.face_mesh.process(rgb) |
| rgb.flags.writeable = True |
| face_visible = results.multi_face_landmarks is not None |
|
|
| if not face_visible: |
| self.state.last_face_seen = now |
| if self.state.status in (Status.RESTING, Status.SPOOFING): |
| if self.state.status == Status.RESTING: |
| self.state.failed_rests += 1 |
| self.state.status = Status.NO_FACE |
| self.state.rest_start_time = None |
| elif self.state.status == Status.ALERT: |
| pass |
| else: |
| self.state.status = Status.NO_FACE |
| return self._draw_overlay(frame), self.state |
|
|
| self.state.last_face_seen = now |
| landmarks = results.multi_face_landmarks[0].landmark |
|
|
| ear_left_raw = eye_aspect_ratio(landmarks, LEFT_EYE_IDX, w, h) |
| ear_right_raw = eye_aspect_ratio(landmarks, RIGHT_EYE_IDX, w, h) |
| ear_left = self._smooth_ear(self.state.ear_history_left, ear_left_raw) |
| ear_right = self._smooth_ear(self.state.ear_history_right, ear_right_raw) |
|
|
| self.state.left_eye = update_eye_state(self.state.left_eye, ear_left, self.ear_open, self.ear_closed) |
| self.state.right_eye = update_eye_state(self.state.right_eye, ear_right, self.ear_open, self.ear_closed) |
|
|
| self._draw_eyes(frame, landmarks, w, h, ear_left, ear_right) |
|
|
| screen_elapsed = now - self.state.screen_timer_start |
| both_eyes_closed = self.state.left_eye.is_closed and self.state.right_eye.is_closed |
| one_eye_closed = (self.state.left_eye.is_closed and not self.state.right_eye.is_closed) or \ |
| (not self.state.left_eye.is_closed and self.state.right_eye.is_closed) |
|
|
| if self.state.status == Status.RESTING and one_eye_closed: |
| self.state.spoofing_detected_at = now |
| self.state.spoofing_count += 1 |
| self.state.status = Status.SPOOFING |
| self.state.failed_rests += 1 |
| self.state.rest_start_time = None |
| return self._draw_overlay(frame), self.state |
|
|
| if self.state.status == Status.ALERT and one_eye_closed: |
| self.state.spoofing_detected_at = now |
| self.state.spoofing_count += 1 |
|
|
| if self.state.status == Status.SCREENING: |
| if screen_elapsed >= SCREEN_TIME_ALERT_SECONDS: |
| self.state.status = Status.ALERT |
|
|
| elif self.state.status == Status.ALERT: |
| if both_eyes_closed and self.state.left_eye.closed_frames >= MIN_CONSECUTIVE_CLOSED_FRAMES: |
| self.state.status = Status.RESTING |
| self.state.rest_start_time = now |
|
|
| elif self.state.status == Status.RESTING: |
| if not both_eyes_closed: |
| self.state.failed_rests += 1 |
| self.state.status = Status.ALERT |
| self.state.rest_start_time = None |
| else: |
| rest_elapsed = now - self.state.rest_start_time |
| if rest_elapsed >= REST_DURATION_SECONDS: |
| self.state.status = Status.REST_COMPLETE |
| self.state.rest_completed_time = now |
| self.state.total_rests_completed += 1 |
| self.state.total_rest_seconds += rest_elapsed |
| self.state.screen_timer_start = now |
|
|
| elif self.state.status == Status.REST_COMPLETE: |
| if now - self.state.rest_completed_time > 3.0: |
| self.state.status = Status.SCREENING |
|
|
| elif self.state.status == Status.SPOOFING: |
| if now - self.state.spoofing_detected_at > 3.0: |
| self.state.status = Status.ALERT |
|
|
| elif self.state.status == Status.NO_FACE: |
| if face_visible: |
| if screen_elapsed >= SCREEN_TIME_ALERT_SECONDS: |
| self.state.status = Status.ALERT |
| else: |
| self.state.status = Status.SCREENING |
|
|
| return self._draw_overlay(frame), self.state |
|
|
| def _draw_eyes(self, frame, landmarks, w, h, ear_left, ear_right): |
| left_pts = np.array([ |
| [int(landmarks[i].x * w), int(landmarks[i].y * h)] for i in LEFT_EYE_IDX |
| ], np.int32) |
| color_left = (0, 255, 0) if not self.state.left_eye.is_closed else (0, 0, 255) |
| cv2.polylines(frame, [left_pts], True, color_left, 2) |
| cv2.putText(frame, f"L-EAR: {ear_left:.3f}", (left_pts[0][0] - 30, left_pts[0][1] - 10), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, color_left, 2) |
|
|
| right_pts = np.array([ |
| [int(landmarks[i].x * w), int(landmarks[i].y * h)] for i in RIGHT_EYE_IDX |
| ], np.int32) |
| color_right = (0, 255, 0) if not self.state.right_eye.is_closed else (0, 0, 255) |
| cv2.polylines(frame, [right_pts], True, color_right, 2) |
| cv2.putText(frame, f"R-EAR: {ear_right:.3f}", (right_pts[0][0] - 30, right_pts[0][1] - 10), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, color_right, 2) |
|
|
| def _draw_overlay(self, frame): |
| h, w = frame.shape[:2] |
| now = time.time() |
| overlay = frame.copy() |
| cv2.rectangle(overlay, (0, 0), (w, 90), (0, 0, 0), -1) |
| frame = cv2.addWeighted(overlay, 0.6, frame, 0.4, 0) |
|
|
| cv2.putText(frame, "EyeGuard 20-20-20", (10, 30), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 255), 2) |
| cv2.putText(frame, f"FPS: {self.state.fps:.1f}", (w - 120, 30), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) |
|
|
| screen_elapsed = now - self.state.screen_timer_start |
| screen_left = max(0, SCREEN_TIME_ALERT_SECONDS - screen_elapsed) |
| mins, secs = divmod(int(screen_left), 60) |
| timer_color = (0, 255, 0) if screen_left > 60 else (0, 165, 255) if screen_left > 30 else (0, 0, 255) |
| cv2.putText(frame, f"Next break in: {mins:02d}:{secs:02d}", (10, 60), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.7, timer_color, 2) |
|
|
| banner_y = h - 80 |
| if self.state.status == Status.SCREENING: |
| msg, color = "Keep working! Eyes healthy.", (0, 255, 0) |
| elif self.state.status == Status.ALERT: |
| msg, color = "TIME FOR A BREAK! Close BOTH eyes for 20 seconds", (0, 0, 255) |
| if int(now * 2) % 2 == 0: |
| cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (0, 0, 255), -1) |
| elif self.state.status == Status.RESTING: |
| rest_elapsed = now - self.state.rest_start_time |
| rest_left = max(0, REST_DURATION_SECONDS - rest_elapsed) |
| msg, color = f"RESTING... Keep both eyes closed: {rest_left:.1f}s left", (255, 255, 0) |
| cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (255, 255, 0), -1) |
| elif self.state.status == Status.REST_COMPLETE: |
| msg, color = "Great job! Break complete. Back to work!", (0, 255, 0) |
| cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (0, 255, 0), -1) |
| elif self.state.status == Status.SPOOFING: |
| msg, color = "SPOOFING DETECTED! One eye closed trick won't work!", (0, 0, 255) |
| cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (0, 0, 255), -1) |
| elif self.state.status == Status.NO_FACE: |
| msg, color = "No face detected. Please position yourself in front of camera.", (128, 128, 128) |
| else: |
| msg, color = "Unknown state", (128, 128, 128) |
|
|
| text_size = cv2.getTextSize(msg, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2)[0] |
| text_x = (w - text_size[0]) // 2 |
| bg_color = (0, 0, 0) if self.state.status in ( |
| Status.ALERT, Status.RESTING, Status.REST_COMPLETE, Status.SPOOFING) else None |
| cv2.putText(frame, msg, (text_x, banner_y + 20), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0) if bg_color else color, 2) |
|
|
| eye_status_y = 110 |
| le_status = "CLOSED" if self.state.left_eye.is_closed else "OPEN" |
| re_status = "CLOSED" if self.state.right_eye.is_closed else "OPEN" |
| le_color = (0, 0, 255) if self.state.left_eye.is_closed else (0, 255, 0) |
| re_color = (0, 0, 255) if self.state.right_eye.is_closed else (0, 255, 0) |
|
|
| cv2.putText(frame, f"Left Eye: {le_status}", (w - 220, eye_status_y), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.6, le_color, 2) |
| cv2.putText(frame, f"Right Eye: {re_status}", (w - 220, eye_status_y + 25), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.6, re_color, 2) |
|
|
| stats_y = h - 10 |
| cv2.putText(frame, |
| f"Completed: {self.state.total_rests_completed} | Failed: {self.state.failed_rests} | Spoofs: {self.state.spoofing_count}", |
| (10, stats_y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) |
|
|
| return frame |
|
|
| def run(self, source=0): |
| cap = cv2.VideoCapture(source) |
| if not cap.isOpened(): |
| print(f"Failed to open video source: {source}") |
| return |
|
|
| print("=" * 60) |
| print("EyeGuard 20-20-20 Started!") |
| print("- Every 20 minutes, close BOTH eyes for 20 seconds") |
| print("- One-eye tricks are detected and rejected!") |
| print("- Press 'q' to quit, 'r' to reset screen timer, 't' for test mode") |
| print("=" * 60) |
|
|
| self.running = True |
| try: |
| while self.running: |
| ret, frame = cap.read() |
| if not ret: |
| break |
| frame = cv2.flip(frame, 1) |
| annotated, state = self.process_frame(frame) |
| cv2.imshow("EyeGuard 20-20-20", annotated) |
| key = cv2.waitKey(1) & 0xFF |
| if key == ord('q'): |
| break |
| elif key == ord('r'): |
| self.state.screen_timer_start = time.time() |
| print("Timer reset!") |
| elif key == ord('t'): |
| self.state.screen_timer_start = time.time() - SCREEN_TIME_ALERT_SECONDS - 1 |
| print("Test mode: forcing alert!") |
| finally: |
| self.running = False |
| cap.release() |
| cv2.destroyAllWindows() |
| self.face_mesh.close() |
| self._print_summary() |
|
|
| def _print_summary(self): |
| print("\n" + "=" * 60) |
| print("SESSION SUMMARY") |
| print("=" * 60) |
| print(f"Total rests completed: {self.state.total_rests_completed}") |
| print(f"Total rest time: {self.state.total_rest_seconds:.1f} seconds") |
| print(f"Failed rests: {self.state.failed_rests}") |
| print(f"Spoofing attempts: {self.state.spoofing_count}") |
| print("=" * 60) |
|
|
|
|
| if __name__ == "__main__": |
| import argparse |
| parser = argparse.ArgumentParser(description="EyeGuard 20-20-20 Rule Enforcer") |
| parser.add_argument("--camera", type=int, default=0) |
| parser.add_argument("--ear-open", type=float, default=EAR_OPEN_THRESHOLD) |
| parser.add_argument("--ear-closed", type=float, default=EAR_CLOSED_THRESHOLD) |
| args = parser.parse_args() |
| guard = EyeGuard2020(ear_open=args.ear_open, ear_closed=args.ear_closed) |
| guard.run(source=args.camera) |
|
|