pyadas 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyadas/__init__.py +0 -0
- pyadas/camera/core.py +96 -0
- pyadas/driver_state/state_estimator.py +34 -0
- pyadas/drowsiness/temporal.py +58 -0
- pyadas/perception/ear.py +48 -0
- pyadas/perception/head_pose.py +70 -0
- pyadas/perception/mar.py +35 -0
- pyadas/pyadas_global.py +23 -0
- pyadas/telemetry/logger.py +46 -0
- pyadas/ui/main_window.py +309 -0
- pyadas-0.1.0.dist-info/METADATA +77 -0
- pyadas-0.1.0.dist-info/RECORD +15 -0
- pyadas-0.1.0.dist-info/WHEEL +5 -0
- pyadas-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyadas-0.1.0.dist-info/top_level.txt +1 -0
pyadas/__init__.py
ADDED
|
File without changes
|
pyadas/camera/core.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import time
|
|
3
|
+
import mediapipe as mp
|
|
4
|
+
from pyadas.perception.ear import get_ear_metrics
|
|
5
|
+
from pyadas.perception.mar import get_mar_metric
|
|
6
|
+
from pyadas.perception.head_pose import get_head_pose
|
|
7
|
+
from pyadas.drowsiness.temporal import TemporalAnalyzer
|
|
8
|
+
from pyadas.driver_state.state_estimator import DriverStateEstimator
|
|
9
|
+
# NEW: Importar o Logger
|
|
10
|
+
from pyadas.telemetry.logger import TelemetryLogger
|
|
11
|
+
|
|
12
|
+
def test_camera_feed():
|
|
13
|
+
cap = cv2.VideoCapture(0)
|
|
14
|
+
p_time = 0
|
|
15
|
+
|
|
16
|
+
mp_face_mesh = mp.solutions.face_mesh
|
|
17
|
+
mp_drawing = mp.solutions.drawing_utils
|
|
18
|
+
mp_drawing_styles = mp.solutions.drawing_styles
|
|
19
|
+
|
|
20
|
+
face_mesh = mp_face_mesh.FaceMesh(
|
|
21
|
+
max_num_faces=1,
|
|
22
|
+
refine_landmarks=True,
|
|
23
|
+
min_detection_confidence=0.5,
|
|
24
|
+
min_tracking_confidence=0.5
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
analyzer = TemporalAnalyzer(calibration_frames=100, perclos_window_frames=150)
|
|
28
|
+
state_estimator = DriverStateEstimator()
|
|
29
|
+
# NEW: Instanciar o gravador de telemetria
|
|
30
|
+
logger = TelemetryLogger(log_dir="data")
|
|
31
|
+
|
|
32
|
+
print("Iniciando captura. Pressione 'q' para encerrar.")
|
|
33
|
+
|
|
34
|
+
while cap.isOpened():
|
|
35
|
+
success, frame = cap.read()
|
|
36
|
+
if not success:
|
|
37
|
+
print("Failed to capture video. Check the camera connection.")
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
frame = cv2.resize(frame, (1280, 720))
|
|
41
|
+
h_frame, w_frame, _ = frame.shape
|
|
42
|
+
|
|
43
|
+
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
44
|
+
results = face_mesh.process(frame_rgb)
|
|
45
|
+
|
|
46
|
+
c_time = time.time()
|
|
47
|
+
fps = 1 / (c_time - p_time) if (c_time - p_time) > 0 else 0
|
|
48
|
+
p_time = c_time
|
|
49
|
+
|
|
50
|
+
if results.multi_face_landmarks:
|
|
51
|
+
for face_landmarks in results.multi_face_landmarks:
|
|
52
|
+
mp_drawing.draw_landmarks(
|
|
53
|
+
image=frame,
|
|
54
|
+
landmark_list=face_landmarks,
|
|
55
|
+
connections=mp_face_mesh.FACEMESH_TESSELATION,
|
|
56
|
+
landmark_drawing_spec=None,
|
|
57
|
+
connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_tesselation_style()
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
ear_left, ear_right, ear_avg = get_ear_metrics(face_landmarks, w_frame, h_frame)
|
|
61
|
+
mar = get_mar_metric(face_landmarks, w_frame, h_frame)
|
|
62
|
+
yaw, pitch, roll = get_head_pose(face_landmarks, w_frame, h_frame)
|
|
63
|
+
|
|
64
|
+
analyzer.update(ear_avg, mar)
|
|
65
|
+
perclos = analyzer.get_perclos()
|
|
66
|
+
is_calibrated = analyzer.is_calibrated
|
|
67
|
+
calib_status = analyzer.get_calibration_status()
|
|
68
|
+
|
|
69
|
+
current_state = state_estimator.estimate_state(is_calibrated, perclos, mar, yaw)
|
|
70
|
+
|
|
71
|
+
# NEW: Gravar os dados da iteração atual no arquivo CSV
|
|
72
|
+
logger.log(fps, ear_left, ear_right, ear_avg, mar, perclos, yaw, pitch, roll, "UNKNOWN", current_state)
|
|
73
|
+
|
|
74
|
+
# Display Metrics
|
|
75
|
+
cv2.putText(frame, f'EAR Avg: {ear_avg:.2f}', (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
|
76
|
+
cv2.putText(frame, f'MAR: {mar:.2f}', (20, 130), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 165, 255), 2)
|
|
77
|
+
cv2.putText(frame, f'Yaw: {yaw:.1f}', (1000, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2)
|
|
78
|
+
|
|
79
|
+
if is_calibrated:
|
|
80
|
+
cv2.putText(frame, f'STATE: {current_state}', (20, 170), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 255), 2)
|
|
81
|
+
cv2.putText(frame, f'PERCLOS: {perclos*100:.1f}%', (20, 210), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
|
82
|
+
else:
|
|
83
|
+
cv2.putText(frame, f'Calibrating... {int(calib_status*100)}%', (20, 170), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
|
|
84
|
+
cv2.putText(frame, f'STATE: {current_state}', (20, 210), cv2.FONT_HERSHEY_SIMPLEX, 1, (128, 128, 128), 2)
|
|
85
|
+
|
|
86
|
+
cv2.putText(frame, f'FPS: {int(fps)}', (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
|
|
87
|
+
cv2.imshow("pyadas - Hardware Test", frame)
|
|
88
|
+
|
|
89
|
+
if cv2.waitKey(1) & 0xFF == ord('q'):
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
cap.release()
|
|
93
|
+
cv2.destroyAllWindows()
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
test_camera_feed()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class DriverStateEstimator:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
# Thresholds baseados nas métricas calculadas e calibração dinâmica
|
|
4
|
+
self.perclos_drowsy_threshold = 0.20 # > 20% do tempo de olhos fechados = sonolência
|
|
5
|
+
self.perclos_microsleep_threshold = 0.60 # > 60% = potencial microssono
|
|
6
|
+
self.mar_yawning_threshold = 0.40 # MAR alto
|
|
7
|
+
self.yaw_distracted_threshold = 30.0 # Cabeça virada mais que 30 graus
|
|
8
|
+
|
|
9
|
+
def estimate_state(self, is_calibrated, perclos, mar, yaw):
|
|
10
|
+
"""
|
|
11
|
+
Determina o estado atual do motorista com base nas métricas consolidadas.
|
|
12
|
+
Prioriza estados críticos (Microsleep > Drowsy > Yawning > Distracted).
|
|
13
|
+
"""
|
|
14
|
+
if not is_calibrated:
|
|
15
|
+
return "UNKNOWN"
|
|
16
|
+
|
|
17
|
+
state = "ALERT"
|
|
18
|
+
|
|
19
|
+
# 1. Distração (Prioridade baixa, a cabeça virada pode afetar MAR/EAR)
|
|
20
|
+
if abs(yaw) > self.yaw_distracted_threshold:
|
|
21
|
+
state = "DISTRACTED"
|
|
22
|
+
return state # Retorna cedo pois a rotação extrema distorce o cálculo dos olhos/boca
|
|
23
|
+
|
|
24
|
+
# 2. Bocejo
|
|
25
|
+
if mar > self.mar_yawning_threshold:
|
|
26
|
+
state = "YAWNING"
|
|
27
|
+
|
|
28
|
+
# 3. Sonolência / Fadiga (Sobrescreve bocejo se estiverem ocorrendo juntos)
|
|
29
|
+
if perclos >= self.perclos_microsleep_threshold:
|
|
30
|
+
state = "POTENTIAL_MICROSLEEP"
|
|
31
|
+
elif perclos >= self.perclos_drowsy_threshold:
|
|
32
|
+
state = "DROWSY"
|
|
33
|
+
|
|
34
|
+
return state
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
class TemporalAnalyzer:
|
|
5
|
+
def __init__(self, calibration_frames=100, perclos_window_frames=150):
|
|
6
|
+
"""
|
|
7
|
+
calibration_frames: Quantidade de frames para calcular o baseline inicial.
|
|
8
|
+
perclos_window_frames: Tamanho da janela deslizante para cálculo do PERCLOS.
|
|
9
|
+
"""
|
|
10
|
+
self.calibration_frames = calibration_frames
|
|
11
|
+
self.perclos_window_frames = perclos_window_frames
|
|
12
|
+
|
|
13
|
+
# Buffers circulares de histórico
|
|
14
|
+
self.ear_history = collections.deque(maxlen=perclos_window_frames)
|
|
15
|
+
self.mar_history = collections.deque(maxlen=perclos_window_frames)
|
|
16
|
+
|
|
17
|
+
# Variáveis de Baseline (Calibração Dinâmica)
|
|
18
|
+
self.baseline_ear = 0.0
|
|
19
|
+
self.baseline_mar = 0.0
|
|
20
|
+
|
|
21
|
+
# Controle de Estado
|
|
22
|
+
self.frames_processed = 0
|
|
23
|
+
self.is_calibrated = False
|
|
24
|
+
|
|
25
|
+
def update(self, ear, mar):
|
|
26
|
+
"""Alimenta o buffer com as métricas do frame atual e verifica calibração."""
|
|
27
|
+
self.ear_history.append(ear)
|
|
28
|
+
self.mar_history.append(mar)
|
|
29
|
+
|
|
30
|
+
if not self.is_calibrated:
|
|
31
|
+
self.frames_processed += 1
|
|
32
|
+
if self.frames_processed >= self.calibration_frames:
|
|
33
|
+
# Calcula a média do período de aquecimento
|
|
34
|
+
self.baseline_ear = float(np.mean(self.ear_history))
|
|
35
|
+
self.baseline_mar = float(np.mean(self.mar_history))
|
|
36
|
+
self.is_calibrated = True
|
|
37
|
+
|
|
38
|
+
def get_perclos(self, closure_threshold_ratio=0.6):
|
|
39
|
+
"""
|
|
40
|
+
Calcula o PERCLOS (Percentage of Eye Closure).
|
|
41
|
+
O olho é considerado fechado se o EAR for menor que 60% do Baseline.
|
|
42
|
+
"""
|
|
43
|
+
if not self.is_calibrated or len(self.ear_history) == 0:
|
|
44
|
+
return 0.0
|
|
45
|
+
|
|
46
|
+
# O limiar não é fixo, é relativo ao rosto do motorista atual
|
|
47
|
+
threshold = self.baseline_ear * closure_threshold_ratio
|
|
48
|
+
|
|
49
|
+
closed_frames = sum(1 for val in self.ear_history if val < threshold)
|
|
50
|
+
perclos = closed_frames / len(self.ear_history)
|
|
51
|
+
|
|
52
|
+
return perclos
|
|
53
|
+
|
|
54
|
+
def get_calibration_status(self):
|
|
55
|
+
"""Retorna o progresso da calibração (0.0 a 1.0)."""
|
|
56
|
+
if self.is_calibrated:
|
|
57
|
+
return 1.0
|
|
58
|
+
return self.frames_processed / self.calibration_frames
|
pyadas/perception/ear.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
# MediaPipe Face Mesh indices for the eyes
|
|
4
|
+
# Order: [Corner Inner/Outer, Top 1, Top 2, Corner Outer/Inner, Bottom 2, Bottom 1]
|
|
5
|
+
RIGHT_EYE_INDICES = [33, 160, 158, 133, 153, 144]
|
|
6
|
+
LEFT_EYE_INDICES = [362, 385, 387, 263, 373, 380]
|
|
7
|
+
|
|
8
|
+
def _euclidean_distance(p1, p2):
|
|
9
|
+
"""Calculates the 2D Euclidean distance between two points."""
|
|
10
|
+
return math.dist(p1, p2)
|
|
11
|
+
|
|
12
|
+
def _calculate_single_eye_ear(eye_points):
|
|
13
|
+
"""
|
|
14
|
+
Computes the Eye Aspect Ratio for a single eye.
|
|
15
|
+
Formula: EAR = (||p2-p6|| + ||p3-p5||) / (2 * ||p1-p4||)
|
|
16
|
+
"""
|
|
17
|
+
# Vertical distances
|
|
18
|
+
v1 = _euclidean_distance(eye_points[1], eye_points[5])
|
|
19
|
+
v2 = _euclidean_distance(eye_points[2], eye_points[4])
|
|
20
|
+
|
|
21
|
+
# Horizontal distance
|
|
22
|
+
h = _euclidean_distance(eye_points[0], eye_points[3])
|
|
23
|
+
|
|
24
|
+
# Avoid division by zero in case of extreme tracking anomalies
|
|
25
|
+
if h == 0:
|
|
26
|
+
return 0.0
|
|
27
|
+
|
|
28
|
+
ear = (v1 + v2) / (2.0 * h)
|
|
29
|
+
return ear
|
|
30
|
+
|
|
31
|
+
def get_ear_metrics(face_landmarks, frame_width, frame_height):
|
|
32
|
+
"""
|
|
33
|
+
Extracts the left, right, and average EAR from the detected face landmarks.
|
|
34
|
+
"""
|
|
35
|
+
def _extract_pixel_coords(indices):
|
|
36
|
+
# Converts normalized coordinates (0.0 to 1.0) into absolute pixel values
|
|
37
|
+
return [(face_landmarks.landmark[i].x * frame_width,
|
|
38
|
+
face_landmarks.landmark[i].y * frame_height) for i in indices]
|
|
39
|
+
|
|
40
|
+
right_eye_coords = _extract_pixel_coords(RIGHT_EYE_INDICES)
|
|
41
|
+
left_eye_coords = _extract_pixel_coords(LEFT_EYE_INDICES)
|
|
42
|
+
|
|
43
|
+
ear_right = _calculate_single_eye_ear(right_eye_coords)
|
|
44
|
+
ear_left = _calculate_single_eye_ear(left_eye_coords)
|
|
45
|
+
|
|
46
|
+
ear_avg = (ear_right + ear_left) / 2.0
|
|
47
|
+
|
|
48
|
+
return ear_left, ear_right, ear_avg
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
# Generic 3D face model coordinates
|
|
5
|
+
# Points: Nose tip, Chin, Right Eye Outer, Left Eye Outer, Right Mouth, Left Mouth
|
|
6
|
+
FACE_3D_MODEL_POINTS = np.array([
|
|
7
|
+
(0.0, 0.0, 0.0), # Nose tip
|
|
8
|
+
(0.0, -330.0, -65.0), # Chin
|
|
9
|
+
(225.0, 170.0, -135.0), # Right eye outer corner
|
|
10
|
+
(-225.0, 170.0, -135.0), # Left eye outer corner
|
|
11
|
+
(150.0, -150.0, -125.0), # Right mouth corner
|
|
12
|
+
(-150.0, -150.0, -125.0) # Left mouth corner
|
|
13
|
+
], dtype=np.float64)
|
|
14
|
+
|
|
15
|
+
# Corresponding MediaPipe landmark indices
|
|
16
|
+
FACE_2D_INDICES = [1, 152, 33, 263, 61, 291]
|
|
17
|
+
|
|
18
|
+
def get_head_pose(face_landmarks, frame_width, frame_height):
|
|
19
|
+
"""
|
|
20
|
+
Estimates the head pose (Yaw, Pitch, Roll) using cv2.solvePnP.
|
|
21
|
+
Returns the angles in degrees.
|
|
22
|
+
"""
|
|
23
|
+
image_points = []
|
|
24
|
+
for idx in FACE_2D_INDICES:
|
|
25
|
+
x = face_landmarks.landmark[idx].x * frame_width
|
|
26
|
+
y = face_landmarks.landmark[idx].y * frame_height
|
|
27
|
+
image_points.append((x, y))
|
|
28
|
+
|
|
29
|
+
image_points = np.array(image_points, dtype=np.float64)
|
|
30
|
+
|
|
31
|
+
# Fake camera internals (assuming no lens distortion for the prototype)
|
|
32
|
+
focal_length = frame_width
|
|
33
|
+
center = (frame_width / 2, frame_height / 2)
|
|
34
|
+
camera_matrix = np.array(
|
|
35
|
+
[[focal_length, 0, center[0]],
|
|
36
|
+
[0, focal_length, center[1]],
|
|
37
|
+
[0, 0, 1]], dtype=np.float64
|
|
38
|
+
)
|
|
39
|
+
dist_coeffs = np.zeros((4, 1))
|
|
40
|
+
|
|
41
|
+
success, rotation_vector, translation_vector = cv2.solvePnP(
|
|
42
|
+
FACE_3D_MODEL_POINTS, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not success:
|
|
46
|
+
return 0.0, 0.0, 0.0
|
|
47
|
+
|
|
48
|
+
# Convert rotation vector to rotation matrix
|
|
49
|
+
rotation_matrix, _ = cv2.Rodrigues(rotation_vector)
|
|
50
|
+
|
|
51
|
+
# Decompose the projection matrix to extract Euler angles
|
|
52
|
+
proj_matrix = np.hstack((rotation_matrix, translation_vector))
|
|
53
|
+
_, _, _, _, _, _, euler_angles = cv2.decomposeProjectionMatrix(proj_matrix)
|
|
54
|
+
|
|
55
|
+
pitch = euler_angles[0][0]
|
|
56
|
+
yaw = euler_angles[1][0]
|
|
57
|
+
roll = euler_angles[2][0]
|
|
58
|
+
|
|
59
|
+
# Normalização de Gimbal Lock para o sistema de coordenadas do OpenCV
|
|
60
|
+
if pitch > 0:
|
|
61
|
+
pitch = 180 - pitch
|
|
62
|
+
else:
|
|
63
|
+
pitch = pitch + 180
|
|
64
|
+
|
|
65
|
+
# Correção de espelhamento: se a matriz inverteu, corrigimos Yaw e Roll
|
|
66
|
+
if euler_angles[0][0] > 0:
|
|
67
|
+
yaw = -yaw
|
|
68
|
+
roll = -roll
|
|
69
|
+
|
|
70
|
+
return yaw, pitch, roll
|
pyadas/perception/mar.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
# MediaPipe inner lip indices
|
|
4
|
+
# Horizontal corners: 78 (left), 308 (right)
|
|
5
|
+
# Vertical center: 13 (top), 14 (bottom)
|
|
6
|
+
|
|
7
|
+
def _euclidean_distance(p1, p2):
|
|
8
|
+
"""Calculates the 2D Euclidean distance between two points."""
|
|
9
|
+
return math.dist(p1, p2)
|
|
10
|
+
|
|
11
|
+
def get_mar_metric(face_landmarks, frame_width, frame_height):
|
|
12
|
+
"""
|
|
13
|
+
Computes the Mouth Aspect Ratio (MAR) using the inner lips.
|
|
14
|
+
Formula: MAR = ||p13 - p14|| / ||p78 - p308||
|
|
15
|
+
"""
|
|
16
|
+
def _extract_pixel_coord(index):
|
|
17
|
+
return (face_landmarks.landmark[index].x * frame_width,
|
|
18
|
+
face_landmarks.landmark[index].y * frame_height)
|
|
19
|
+
|
|
20
|
+
# Extract coordinates
|
|
21
|
+
p_left = _extract_pixel_coord(78)
|
|
22
|
+
p_right = _extract_pixel_coord(308)
|
|
23
|
+
p_top = _extract_pixel_coord(13)
|
|
24
|
+
p_bottom = _extract_pixel_coord(14)
|
|
25
|
+
|
|
26
|
+
# Calculate distances
|
|
27
|
+
horizontal_dist = _euclidean_distance(p_left, p_right)
|
|
28
|
+
vertical_dist = _euclidean_distance(p_top, p_bottom)
|
|
29
|
+
|
|
30
|
+
# Avoid division by zero
|
|
31
|
+
if horizontal_dist == 0:
|
|
32
|
+
return 0.0
|
|
33
|
+
|
|
34
|
+
mar = vertical_dist / horizontal_dist
|
|
35
|
+
return mar
|
pyadas/pyadas_global.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from PySide6.QtWidgets import QApplication
|
|
3
|
+
from pyadas.ui.main_window import MainWindow
|
|
4
|
+
|
|
5
|
+
def pyadasGui():
|
|
6
|
+
"""
|
|
7
|
+
Função principal de inicialização da interface gráfica do pyadas.
|
|
8
|
+
Cria a instância do QApplication e exibe a MainWindow.
|
|
9
|
+
"""
|
|
10
|
+
# Verifica se já existe uma instância do QApplication (útil se rodar via Jupyter no futuro)
|
|
11
|
+
app = QApplication.instance()
|
|
12
|
+
if not app:
|
|
13
|
+
app = QApplication(sys.argv)
|
|
14
|
+
|
|
15
|
+
window = MainWindow()
|
|
16
|
+
window.show()
|
|
17
|
+
|
|
18
|
+
# Inicia o loop de eventos da interface gráfica
|
|
19
|
+
sys.exit(app.exec())
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
# Permite que o arquivo seja testado rodando diretamente pelo terminal
|
|
23
|
+
pyadasGui()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
class TelemetryLogger:
|
|
7
|
+
def __init__(self, log_dir="data"):
|
|
8
|
+
# Garante que a pasta de destino exista
|
|
9
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
# Cria um arquivo com timestamp para não sobrescrever sessões antigas
|
|
12
|
+
timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
13
|
+
self.filepath = os.path.join(log_dir, f"session_{timestamp_str}.csv")
|
|
14
|
+
|
|
15
|
+
self.headers = [
|
|
16
|
+
"timestamp", "fps", "ear_left", "ear_right", "ear_average",
|
|
17
|
+
"mar", "perclos", "yaw", "pitch", "roll", "gaze_direction", "driver_state"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Inicializa o arquivo e escreve o cabeçalho
|
|
21
|
+
with open(self.filepath, mode='w', newline='') as file:
|
|
22
|
+
writer = csv.writer(file)
|
|
23
|
+
writer.writerow(self.headers)
|
|
24
|
+
|
|
25
|
+
def log(self, fps, ear_l, ear_r, ear_avg, mar, perclos, yaw, pitch, roll, gaze, state):
|
|
26
|
+
"""Grava uma nova linha de telemetria no arquivo."""
|
|
27
|
+
current_timestamp = time.time()
|
|
28
|
+
row = [
|
|
29
|
+
f"{current_timestamp:.3f}",
|
|
30
|
+
int(fps),
|
|
31
|
+
f"{ear_l:.3f}",
|
|
32
|
+
f"{ear_r:.3f}",
|
|
33
|
+
f"{ear_avg:.3f}",
|
|
34
|
+
f"{mar:.3f}",
|
|
35
|
+
f"{perclos:.3f}",
|
|
36
|
+
f"{yaw:.1f}",
|
|
37
|
+
f"{pitch:.1f}",
|
|
38
|
+
f"{roll:.1f}",
|
|
39
|
+
gaze,
|
|
40
|
+
state
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Abre em modo append para adicionar a linha sem apagar o histórico
|
|
44
|
+
with open(self.filepath, mode='a', newline='') as file:
|
|
45
|
+
writer = csv.writer(file)
|
|
46
|
+
writer.writerow(row)
|
pyadas/ui/main_window.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import time
|
|
3
|
+
import mediapipe as mp
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from PySide6.QtCore import QThread, Signal, Qt
|
|
7
|
+
from PySide6.QtGui import QImage, QPixmap, QFont
|
|
8
|
+
# NEW: Importação do QComboBox para o seletor de câmera
|
|
9
|
+
from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
10
|
+
QLabel, QPushButton, QFrame, QGridLayout, QComboBox)
|
|
11
|
+
|
|
12
|
+
from pyadas.perception.ear import get_ear_metrics
|
|
13
|
+
from pyadas.perception.mar import get_mar_metric
|
|
14
|
+
from pyadas.perception.head_pose import get_head_pose
|
|
15
|
+
from pyadas.drowsiness.temporal import TemporalAnalyzer
|
|
16
|
+
from pyadas.driver_state.state_estimator import DriverStateEstimator
|
|
17
|
+
from pyadas.telemetry.logger import TelemetryLogger
|
|
18
|
+
|
|
19
|
+
from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
20
|
+
QLabel, QPushButton, QFrame, QGridLayout, QComboBox,
|
|
21
|
+
QCheckBox, QFileDialog)
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
from PySide6.QtMultimedia import QMediaDevices
|
|
25
|
+
class VideoAcquisitionThread(QThread):
|
|
26
|
+
frame_ready = Signal(QImage)
|
|
27
|
+
telemetry_ready = Signal(dict)
|
|
28
|
+
|
|
29
|
+
# NEW: O inicializador agora aceita o índice da câmera
|
|
30
|
+
def __init__(self, camera_index=0):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.running = False
|
|
33
|
+
self.camera_index = camera_index
|
|
34
|
+
|
|
35
|
+
self.analyzer = TemporalAnalyzer(calibration_frames=100, perclos_window_frames=150)
|
|
36
|
+
self.state_estimator = DriverStateEstimator()
|
|
37
|
+
self.logger = TelemetryLogger(log_dir="data")
|
|
38
|
+
|
|
39
|
+
self.mp_face_mesh = mp.solutions.face_mesh.FaceMesh(
|
|
40
|
+
max_num_faces=1, refine_landmarks=True,
|
|
41
|
+
min_detection_confidence=0.5, min_tracking_confidence=0.5
|
|
42
|
+
)
|
|
43
|
+
self.mp_drawing = mp.solutions.drawing_utils
|
|
44
|
+
self.mp_drawing_styles = mp.solutions.drawing_styles
|
|
45
|
+
|
|
46
|
+
def run(self):
|
|
47
|
+
self.running = True
|
|
48
|
+
# NEW: Inicia a câmera com o índice escolhido na interface
|
|
49
|
+
cap = cv2.VideoCapture(self.camera_index)
|
|
50
|
+
p_time = 0
|
|
51
|
+
|
|
52
|
+
while self.running and cap.isOpened():
|
|
53
|
+
success, frame = cap.read()
|
|
54
|
+
if not success:
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
frame = cv2.resize(frame, (1280, 720))
|
|
58
|
+
h_frame, w_frame, _ = frame.shape
|
|
59
|
+
|
|
60
|
+
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
61
|
+
results = self.mp_face_mesh.process(frame_rgb)
|
|
62
|
+
|
|
63
|
+
c_time = time.time()
|
|
64
|
+
fps = 1 / (c_time - p_time) if (c_time - p_time) > 0 else 0
|
|
65
|
+
p_time = c_time
|
|
66
|
+
|
|
67
|
+
telemetry = {
|
|
68
|
+
"fps": int(fps), "ear": 0.0, "mar": 0.0, "perclos": 0.0,
|
|
69
|
+
"yaw": 0.0, "pitch": 0.0, "roll": 0.0,
|
|
70
|
+
"state": "NO_FACE", "calib_status": 0.0, "is_calibrated": False
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if results.multi_face_landmarks:
|
|
74
|
+
for face_landmarks in results.multi_face_landmarks:
|
|
75
|
+
self.mp_drawing.draw_landmarks(
|
|
76
|
+
image=frame_rgb,
|
|
77
|
+
landmark_list=face_landmarks,
|
|
78
|
+
connections=mp.solutions.face_mesh.FACEMESH_TESSELATION,
|
|
79
|
+
landmark_drawing_spec=None,
|
|
80
|
+
connection_drawing_spec=self.mp_drawing_styles.get_default_face_mesh_tesselation_style()
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
ear_left, ear_right, ear_avg = get_ear_metrics(face_landmarks, w_frame, h_frame)
|
|
84
|
+
mar = get_mar_metric(face_landmarks, w_frame, h_frame)
|
|
85
|
+
yaw, pitch, roll = get_head_pose(face_landmarks, w_frame, h_frame)
|
|
86
|
+
|
|
87
|
+
self.analyzer.update(ear_avg, mar)
|
|
88
|
+
perclos = self.analyzer.get_perclos()
|
|
89
|
+
is_calibrated = self.analyzer.is_calibrated
|
|
90
|
+
calib_status = self.analyzer.get_calibration_status()
|
|
91
|
+
|
|
92
|
+
current_state = self.state_estimator.estimate_state(is_calibrated, perclos, mar, yaw)
|
|
93
|
+
|
|
94
|
+
# Adiciona a verificação do logger
|
|
95
|
+
if self.logger is not None:
|
|
96
|
+
self.logger.log(fps, ear_left, ear_right, ear_avg, mar, perclos, yaw, pitch, roll, "UNKNOWN", current_state)
|
|
97
|
+
|
|
98
|
+
telemetry.update({
|
|
99
|
+
"ear": ear_avg, "mar": mar, "perclos": perclos,
|
|
100
|
+
"yaw": yaw, "pitch": pitch, "roll": roll,
|
|
101
|
+
"state": current_state, "calib_status": calib_status,
|
|
102
|
+
"is_calibrated": is_calibrated
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
self.telemetry_ready.emit(telemetry)
|
|
106
|
+
|
|
107
|
+
h, w, ch = frame_rgb.shape
|
|
108
|
+
bytes_per_line = ch * w
|
|
109
|
+
q_img = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888)
|
|
110
|
+
self.frame_ready.emit(q_img)
|
|
111
|
+
|
|
112
|
+
cap.release()
|
|
113
|
+
|
|
114
|
+
def stop(self):
|
|
115
|
+
self.running = False
|
|
116
|
+
self.wait()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MainWindow(QMainWindow):
|
|
120
|
+
def __init__(self):
|
|
121
|
+
super().__init__()
|
|
122
|
+
self.setWindowTitle("pyadas - Driver Monitoring System")
|
|
123
|
+
self.resize(1200, 700)
|
|
124
|
+
|
|
125
|
+
self.log_directory = "data" # Diretório padrão
|
|
126
|
+
self.setup_ui()
|
|
127
|
+
self.video_thread = None
|
|
128
|
+
|
|
129
|
+
def setup_ui(self):
|
|
130
|
+
central_widget = QWidget()
|
|
131
|
+
self.setCentralWidget(central_widget)
|
|
132
|
+
main_layout = QHBoxLayout(central_widget)
|
|
133
|
+
|
|
134
|
+
# --- Lado Esquerdo: Câmera e Controles ---
|
|
135
|
+
cam_layout = QVBoxLayout()
|
|
136
|
+
self.lbl_video = QLabel("Câmera Desligada")
|
|
137
|
+
self.lbl_video.setAlignment(Qt.AlignCenter)
|
|
138
|
+
self.lbl_video.setStyleSheet("background-color: black; color: white; font-size: 20px;")
|
|
139
|
+
self.lbl_video.setMinimumSize(800, 600)
|
|
140
|
+
|
|
141
|
+
control_layout = QHBoxLayout()
|
|
142
|
+
|
|
143
|
+
self.combo_camera = QComboBox()
|
|
144
|
+
|
|
145
|
+
# NEW: Varredura dinâmica de câmeras conectadas
|
|
146
|
+
available_cameras = QMediaDevices.videoInputs()
|
|
147
|
+
if not available_cameras:
|
|
148
|
+
self.combo_camera.addItem("Nenhuma câmera detectada", 0)
|
|
149
|
+
else:
|
|
150
|
+
for idx, cam in enumerate(available_cameras):
|
|
151
|
+
# cam.description() pega o nome real da câmera no Windows
|
|
152
|
+
self.combo_camera.addItem(f"{cam.description()} (ID {idx})", idx)
|
|
153
|
+
|
|
154
|
+
self.combo_camera.setMinimumHeight(45)
|
|
155
|
+
self.combo_camera.setStyleSheet("font-size: 14px;")
|
|
156
|
+
|
|
157
|
+
self.btn_start = QPushButton("Start Monitoring")
|
|
158
|
+
self.btn_start.setMinimumHeight(45)
|
|
159
|
+
self.btn_start.setStyleSheet("font-weight: bold; font-size: 14px;")
|
|
160
|
+
self.btn_start.clicked.connect(self.toggle_monitoring)
|
|
161
|
+
|
|
162
|
+
control_layout.addWidget(self.combo_camera, stretch=1)
|
|
163
|
+
control_layout.addWidget(self.btn_start, stretch=4)
|
|
164
|
+
|
|
165
|
+
# NEW: Controles de Salvamento do CSV
|
|
166
|
+
csv_control_layout = QHBoxLayout()
|
|
167
|
+
|
|
168
|
+
self.check_save_csv = QCheckBox("Gravar sessão em CSV")
|
|
169
|
+
self.check_save_csv.setChecked(True)
|
|
170
|
+
self.check_save_csv.setStyleSheet("font-size: 12px; font-weight: bold;")
|
|
171
|
+
|
|
172
|
+
self.btn_choose_dir = QPushButton("Escolher Pasta")
|
|
173
|
+
self.btn_choose_dir.clicked.connect(self.choose_directory)
|
|
174
|
+
|
|
175
|
+
self.lbl_dir_path = QLabel(f"Pasta Atual: {os.path.abspath(self.log_directory)}")
|
|
176
|
+
self.lbl_dir_path.setStyleSheet("font-size: 10px; color: gray;")
|
|
177
|
+
|
|
178
|
+
csv_control_layout.addWidget(self.check_save_csv)
|
|
179
|
+
csv_control_layout.addWidget(self.btn_choose_dir)
|
|
180
|
+
csv_control_layout.addWidget(self.lbl_dir_path, stretch=1)
|
|
181
|
+
|
|
182
|
+
cam_layout.addWidget(self.lbl_video, stretch=1)
|
|
183
|
+
cam_layout.addLayout(csv_control_layout)
|
|
184
|
+
cam_layout.addLayout(control_layout)
|
|
185
|
+
|
|
186
|
+
# --- Lado Direito: Painel de Telemetria (RÍGIDO) ---
|
|
187
|
+
right_panel = QWidget()
|
|
188
|
+
right_panel.setFixedWidth(350)
|
|
189
|
+
panel_layout = QVBoxLayout(right_panel)
|
|
190
|
+
panel_layout.setSpacing(20)
|
|
191
|
+
panel_layout.setContentsMargins(10, 0, 0, 0)
|
|
192
|
+
|
|
193
|
+
self.lbl_state = self._create_panel_label("STATE: IDLE", 24, bold=True)
|
|
194
|
+
self.lbl_state.setStyleSheet("color: gray;")
|
|
195
|
+
self.lbl_state.setWordWrap(True)
|
|
196
|
+
self.lbl_state.setMinimumHeight(70)
|
|
197
|
+
|
|
198
|
+
self.lbl_calib = self._create_panel_label("Status: Waiting", 14)
|
|
199
|
+
|
|
200
|
+
metrics_frame = QFrame()
|
|
201
|
+
metrics_frame.setFrameShape(QFrame.StyledPanel)
|
|
202
|
+
metrics_layout = QGridLayout(metrics_frame)
|
|
203
|
+
|
|
204
|
+
self.lbl_ear = self._create_panel_label("EAR: 0.00")
|
|
205
|
+
self.lbl_mar = self._create_panel_label("MAR: 0.00")
|
|
206
|
+
self.lbl_perclos = self._create_panel_label("PERCLOS: 0.0%")
|
|
207
|
+
self.lbl_yaw = self._create_panel_label("Yaw: 0.0°")
|
|
208
|
+
self.lbl_fps = self._create_panel_label("FPS: 0")
|
|
209
|
+
|
|
210
|
+
metrics_layout.addWidget(self.lbl_ear, 0, 0)
|
|
211
|
+
metrics_layout.addWidget(self.lbl_mar, 1, 0)
|
|
212
|
+
metrics_layout.addWidget(self.lbl_perclos, 2, 0)
|
|
213
|
+
metrics_layout.addWidget(self.lbl_yaw, 3, 0)
|
|
214
|
+
metrics_layout.addWidget(self.lbl_fps, 4, 0)
|
|
215
|
+
|
|
216
|
+
panel_layout.addWidget(self.lbl_state)
|
|
217
|
+
panel_layout.addWidget(self.lbl_calib)
|
|
218
|
+
panel_layout.addWidget(metrics_frame)
|
|
219
|
+
panel_layout.addStretch()
|
|
220
|
+
|
|
221
|
+
main_layout.addLayout(cam_layout, stretch=1)
|
|
222
|
+
main_layout.addWidget(right_panel)
|
|
223
|
+
|
|
224
|
+
def _create_panel_label(self, text, size=16, bold=False):
|
|
225
|
+
lbl = QLabel(text)
|
|
226
|
+
font = QFont("Arial", size)
|
|
227
|
+
font.setBold(bold)
|
|
228
|
+
lbl.setFont(font)
|
|
229
|
+
return lbl
|
|
230
|
+
|
|
231
|
+
def choose_directory(self):
|
|
232
|
+
dir_path = QFileDialog.getExistingDirectory(self, "Selecionar Pasta para Salvar CSV")
|
|
233
|
+
if dir_path:
|
|
234
|
+
self.log_directory = dir_path
|
|
235
|
+
self.lbl_dir_path.setText(f"Pasta Atual: {os.path.abspath(self.log_directory)}")
|
|
236
|
+
|
|
237
|
+
def toggle_monitoring(self):
|
|
238
|
+
if self.video_thread is None or not self.video_thread.isRunning():
|
|
239
|
+
# NEW: Pega o ID (userdata) correspondente à câmera selecionada
|
|
240
|
+
cam_idx = self.combo_camera.currentData()
|
|
241
|
+
|
|
242
|
+
self.video_thread = VideoAcquisitionThread(camera_index=cam_idx)
|
|
243
|
+
|
|
244
|
+
# Repassa a escolha do usuário para a thread de vídeo
|
|
245
|
+
if not self.check_save_csv.isChecked():
|
|
246
|
+
self.video_thread.logger = None # Desliga o logger se a checkbox estiver desmarcada
|
|
247
|
+
else:
|
|
248
|
+
self.video_thread.logger = TelemetryLogger(log_dir=self.log_directory)
|
|
249
|
+
|
|
250
|
+
self.video_thread.frame_ready.connect(self.update_image)
|
|
251
|
+
self.video_thread.telemetry_ready.connect(self.update_telemetry)
|
|
252
|
+
self.video_thread.start()
|
|
253
|
+
|
|
254
|
+
self.combo_camera.setEnabled(False)
|
|
255
|
+
self.check_save_csv.setEnabled(False)
|
|
256
|
+
self.btn_choose_dir.setEnabled(False)
|
|
257
|
+
self.btn_start.setText("Stop Monitoring")
|
|
258
|
+
self.btn_start.setStyleSheet("background-color: #ff4c4c; font-weight: bold; font-size: 14px;")
|
|
259
|
+
else:
|
|
260
|
+
self.video_thread.stop()
|
|
261
|
+
self.combo_camera.setEnabled(True)
|
|
262
|
+
self.check_save_csv.setEnabled(True)
|
|
263
|
+
self.btn_choose_dir.setEnabled(True)
|
|
264
|
+
self.btn_start.setText("Start Monitoring")
|
|
265
|
+
self.btn_start.setStyleSheet("font-weight: bold; font-size: 14px;")
|
|
266
|
+
self.lbl_video.clear()
|
|
267
|
+
self.lbl_video.setText("Câmera Desligada")
|
|
268
|
+
self.lbl_state.setText("STATE: IDLE")
|
|
269
|
+
self.lbl_state.setStyleSheet("color: gray;")
|
|
270
|
+
|
|
271
|
+
def update_image(self, q_img):
|
|
272
|
+
pixmap = QPixmap.fromImage(q_img).scaled(
|
|
273
|
+
self.lbl_video.width(), self.lbl_video.height(), Qt.KeepAspectRatio
|
|
274
|
+
)
|
|
275
|
+
self.lbl_video.setPixmap(pixmap)
|
|
276
|
+
|
|
277
|
+
def update_telemetry(self, data):
|
|
278
|
+
self.lbl_ear.setText(f"EAR: {data['ear']:.2f}")
|
|
279
|
+
self.lbl_mar.setText(f"MAR: {data['mar']:.2f}")
|
|
280
|
+
self.lbl_perclos.setText(f"PERCLOS: {data['perclos']*100:.1f}%")
|
|
281
|
+
self.lbl_yaw.setText(f"Yaw: {data['yaw']:.1f}°")
|
|
282
|
+
self.lbl_fps.setText(f"FPS: {data['fps']}")
|
|
283
|
+
|
|
284
|
+
if data['is_calibrated']:
|
|
285
|
+
self.lbl_calib.setText("Status: CALIBRATED")
|
|
286
|
+
self.lbl_calib.setStyleSheet("color: green; font-weight: bold;")
|
|
287
|
+
else:
|
|
288
|
+
pct = int(data['calib_status'] * 100)
|
|
289
|
+
self.lbl_calib.setText(f"Calibrating... {pct}%")
|
|
290
|
+
self.lbl_calib.setStyleSheet("color: orange; font-weight: bold;")
|
|
291
|
+
|
|
292
|
+
state = data['state']
|
|
293
|
+
|
|
294
|
+
state_display = state.replace("_", " ")
|
|
295
|
+
self.lbl_state.setText(f"STATE:\n{state_display}")
|
|
296
|
+
|
|
297
|
+
if state == "ALERT":
|
|
298
|
+
self.lbl_state.setStyleSheet("color: green;")
|
|
299
|
+
elif state in ["DROWSY", "YAWNING", "DISTRACTED"]:
|
|
300
|
+
self.lbl_state.setStyleSheet("color: orange;")
|
|
301
|
+
elif state == "POTENTIAL_MICROSLEEP":
|
|
302
|
+
self.lbl_state.setStyleSheet("color: red;")
|
|
303
|
+
else:
|
|
304
|
+
self.lbl_state.setStyleSheet("color: gray;")
|
|
305
|
+
|
|
306
|
+
def closeEvent(self, event):
|
|
307
|
+
if self.video_thread is not None and self.video_thread.isRunning():
|
|
308
|
+
self.video_thread.stop()
|
|
309
|
+
event.accept()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyadas
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open-Source Driver Monitoring System
|
|
5
|
+
Requires-Python: <3.12,>=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: opencv-python
|
|
9
|
+
Requires-Dist: mediapipe==0.10.14
|
|
10
|
+
Requires-Dist: PySide6
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: scipy
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# pyadas 🚘📹
|
|
16
|
+
|
|
17
|
+
[](https://pypi.org/project/pyadas/)
|
|
18
|
+
[](https://pypi.org/project/pyadas/)
|
|
19
|
+
[](https://opensource.org/licenses/MIT)
|
|
20
|
+
|
|
21
|
+
**pyadas** is a real-time Driver Monitoring System (DMS) tailored for Advanced Driver Assistance Systems (ADAS) applications.
|
|
22
|
+
|
|
23
|
+
Built purely in Python, the project leverages asynchronous processing (via PySide6), computer vision, and facial geometry (via MediaPipe and OpenCV) to estimate attention, distraction, and fatigue states in real time using conventional hardware (webcams).
|
|
24
|
+
|
|
25
|
+
> **Important Notice:** This is a software engineering and research prototype. It is **not** a diagnostic medical device and holds **no** certifications to act as a production vehicular safety system.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 📋 Table of Contents
|
|
30
|
+
1. [Key Features](#-key-features)
|
|
31
|
+
2. [Driver State Estimator](#%EF%B8%8F-driver-state-estimator)
|
|
32
|
+
3. [Installation](#-installation)
|
|
33
|
+
4. [Usage](#-usage)
|
|
34
|
+
5. [Contact](#-contact)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 🚀 Key Features
|
|
39
|
+
|
|
40
|
+
* **Dynamic Calibration (Auto-Baseline):** The system relies on no universal hardcoded thresholds. It autonomously calibrates the driver's facial averages within the first few seconds of initialization.
|
|
41
|
+
* **Fatigue Estimation:** Continuous calculation of the Eye Aspect Ratio (EAR).
|
|
42
|
+
* **Temporal Analysis (PERCLOS):** Implementation of the Percentage of Eye Closure metric via a high-performance sliding window for robust drowsiness and potential microsleep detection, bypassing normal blinking false positives.
|
|
43
|
+
* **Yawn Detection:** Mouth Aspect Ratio (MAR) calculation.
|
|
44
|
+
* **Gaze & Pose Estimation:** Approximate calculation of the head's Yaw, Pitch, and Roll angles.
|
|
45
|
+
* **Asynchronous & Responsive UI:** Multi-threaded architecture isolating heavy video acquisition from the graphical user interface built with PySide6.
|
|
46
|
+
* **Telemetry (Black-box logger):** Continuous CSV log recording (EAR, MAR, PERCLOS, FPS, Angles, Driver State) for post-processing and mathematical data cross-referencing.
|
|
47
|
+
|
|
48
|
+
## ⚙️ Driver State Estimator
|
|
49
|
+
|
|
50
|
+
The package architecture cross-references geometric and temporal metrics to classify the driver into critical categories:
|
|
51
|
+
* `ALERT`: Normal active visual state.
|
|
52
|
+
* `DISTRACTED`: Prolonged head rotation outside the region of interest.
|
|
53
|
+
* `YAWNING`: Temporal events of severe mouth opening (MAR > threshold).
|
|
54
|
+
* `DROWSY`: Drowsiness indicator triggered when PERCLOS exceeds primary safety levels.
|
|
55
|
+
* `POTENTIAL_MICROSLEEP`: Detection of severe, sustained eye closure over the temporal window.
|
|
56
|
+
|
|
57
|
+
## 💻 Installation
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
pip install pyadas
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 🛠️ Usage
|
|
64
|
+
|
|
65
|
+
### Starting the GUI
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pyadas.pyadas_global import pyadasGui
|
|
69
|
+
|
|
70
|
+
pyadasGui()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## ✉️ Contact
|
|
74
|
+
|
|
75
|
+
Cayo Rawlisom Castoril - [LinkedIn](https://www.linkedin.com/in/cayo-rawlisom-407816247/) | cayorwcs@gmail.com
|
|
76
|
+
|
|
77
|
+
Project Link: [https://github.com/CayoRw/pyadas](https://github.com/CayoRw/pyadas)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pyadas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pyadas/pyadas_global.py,sha256=5u8W8e4c54IsSe3fgP34rdcPVBXPu8IdLStlT02oNbE,726
|
|
3
|
+
pyadas/camera/core.py,sha256=jDR39qU3sIZVJcrnC6bIGBe3Jcw9lTvQBwCc19lh1r0,4332
|
|
4
|
+
pyadas/driver_state/state_estimator.py,sha256=oxY5numXrT71vZl9lUDtf3hLqmkA6ZdKceoeTHlxue4,1490
|
|
5
|
+
pyadas/drowsiness/temporal.py,sha256=gE7pHNYEpeun_cUQGvBJyWHQiyL2d3FcYCkvDPlsQYY,2371
|
|
6
|
+
pyadas/perception/ear.py,sha256=7zv2Esb3VapQZaP77TkAxXK8HgX4blexug39wX8VTUU,1743
|
|
7
|
+
pyadas/perception/head_pose.py,sha256=ch68E4IjkSWLdn3d45egq7BVgM7FHFo2q2WKz6-r_vI,2477
|
|
8
|
+
pyadas/perception/mar.py,sha256=Agb9nbipP8voeipApxfiGk7D1d-QXnCeoS94jZx-yl0,1126
|
|
9
|
+
pyadas/telemetry/logger.py,sha256=ZL6Ehdp7BGyuCdnCs-1nlm4U_hIIRHk8RnuQWRg26Vg,1667
|
|
10
|
+
pyadas/ui/main_window.py,sha256=botxoTQON48pbmilcJOuKPhGyntz-joQs5hBBJdYjA0,13609
|
|
11
|
+
pyadas-0.1.0.dist-info/licenses/LICENSE,sha256=pFQG-1jQwbfWdk37f429_CRc3QObJiga_MIf8RWnCN4,1091
|
|
12
|
+
pyadas-0.1.0.dist-info/METADATA,sha256=BMsFI0osmZ56RJZLsXJUUpOReA4SExRuqU1_iDnnFRM,3486
|
|
13
|
+
pyadas-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
pyadas-0.1.0.dist-info/top_level.txt,sha256=CIcxWpeMb2HEZT_GXLXgHf3y0VNo6W-dahc6LP3ZXIQ,7
|
|
15
|
+
pyadas-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cayo Rawlisom
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyadas
|