HarshCode commited on
Commit
28169a1
·
verified ·
1 Parent(s): 7f92554

Upload complete EyeGuard 20-20-20 main application

Browse files
Files changed (1) hide show
  1. eye_guard_2020.py +393 -1
eye_guard_2020.py CHANGED
@@ -1 +1,393 @@
1
- PLACEHOLDER
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EyeGuard 20-20-20: Robust Eye Rest Enforcement System
3
+ =======================================================
4
+
5
+ Enforces the 20-20-20 rule for eye health:
6
+ - Every 20 minutes of screen time
7
+ - Look at something 20 feet away
8
+ - For at least 20 seconds
9
+
10
+ Anti-spoofing features:
11
+ - BOTH eyes must be closed for the full 20 seconds
12
+ - One-eye-closed tricks are detected and rejected
13
+ - Face must remain visible throughout the rest period
14
+ - Blink detection prevents counting short blinks as rest
15
+
16
+ Uses MediaPipe Face Mesh for precise per-eye landmark tracking,
17
+ with EAR (Eye Aspect Ratio) geometric calculation for each eye independently.
18
+ """
19
+
20
+ import cv2
21
+ import mediapipe as mp
22
+ import numpy as np
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum, auto
26
+ from typing import Optional, Tuple, List
27
+ import warnings
28
+ warnings.filterwarnings("ignore")
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Constants
32
+ # ---------------------------------------------------------------------------
33
+
34
+ # MediaPipe Face Mesh eye landmark indices
35
+ LEFT_EYE_IDX = [362, 385, 387, 263, 373, 380]
36
+ RIGHT_EYE_IDX = [33, 160, 158, 133, 153, 144]
37
+
38
+ # EAR thresholds (tuned for typical webcam distances)
39
+ EAR_OPEN_THRESHOLD = 0.22
40
+ EAR_CLOSED_THRESHOLD = 0.18
41
+
42
+ # Timing constants (20-20-20 rule)
43
+ SCREEN_TIME_ALERT_SECONDS = 20 * 60
44
+ REST_DURATION_SECONDS = 20
45
+ MIN_CONSECUTIVE_CLOSED_FRAMES = 15
46
+ BLINK_MAX_DURATION_FRAMES = 10
47
+
48
+
49
+ class Status(Enum):
50
+ SCREENING = auto()
51
+ ALERT = auto()
52
+ RESTING = auto()
53
+ REST_COMPLETE = auto()
54
+ SPOOFING = auto()
55
+ NO_FACE = auto()
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Geometry helpers
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def euclidean(p1, p2) -> float:
63
+ return np.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
64
+
65
+
66
+ def eye_aspect_ratio(landmarks, eye_indices, img_w, img_h) -> float:
67
+ pts = [(landmarks[i].x * img_w, landmarks[i].y * img_h) for i in eye_indices]
68
+ v1 = euclidean(pts[1], pts[5])
69
+ v2 = euclidean(pts[2], pts[4])
70
+ h = euclidean(pts[0], pts[3])
71
+ if h < 1e-6:
72
+ return 0.0
73
+ return (v1 + v2) / (2.0 * h)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Tracker state
78
+ # ---------------------------------------------------------------------------
79
+
80
+ @dataclass
81
+ class EyeState:
82
+ ear: float = 1.0
83
+ is_closed: bool = False
84
+ closed_start: Optional[float] = None
85
+ closed_frames: int = 0
86
+
87
+
88
+ @dataclass
89
+ class TrackerState:
90
+ status: Status = Status.SCREENING
91
+ screen_timer_start: float = field(default_factory=time.time)
92
+ rest_start_time: Optional[float] = None
93
+ rest_completed_time: Optional[float] = None
94
+ last_face_seen: float = field(default_factory=time.time)
95
+ left_eye: EyeState = field(default_factory=EyeState)
96
+ right_eye: EyeState = field(default_factory=EyeState)
97
+ ear_history_left: List[float] = field(default_factory=list)
98
+ ear_history_right: List[float] = field(default_factory=list)
99
+ history_maxlen: int = 5
100
+ spoofing_detected_at: Optional[float] = None
101
+ spoofing_count: int = 0
102
+ blink_count: int = 0
103
+ last_blink_end: float = 0.0
104
+ total_rests_completed: int = 0
105
+ total_rest_seconds: float = 0.0
106
+ failed_rests: int = 0
107
+ fps: float = 30.0
108
+ frame_times: List[float] = field(default_factory=list)
109
+
110
+
111
+ def update_eye_state(eye, ear, threshold_open, threshold_closed):
112
+ eye.ear = ear
113
+ if eye.is_closed:
114
+ if ear > threshold_open:
115
+ eye.is_closed = False
116
+ eye.closed_start = None
117
+ eye.closed_frames = 0
118
+ else:
119
+ if ear < threshold_closed:
120
+ eye.is_closed = True
121
+ eye.closed_start = time.time()
122
+ eye.closed_frames = 1
123
+ else:
124
+ eye.closed_frames = 0
125
+ if eye.is_closed:
126
+ eye.closed_frames += 1
127
+ return eye
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Main Tracker
132
+ # ---------------------------------------------------------------------------
133
+
134
+ class EyeGuard2020:
135
+ def __init__(self, ear_open=EAR_OPEN_THRESHOLD, ear_closed=EAR_CLOSED_THRESHOLD):
136
+ self.ear_open = ear_open
137
+ self.ear_closed = ear_closed
138
+ self.state = TrackerState()
139
+ self.mp_face_mesh = mp.solutions.face_mesh
140
+ self.face_mesh = self.mp_face_mesh.FaceMesh(
141
+ max_num_faces=1, refine_landmarks=True,
142
+ min_detection_confidence=0.5, min_tracking_confidence=0.5
143
+ )
144
+ self.mp_drawing = mp.solutions.drawing_utils
145
+ self.mp_drawing_styles = mp.solutions.drawing_styles
146
+ self.running = False
147
+
148
+ def _smooth_ear(self, history, new_val):
149
+ history.append(new_val)
150
+ if len(history) > self.state.history_maxlen:
151
+ history.pop(0)
152
+ return np.median(history)
153
+
154
+ def process_frame(self, frame: np.ndarray) -> Tuple[np.ndarray, TrackerState]:
155
+ h, w = frame.shape[:2]
156
+ now = time.time()
157
+ self.state.frame_times.append(now)
158
+ if len(self.state.frame_times) > 30:
159
+ self.state.frame_times.pop(0)
160
+ if len(self.state.frame_times) > 1:
161
+ self.state.fps = len(self.state.frame_times) / (self.state.frame_times[-1] - self.state.frame_times[0])
162
+
163
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
164
+ rgb.flags.writeable = False
165
+ results = self.face_mesh.process(rgb)
166
+ rgb.flags.writeable = True
167
+ face_visible = results.multi_face_landmarks is not None
168
+
169
+ if not face_visible:
170
+ self.state.last_face_seen = now
171
+ if self.state.status in (Status.RESTING, Status.SPOOFING):
172
+ if self.state.status == Status.RESTING:
173
+ self.state.failed_rests += 1
174
+ self.state.status = Status.NO_FACE
175
+ self.state.rest_start_time = None
176
+ elif self.state.status == Status.ALERT:
177
+ pass
178
+ else:
179
+ self.state.status = Status.NO_FACE
180
+ return self._draw_overlay(frame), self.state
181
+
182
+ self.state.last_face_seen = now
183
+ landmarks = results.multi_face_landmarks[0].landmark
184
+
185
+ ear_left_raw = eye_aspect_ratio(landmarks, LEFT_EYE_IDX, w, h)
186
+ ear_right_raw = eye_aspect_ratio(landmarks, RIGHT_EYE_IDX, w, h)
187
+ ear_left = self._smooth_ear(self.state.ear_history_left, ear_left_raw)
188
+ ear_right = self._smooth_ear(self.state.ear_history_right, ear_right_raw)
189
+
190
+ self.state.left_eye = update_eye_state(self.state.left_eye, ear_left, self.ear_open, self.ear_closed)
191
+ self.state.right_eye = update_eye_state(self.state.right_eye, ear_right, self.ear_open, self.ear_closed)
192
+
193
+ self._draw_eyes(frame, landmarks, w, h, ear_left, ear_right)
194
+
195
+ screen_elapsed = now - self.state.screen_timer_start
196
+ both_eyes_closed = self.state.left_eye.is_closed and self.state.right_eye.is_closed
197
+ one_eye_closed = (self.state.left_eye.is_closed and not self.state.right_eye.is_closed) or \
198
+ (not self.state.left_eye.is_closed and self.state.right_eye.is_closed)
199
+
200
+ if self.state.status == Status.RESTING and one_eye_closed:
201
+ self.state.spoofing_detected_at = now
202
+ self.state.spoofing_count += 1
203
+ self.state.status = Status.SPOOFING
204
+ self.state.failed_rests += 1
205
+ self.state.rest_start_time = None
206
+ return self._draw_overlay(frame), self.state
207
+
208
+ if self.state.status == Status.ALERT and one_eye_closed:
209
+ self.state.spoofing_detected_at = now
210
+ self.state.spoofing_count += 1
211
+
212
+ if self.state.status == Status.SCREENING:
213
+ if screen_elapsed >= SCREEN_TIME_ALERT_SECONDS:
214
+ self.state.status = Status.ALERT
215
+
216
+ elif self.state.status == Status.ALERT:
217
+ if both_eyes_closed and self.state.left_eye.closed_frames >= MIN_CONSECUTIVE_CLOSED_FRAMES:
218
+ self.state.status = Status.RESTING
219
+ self.state.rest_start_time = now
220
+
221
+ elif self.state.status == Status.RESTING:
222
+ if not both_eyes_closed:
223
+ self.state.failed_rests += 1
224
+ self.state.status = Status.ALERT
225
+ self.state.rest_start_time = None
226
+ else:
227
+ rest_elapsed = now - self.state.rest_start_time
228
+ if rest_elapsed >= REST_DURATION_SECONDS:
229
+ self.state.status = Status.REST_COMPLETE
230
+ self.state.rest_completed_time = now
231
+ self.state.total_rests_completed += 1
232
+ self.state.total_rest_seconds += rest_elapsed
233
+ self.state.screen_timer_start = now
234
+
235
+ elif self.state.status == Status.REST_COMPLETE:
236
+ if now - self.state.rest_completed_time > 3.0:
237
+ self.state.status = Status.SCREENING
238
+
239
+ elif self.state.status == Status.SPOOFING:
240
+ if now - self.state.spoofing_detected_at > 3.0:
241
+ self.state.status = Status.ALERT
242
+
243
+ elif self.state.status == Status.NO_FACE:
244
+ if face_visible:
245
+ if screen_elapsed >= SCREEN_TIME_ALERT_SECONDS:
246
+ self.state.status = Status.ALERT
247
+ else:
248
+ self.state.status = Status.SCREENING
249
+
250
+ return self._draw_overlay(frame), self.state
251
+
252
+ def _draw_eyes(self, frame, landmarks, w, h, ear_left, ear_right):
253
+ left_pts = np.array([
254
+ [int(landmarks[i].x * w), int(landmarks[i].y * h)] for i in LEFT_EYE_IDX
255
+ ], np.int32)
256
+ color_left = (0, 255, 0) if not self.state.left_eye.is_closed else (0, 0, 255)
257
+ cv2.polylines(frame, [left_pts], True, color_left, 2)
258
+ cv2.putText(frame, f"L-EAR: {ear_left:.3f}", (left_pts[0][0] - 30, left_pts[0][1] - 10),
259
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, color_left, 2)
260
+
261
+ right_pts = np.array([
262
+ [int(landmarks[i].x * w), int(landmarks[i].y * h)] for i in RIGHT_EYE_IDX
263
+ ], np.int32)
264
+ color_right = (0, 255, 0) if not self.state.right_eye.is_closed else (0, 0, 255)
265
+ cv2.polylines(frame, [right_pts], True, color_right, 2)
266
+ cv2.putText(frame, f"R-EAR: {ear_right:.3f}", (right_pts[0][0] - 30, right_pts[0][1] - 10),
267
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, color_right, 2)
268
+
269
+ def _draw_overlay(self, frame):
270
+ h, w = frame.shape[:2]
271
+ now = time.time()
272
+ overlay = frame.copy()
273
+ cv2.rectangle(overlay, (0, 0), (w, 90), (0, 0, 0), -1)
274
+ frame = cv2.addWeighted(overlay, 0.6, frame, 0.4, 0)
275
+
276
+ cv2.putText(frame, "EyeGuard 20-20-20", (10, 30),
277
+ cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 255), 2)
278
+ cv2.putText(frame, f"FPS: {self.state.fps:.1f}", (w - 120, 30),
279
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1)
280
+
281
+ screen_elapsed = now - self.state.screen_timer_start
282
+ screen_left = max(0, SCREEN_TIME_ALERT_SECONDS - screen_elapsed)
283
+ mins, secs = divmod(int(screen_left), 60)
284
+ timer_color = (0, 255, 0) if screen_left > 60 else (0, 165, 255) if screen_left > 30 else (0, 0, 255)
285
+ cv2.putText(frame, f"Next break in: {mins:02d}:{secs:02d}", (10, 60),
286
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, timer_color, 2)
287
+
288
+ banner_y = h - 80
289
+ if self.state.status == Status.SCREENING:
290
+ msg, color = "Keep working! Eyes healthy.", (0, 255, 0)
291
+ elif self.state.status == Status.ALERT:
292
+ msg, color = "TIME FOR A BREAK! Close BOTH eyes for 20 seconds", (0, 0, 255)
293
+ if int(now * 2) % 2 == 0:
294
+ cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (0, 0, 255), -1)
295
+ elif self.state.status == Status.RESTING:
296
+ rest_elapsed = now - self.state.rest_start_time
297
+ rest_left = max(0, REST_DURATION_SECONDS - rest_elapsed)
298
+ msg, color = f"RESTING... Keep both eyes closed: {rest_left:.1f}s left", (255, 255, 0)
299
+ cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (255, 255, 0), -1)
300
+ elif self.state.status == Status.REST_COMPLETE:
301
+ msg, color = "Great job! Break complete. Back to work!", (0, 255, 0)
302
+ cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (0, 255, 0), -1)
303
+ elif self.state.status == Status.SPOOFING:
304
+ msg, color = "SPOOFING DETECTED! One eye closed trick won't work!", (0, 0, 255)
305
+ cv2.rectangle(frame, (0, banner_y - 10), (w, banner_y + 40), (0, 0, 255), -1)
306
+ elif self.state.status == Status.NO_FACE:
307
+ msg, color = "No face detected. Please position yourself in front of camera.", (128, 128, 128)
308
+ else:
309
+ msg, color = "Unknown state", (128, 128, 128)
310
+
311
+ text_size = cv2.getTextSize(msg, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2)[0]
312
+ text_x = (w - text_size[0]) // 2
313
+ bg_color = (0, 0, 0) if self.state.status in (
314
+ Status.ALERT, Status.RESTING, Status.REST_COMPLETE, Status.SPOOFING) else None
315
+ cv2.putText(frame, msg, (text_x, banner_y + 20),
316
+ cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0) if bg_color else color, 2)
317
+
318
+ eye_status_y = 110
319
+ le_status = "CLOSED" if self.state.left_eye.is_closed else "OPEN"
320
+ re_status = "CLOSED" if self.state.right_eye.is_closed else "OPEN"
321
+ le_color = (0, 0, 255) if self.state.left_eye.is_closed else (0, 255, 0)
322
+ re_color = (0, 0, 255) if self.state.right_eye.is_closed else (0, 255, 0)
323
+
324
+ cv2.putText(frame, f"Left Eye: {le_status}", (w - 220, eye_status_y),
325
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, le_color, 2)
326
+ cv2.putText(frame, f"Right Eye: {re_status}", (w - 220, eye_status_y + 25),
327
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, re_color, 2)
328
+
329
+ stats_y = h - 10
330
+ cv2.putText(frame,
331
+ f"Completed: {self.state.total_rests_completed} | Failed: {self.state.failed_rests} | Spoofs: {self.state.spoofing_count}",
332
+ (10, stats_y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
333
+
334
+ return frame
335
+
336
+ def run(self, source=0):
337
+ cap = cv2.VideoCapture(source)
338
+ if not cap.isOpened():
339
+ print(f"Failed to open video source: {source}")
340
+ return
341
+
342
+ print("=" * 60)
343
+ print("EyeGuard 20-20-20 Started!")
344
+ print("- Every 20 minutes, close BOTH eyes for 20 seconds")
345
+ print("- One-eye tricks are detected and rejected!")
346
+ print("- Press 'q' to quit, 'r' to reset screen timer, 't' for test mode")
347
+ print("=" * 60)
348
+
349
+ self.running = True
350
+ try:
351
+ while self.running:
352
+ ret, frame = cap.read()
353
+ if not ret:
354
+ break
355
+ frame = cv2.flip(frame, 1)
356
+ annotated, state = self.process_frame(frame)
357
+ cv2.imshow("EyeGuard 20-20-20", annotated)
358
+ key = cv2.waitKey(1) & 0xFF
359
+ if key == ord('q'):
360
+ break
361
+ elif key == ord('r'):
362
+ self.state.screen_timer_start = time.time()
363
+ print("Timer reset!")
364
+ elif key == ord('t'):
365
+ self.state.screen_timer_start = time.time() - SCREEN_TIME_ALERT_SECONDS - 1
366
+ print("Test mode: forcing alert!")
367
+ finally:
368
+ self.running = False
369
+ cap.release()
370
+ cv2.destroyAllWindows()
371
+ self.face_mesh.close()
372
+ self._print_summary()
373
+
374
+ def _print_summary(self):
375
+ print("\n" + "=" * 60)
376
+ print("SESSION SUMMARY")
377
+ print("=" * 60)
378
+ print(f"Total rests completed: {self.state.total_rests_completed}")
379
+ print(f"Total rest time: {self.state.total_rest_seconds:.1f} seconds")
380
+ print(f"Failed rests: {self.state.failed_rests}")
381
+ print(f"Spoofing attempts: {self.state.spoofing_count}")
382
+ print("=" * 60)
383
+
384
+
385
+ if __name__ == "__main__":
386
+ import argparse
387
+ parser = argparse.ArgumentParser(description="EyeGuard 20-20-20 Rule Enforcer")
388
+ parser.add_argument("--camera", type=int, default=0)
389
+ parser.add_argument("--ear-open", type=float, default=EAR_OPEN_THRESHOLD)
390
+ parser.add_argument("--ear-closed", type=float, default=EAR_CLOSED_THRESHOLD)
391
+ args = parser.parse_args()
392
+ guard = EyeGuard2020(ear_open=args.ear_open, ear_closed=args.ear_closed)
393
+ guard.run(source=args.camera)