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 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
@@ -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
@@ -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
@@ -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)
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/pyadas.svg)](https://pypi.org/project/pyadas/)
18
+ [![Python versions](https://img.shields.io/pypi/pyversions/pyadas.svg)](https://pypi.org/project/pyadas/)
19
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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