-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdetection_engine.py
More file actions
345 lines (285 loc) · 10.5 KB
/
Copy pathdetection_engine.py
File metadata and controls
345 lines (285 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
"""
Detection Engine - Pro-Vision v2.0
Handles face, eye, and drowsiness detection.
Pipeline:
1. CLAHE preprocessing
2. Haar-cascade face detection
3. Eye detection within upper-face ROI
4. EAR-inspired closure ratio + temporal smoothing
5. DrowsinessClassifier scoring (0-100)
"""
import cv2
import numpy as np
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
import json
# ---------------------------------------------------------------------------
# Domain types
# ---------------------------------------------------------------------------
class DrowsinessLevel(Enum):
AWAKE = "AWAKE"
WARNING = "WARNING"
DROWSY = "DROWSY"
OFFLINE = "OFFLINE"
@dataclass
class DetectionMetrics:
"""All detection results for a single processed frame."""
status: DrowsinessLevel
drowsy_score: float
eyes_detected: int
faces_detected: int
is_blink: bool
yawn_detected: bool
frame_annotated: np.ndarray = field(default=None, repr=False)
# ---------------------------------------------------------------------------
# Face / eye detector
# ---------------------------------------------------------------------------
class FaceDetector:
"""Wraps OpenCV Haar cascades with CLAHE preprocessing."""
def __init__(self, config: dict):
cfg = config["detection"]
self._cfg = cfg
self.face_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
self.eye_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_eye.xml"
)
self.smile_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_smile.xml"
)
self.clahe = cv2.createCLAHE(
clipLimit=cfg["clahe_clip_limit"],
tileGridSize=(cfg["clahe_tile_size"], cfg["clahe_tile_size"]),
)
# --- Frame preprocessing ---
def preprocess(self, frame: np.ndarray) -> np.ndarray:
"""Convert to grayscale and apply CLAHE."""
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return self.clahe.apply(gray)
# --- Detection calls ---
def detect_faces(self, gray: np.ndarray) -> list:
cfg = self._cfg
return self.face_cascade.detectMultiScale(
gray,
scaleFactor=cfg["face_scale_factor"],
minNeighbors=cfg["face_min_neighbors"],
minSize=(cfg["face_min_size"], cfg["face_min_size"]),
)
def detect_eyes(self, roi_gray: np.ndarray, sensitivity: int) -> list:
return self.eye_cascade.detectMultiScale(
roi_gray,
scaleFactor=self._cfg["eye_scale_factor"],
minNeighbors=sensitivity,
)
def detect_yawn(self, roi_gray: np.ndarray) -> list:
return self.smile_cascade.detectMultiScale(
roi_gray,
scaleFactor=1.8,
minNeighbors=20,
minSize=(25, 25),
)
# ---------------------------------------------------------------------------
# Drowsiness classifier
# ---------------------------------------------------------------------------
class DrowsinessClassifier:
"""
Scores drowsiness 0-100 using temporal smoothing over a history window.
Eye-open frames pull score down; eye-closed frames push it up.
Blink detection uses the consecutive-closure count: a very short
closure (< blink_threshold_frames) followed by re-opening counts
as a normal blink rather than drowsiness.
"""
def __init__(self, config: dict):
self._cfg = config["drowsiness"]
history_len = self._cfg["history_window"]
self._eye_history: deque = deque(maxlen=history_len)
self._score: float = 0.0
self._consecutive_closed: int = 0
self._blink_window: deque = deque(maxlen=60) # last 60 frames
# ---- public ---
def update(
self, eyes_detected: int, faces_detected: int
) -> tuple[float, bool]:
"""
Update state from the latest frame.
Returns
-------
(score, is_blink)
score : float 0-100
is_blink : True on the frame a completed blink is registered
"""
cfg = self._cfg
is_closed = faces_detected > 0 and eyes_detected == 0
is_blink = False
if is_closed:
self._consecutive_closed += 1
else:
if 0 < self._consecutive_closed <= cfg["blink_threshold_frames"]:
is_blink = True
self._consecutive_closed = 0
self._eye_history.append(1 if is_closed else 0)
# Rolling closure ratio
if self._eye_history:
ratio = sum(self._eye_history) / len(self._eye_history)
else:
ratio = 0.0
# Hysteresis scoring
if ratio > cfg["eye_closed_ratio"]:
self._score = min(100.0, self._score + cfg["score_increase"])
else:
self._score = max(0.0, self._score - cfg["score_decrease"])
return self._score, is_blink
def classify(self, score: float) -> DrowsinessLevel:
cfg = self._cfg
if score >= cfg["sleep_threshold"]:
return DrowsinessLevel.DROWSY
if score >= cfg["drowsy_threshold"]:
return DrowsinessLevel.WARNING
return DrowsinessLevel.AWAKE
# ---------------------------------------------------------------------------
# Frame annotator
# ---------------------------------------------------------------------------
class FrameAnnotator:
"""Static helpers for drawing overlays on BGR frames."""
@staticmethod
def draw_face_box(
frame: np.ndarray,
faces,
color: tuple = (255, 0, 0),
thickness: int = 2,
) -> None:
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x + w, y + h), color, thickness)
@staticmethod
def draw_eye_boxes(
frame: np.ndarray,
eyes,
x_off: int,
y_off: int,
color: tuple = (0, 255, 0),
thickness: int = 2,
) -> None:
for (ex, ey, ew, eh) in eyes:
cv2.rectangle(
frame,
(x_off + ex, y_off + ey),
(x_off + ex + ew, y_off + ey + eh),
color,
thickness,
)
@staticmethod
def draw_alert_border(
frame: np.ndarray,
color: tuple = (0, 0, 255),
thickness: int = 15,
) -> None:
h, w = frame.shape[:2]
cv2.rectangle(frame, (0, 0), (w, h), color, thickness)
@staticmethod
def draw_hud(frame: np.ndarray, lines: list[str]) -> None:
"""
Draw a multi-line HUD in the top-left corner.
Parameters
----------
lines : list of strings, each drawn on its own row.
"""
y = 28
for text in lines:
# Shadow for readability
cv2.putText(
frame, text, (9, y + 1),
cv2.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 2,
)
cv2.putText(
frame, text, (10, y),
cv2.FONT_HERSHEY_SIMPLEX, 0.62, (255, 255, 255), 1,
)
y += 26
@staticmethod
def draw_status_banner(
frame: np.ndarray,
text: str,
bg_color: tuple,
) -> None:
"""Draw a filled status banner at the bottom of the frame."""
h, w = frame.shape[:2]
banner_h = 40
overlay = frame.copy()
cv2.rectangle(overlay, (0, h - banner_h), (w, h), bg_color, -1)
cv2.addWeighted(overlay, 0.6, frame, 0.4, 0, frame)
cv2.putText(
frame, text,
(10, h - 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2,
)
# ---------------------------------------------------------------------------
# Main engine
# ---------------------------------------------------------------------------
class ProVisionEngine:
"""
Orchestrates detection, classification, and annotation.
Usage
-----
engine = ProVisionEngine("config.json")
metrics = engine.process_frame(bgr_frame, sensitivity=12)
"""
def __init__(self, config_path: str = "config.json"):
with open(config_path, "r") as fh:
self.config = json.load(fh)
self.detector = FaceDetector(self.config)
self.classifier = DrowsinessClassifier(self.config)
self.annotator = FrameAnnotator()
self.frame_count: int = 0
self._skip: int = self.config["performance"]["skip_frames"]
# Cache last detection so skipped frames still return valid data
self._last_faces = []
self._last_eyes_count = 0
self._last_yawn = False
# ---- public ---
def process_frame(
self, frame: np.ndarray, sensitivity: int = 12
) -> DetectionMetrics:
"""
Run the full detection pipeline on one BGR frame.
Frame skipping applies to the expensive cascade detection;
classifier update and annotation run every frame.
"""
self.frame_count += 1
run_detection = (self.frame_count % self._skip) == 0
gray = self.detector.preprocess(frame)
if run_detection:
faces = self.detector.detect_faces(gray)
self._last_faces = faces
eyes_count = 0
yawn = False
for (x, y, w, h) in faces:
# Upper ~30 % of face = eye region
ey_top = y + int(h * 0.22)
ey_bot = y + int(h * 0.52)
roi_eye = gray[ey_top:ey_bot, x: x + w]
eyes = self.detector.detect_eyes(roi_eye, sensitivity)
eyes_count += len(eyes)
self.annotator.draw_face_box(frame, [(x, y, w, h)])
self.annotator.draw_eye_boxes(frame, eyes, x, ey_top)
# Yawn: open mouth detected in lower face ROI
roi_mouth = gray[y + int(h * 0.60): y + int(h * 0.95), x: x + w]
yawn = yawn or len(self.detector.detect_yawn(roi_mouth)) > 0
self._last_eyes_count = eyes_count
self._last_yawn = yawn
else:
faces = self._last_faces
eyes_count = self._last_eyes_count
yawn = self._last_yawn
score, is_blink = self.classifier.update(eyes_count, len(faces))
status = self.classifier.classify(score)
return DetectionMetrics(
status=status,
drowsy_score=score,
eyes_detected=eyes_count,
faces_detected=len(faces),
is_blink=is_blink,
yawn_detected=yawn,
frame_annotated=frame,
)