基于MediaPipe的违规驾驶行为识别系统设计与实现
2026/9/14 3:40:13 网站建设 项目流程

简介:一套面向计算机视觉/智能交通方向的毕设级Python项目源码,专注于驾驶行为中的违规动作识别,覆盖数据准备、模型训练到推理检测的完整链路。压缩包内含128个文件,打包大小约64.93MB,其中以81个py源码脚本为核心,辅以sh部署脚本、pth权重、pkl序列化文件、npy数据文件及多份md/txt说明文档;另有png可视化结果与pyc缓存文件,可支撑环境搭建、二次训练与结果展示。已有1060人在CSDN学习或下载。除主体识别系统代码外,包内还整合了依赖清单、LICENSE、README与CHANGELOG等工程化文档,目录结构清晰,便于对照复现;适合需要以真实项目完成毕业设计、课程设计或积累视觉实战经验的开发者,可直接运行调试,也可围绕模型与接口扩展优化。

1. 违规驾驶识别这个毕设题,真正要解决的不是训练模型

有的同学拿到这个题目,第一反应是去找数据集、训一个行为分类网络,结果标了一周数据,发现答辩日期先到了。违规驾驶行为识别在毕设清单里的真实难度,恰恰不在“识别”两个字,而在于把一条开源视觉管线做成能实时响应的事件系统:摄像头画面进来,报警出去,中间每一步状态变化都能解释给老师听。适合的人群是已会 Python 基础语法、想避开训练成本又能把工程做完整的同学。核心路径是:用 MediaPipe 提取人脸和手部关键点,再用 EAR、MAR、头部姿态这些几何特征做规则判定。这套方案没有训练步骤,所有阈值可调,落盘、截图、指标都能写进论文。

2. 识别原理与选型:关键点坐标如何变成行为判定

2.1 从“检测到关键点”到“判定为违规”,中间隔着一层规则引擎

行为识别系统在架构上必须拆成两层:感知层和判别层。感知层的任务只有一个——从 RGB 帧里拿到人脸 468 个关键点、手部 21 个关键点的归一化坐标;判别层拿这些坐标算几何量,比如眼睛开合程度、嘴巴张开幅度、鼻尖相对眼睛的位移,再和阈值比较,输出“闭眼”“打哈欠”“转头看手机”。两层之间通过坐标数组通信,不混业务逻辑。

这个分层不是写代码的习惯问题,而是决定了毕设能不能收尾。判别层用规则而不是神经网络,意味着你不需要准备大量带标签的行为视频,只需要在 OpenCV 窗口里慢慢调几个阈值,就能覆盖疲劳驾驶、分心驾驶、手持电话这三类最常见的违规场景。调阈值的过程本身就是毕设答辩时最有说服力的素材,老师问“为什么这个值是 0.2”,你能直接指着一帧画面上叠加的 EAR 数值回答,而不是说“网络学出来的,我也说不清”。

网上流传的同类源码包质量差别很大,很多只是把 MediaPipe 官方示例改了个界面,单帧检测看起来有效,一旦连续跑视频流,报警就会反复抖动。原因就是没有在判别层做时间维度上的累积判断。后续章节的实现,会从单帧特征一直做到带状态机的连续事件流。

2.2 为什么选 MediaPipe 而不是 OpenPose 或 YOLOv8-Pose

毕设选型要同时看三个约束:能不能用 CPU 跑、能不能 pip 装完就运行、关键点数量够不够描述行为。三者都满足的,目前最省心的是 MediaPipe。下表是三个方案在同样一台无 GPU 笔记本上的对比:

方案模型体积CPU 实时帧率关键点安装与使用毕设友好度
OpenPose200MB+5 FPS 以下身体 135 点需编译,依赖复杂
YOLOv8-Pose6MB~50MBCPU 勉强 10 FPS身体 17 点pip 可装,但人脸细节不足
MediaPipe10MB 以内30 FPS 左右人脸 468 + 手 21pip install mediapipe 即用

选 MediaPipe 的决定性理由不是精度,而是它同时给你人脸和手两套关键点,且在同一个进程里跑两个模型依旧能维持实时。OpenPose 的身体关键点能看出手是否靠近嘴,但看不出眼睛是睁开还是闭上,检测疲劳驾驶必须额外接一个人脸模型;YOLOv8-Pose 的手腕点足够用来做“手靠近耳朵”的判断,但 17 个点里没有眼皮和嘴唇的语义,做打哈欠检测要自己再训练分类头。MediaPipe 的 FaceMesh 拥有眼皮、嘴唇、鼻尖的独立索引,手部模型又有手掌中心和指尖坐标,一个程序里能把闭眼、张嘴、低头、举电话四件事全做了。

2.3 EAR、MAR 与低头角:三个可解释的行为特征

EAR(Eye Aspect Ratio,眼睛纵横比)是疲劳检测最通用的指标。取单只眼睛周围 6 个关键点,用两个垂直距离的平均值除以水平距离,得到一个与脸到相机距离无关的比值。眨眼时 EAR 会掉到 0.15 左右,正常平视在 0.25 到 0.35。公式和 MediaPipe 索引对应如下:

  • 左眼索引[33, 160, 158, 133, 153, 144]
  • 右眼索引[362, 385, 387, 263, 373, 380]

其中第 0 位和第 3 位是内、外眼角,第 1、2 位是上眼皮,第 4、5 位是下眼皮。EAR = (d1 + d2) / (2 * d0),分子是两个垂直距离,分母是眼裂宽度。

MAR(Mouth Aspect Ratio,嘴部纵横比)用来识别打哈欠,取上内唇点 13、下内唇点 14 的距离除以嘴角 61 与 291 的距离。哈欠时嘴部高度显著增加,MAR 通常在 0.6 以上,正常说话在 0.3 到 0.5。低头角则用一个近似量:鼻尖点(索引 1)相对两眼中心连线中点的 y 方向偏移。图像坐标系里 y 轴向下,平视时鼻尖在眼睛下方,偏移量为正;低头时鼻尖在画面中相对眼睛上移,偏移量变小甚至变负。

这三个特征全部基于归一化坐标计算,不受画面分辨率影响。但注意,MAR 和低头角对摄像头安装位置敏感:摄像头装在挡风玻璃上方正中,与装在仪表盘右侧的结果会差不少,阈值必须在自己的拍摄条件下重新标定。

3. 用 Python 搭最小检测管线:人脸与手部联合提取

3.1 工程目录、依赖与模型初始化

先落地一个能跑的工程结构,把后续的判别逻辑和日志功能留出位置,避免写到后面把 main.py 堆成一千行:

driving_behavior/ ├── detectors/ │ ├── __init__.py │ ├── landmarks.py # 帧关键点提取 │ └── behavior.py # EAR/MAR/低头角计算与判定 ├── core/ │ ├── state_machine.py # 连续帧状态机 │ └── logger.py # CSV 日志与截图 ├── config.py # 所有阈值集中管理 ├── main.py # 视频/摄像头主循环 └── requirements.txt

依赖安装很简单,假定你已经在 Windows 或 Ubuntu 上装好了 Python 3.9 以上的环境,在项目目录执行:

pip install opencv-python mediapipe numpy

opencv-python负责视频读取和画面绘制,mediapipe提供人脸和手部两套关键点模型,numpy用来做坐标向量计算。三个库都不需要 GPU 就能运行,安装完可以立刻用python -c "import mediapipe"验证是否装好。对入门来讲,这是最省心的组合,没有编译步骤,也不涉及 CUDA 配置。

3.2 一帧输入,两个模型的输出如何对齐

detectors/landmarks.py里写一个类,统一封装 MediaPipe 的两个模型。这个类只做一件事:输入一帧 BGR 图像,输出人脸关键点对象和手部关键点对象。

import cv2 import mediapipe as mp class FrameLandmarks: def __init__(self): self.mp_face = mp.solutions.face_mesh self.mp_hands = mp.solutions.hands self.face = self.mp_face.FaceMesh( static_image_mode=False, max_num_faces=1, refine_landmarks=True, min_detection_confidence=0.5, min_tracking_confidence=0.5, ) self.hands = self.mp_hands.Hands( static_image_mode=False, max_num_hands=2, min_detection_confidence=0.5, min_tracking_confidence=0.5, ) def extract(self, frame_bgr): rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) rgb.flags.writeable = False # 标记只读,MediaPipe内部可跳过复制,提速 face_res = self.face.process(rgb) hand_res = self.hands.process(rgb) face_pts = hand_pts = None if face_res.multi_face_landmarks: face_pts = face_res.multi_face_landmarks[0] if hand_res.multi_hand_landmarks: hand_pts = hand_res.multi_hand_landmarks[0] return face_pts, hand_pts

这个类里有几个参数值得解释。refine_landmarks=True会让 FaceMesh 额外输出瞳孔中心等 10 个点,原始 468 点的索引不变,瞳孔信息将来扩展注视方向检测时可以直接用,而且这个开关还能轻微提升关键点稳定性,默认建议开着。max_num_faces=1是因为驾驶舱内我们只关心主驾驶位的人,多一个人脸反而会让行为判定找不到目标。min_detection_confidencemin_tracking_confidence都取 0.5,是权衡“漏检”和“抖动”之后的经验值:调高到 0.7 会显著减少误检,但在光照变化时会频繁丢失目标;调低到 0.3 跟踪更连续,却容易把窗外的广告牌人形误判成驾驶员。

extract方法的返回值是 MediaPipe 官方的 landmark 对象,里面每个关键点都有xyz三个属性,且xy已经归一化到 0 到 1。归一化坐标意味着行为特征与摄像头分辨率无关,你也可以直接用landmark.x * frame.shape[1]换算回像素坐标,画框打点都方便。

3.3 行为判定函数与阈值参数表

detectors/behavior.py里写几何特征计算和行为判定。先定义关键点索引常量,再实现 EAR、MAR 和低头角三个函数:

import numpy as np LEFT_EYE = [33, 160, 158, 133, 153, 144] RIGHT_EYE = [362, 385, 387, 263, 373, 380] MOUTH_UP, MOUTH_DOWN = 13, 14 MOUTH_LEFT, MOUTH_RIGHT = 61, 291 NOSE_TIP = 1 EYE_LEFT_OUTER, EYE_RIGHT_OUTER = 33, 263 def distance(p1, p2): return np.linalg.norm(np.array([p1.x, p1.y]) - np.array([p2.x, p2.y])) def calc_ear(landmarks): def eye_ear(idx): p = [landmarks.landmark[i] for i in idx] return (distance(p[1], p[5]) + distance(p[2], p[4])) / (2 * distance(p[0], p[3])) return (eye_ear(LEFT_EYE) + eye_ear(RIGHT_EYE)) / 2 def calc_mar(landmarks): up = landmarks.landmark[MOUTH_UP] down = landmarks.landmark[MOUTH_DOWN] left = landmarks.landmark[MOUTH_LEFT] right = landmarks.landmark[MOUTH_RIGHT] return distance(up, down) / distance(left, right) def calc_look_down(landmarks): nose = landmarks.landmark[NOSE_TIP] left = landmarks.landmark[EYE_LEFT_OUTER] right = landmarks.landmark[EYE_RIGHT_OUTER] eye_mid_y = (left.y + right.y) / 2 return nose.y - eye_mid_y # 平视为正,低头变小或变负

calc_ear对左右眼分别计算后取平均,好处是单眼被头发遮挡时数据不会瞬间跳变。calc_mar用上下唇距离除以嘴角宽度做归一化,所以脸离摄像头近还是远不影响结果。calc_look_down返回的不是真正的欧拉角,而是鼻尖相对眼睛连线的 y 偏移,这个量实现成本最低,对“低头看手机”这个动作已经足够敏感。

判定函数把这些特征和阈值比较,输出一个行为字典:

def judge_behaviors(landmarks, hand_landmarks, threshold): if landmarks is None: return None ear = calc_ear(landmarks) mar = calc_mar(landmarks) nose_delta = calc_look_down(landmarks) behavior = { "ear": ear, "mar": mar, "closed_eye": ear < threshold["EAR_CLOSED"], "yawning": mar > threshold["MAR_YAWN"], "look_down": nose_delta < threshold["NOSE_DELTA"], "phone": False, } if hand_landmarks is not None: hand_center = hand_landmarks.landmark[9] # 中指根部,近似手掌中心 ear_point = landmarks.landmark[127] # 右耳外沿点 if distance(hand_center, ear_point) < threshold["HAND_EAR_DIST"]: behavior["phone"] = True return behavior

所有阈值集中在config.py,方便调参:

THRESHOLD = { "EAR_CLOSED": 0.20, # EAR 低于 0.2 判定闭眼 "MAR_YAWN": 0.60, # MAR 高于 0.6 判定哈欠 "NOSE_DELTA": -0.03, # 鼻尖相对眼睛的偏移低于 -0.03 判定低头 "HAND_EAR_DIST": 0.12, # 手与耳朵距离小于 0.12 判定打电话 }

这里每个阈值都要解释一下取值范围。EAR 阈值在 0.18 到 0.25 之间常见,戴眼镜会略微影响结果,取值 0.2 比较保守,避免把正常眨眼当疲劳。MAR 阈值 0.6 对应“嘴明显张大”,如果测试视频里哈欠总被漏检,降到 0.5 即可。NOSE_DELTA是最需要现场重标的阈值,它跟摄像头安装高度强相关,建议先用正常驾驶画面打印出这个值,再低头看手机打印出这个值,取两者中线。HAND_EAR_DIST使用归一化距离,0.12 大约是画面宽度的 12%,手举到耳边时通常小于这个数,手放在方向盘上时大于 0.25。

4. 从单帧判定到连续监测:状态机、事件日志与性能取舍

4.1 用状态机消除阈值抖动,别让报警反复横跳

单帧判定直接驱动报警,画面里只要有一帧 EAR 掉到阈值以下,就会触发一次“疲劳报警”,下一帧恢复正常又消失。这种抖动在答辩演示里非常难看。正确做法是加一个三态状态机:SAFE(安全)、WARNING(警告)、ALERT(报警)。状态转移规则如下:

当前状态转移条件下一状态
SAFE最近 10 帧中有连续 3 帧判定违规WARNING
WARNING违规帧累计达到 30 帧ALERT
WARNING连续 15 帧无违规SAFE
ALERT连续 15 帧无违规SAFE

实现放在core/state_machine.py

class BehaviorStateMachine: def __init__(self, warn_frames=10, alert_frames=30, recover_frames=15): self.state = "safe" self.violation_count = 0 self.safe_count = 0 self.warn_frames = warn_frames self.alert_frames = alert_frames self.recover_frames = recover_frames def update(self, is_violation): if is_violation: self.violation_count += 1 self.safe_count = 0 else: self.safe_count += 1 if self.state == "safe": if self.violation_count >= self.warn_frames: self.state = "warning" self.violation_count = 0 elif self.state == "warning": if self.violation_count >= self.alert_frames: self.state = "alert" self.violation_count = 0 if self.safe_count >= self.recover_frames: self.state = "safe" self.violation_count = 0 self.safe_count = 0 return self.state

is_violation是单帧里任一行为判定为真的结果,violation_count统计的是持续帧数,不是总帧数。为什么用累计帧而不是“10 秒内违规次数”?因为闭眼、低头这类行为天然是连续过程,持续帧数直接反映行为时长;而“10 秒 5 次”这种统计方式需要额外维护时间窗,代码复杂度更高。recover_frames=15让报警解除有一个延迟,避免驾驶员闭眼一次、睁眼一帧、再闭眼时状态反复切换。这个状态机的输出要接在 UI 上,同时驱动后续的日志和截图。

4.2 事件落盘与告警截图:CSV 和 JPG 各存什么

报警不能只在终端打印一行,毕设需要能回放评估的记录。设计一个简单的EventLogger,在状态变为ALERT时写一条 CSV 记录并保存当前帧截图:

import csv import time from pathlib import Path import cv2 class EventLogger: def __init__(self, log_dir="events"): self.log_dir = Path(log_dir) self.log_dir.mkdir(exist_ok=True) self.csv_path = self.log_dir / "violations.csv" if not self.csv_path.exists(): with open(self.csv_path, "w", newline="") as f: csv.writer(f).writerow( ["timestamp", "state", "ear", "mar", "phone", "frame_no"]) def log(self, frame, state, ear, mar, phone, frame_no): ts = time.strftime("%Y%m%d_%H%M%S") with open(self.csv_path, "a", newline="") as f: csv.writer(f).writerow( [ts, state, round(ear, 3), round(mar, 3), int(phone), frame_no]) if state == "alert": img_path = self.log_dir / f"alert_{ts}_frame{frame_no}.jpg" cv2.imwrite(str(img_path), frame)

CSV 里存的是判定依据,不只是“报警了”这个结论。earmar这两列数值在后续计算指标和写论文分析时可以直接拿来画曲线,比如截取报警前后各 5 秒,画出 EAR 随时间下降的过程,这是很直观的实验结果图。截图只在ALERT状态保存,如果在WARNING就存图,一次完整的疲劳过程会产生几十张高度相似的图片,白白增加磁盘 IO。文件命名带时间戳和帧号,方便和视频逐帧对齐。

4.3 跳帧策略与多线程日志:CPU 上维持实时性的手段

MediaPipe 在普通笔记本 CPU 上处理一帧大约需要 20 到 40 毫秒,加上 OpenCV 的画面绘制,勉强能到 25 FPS。如果日志模块把写文件和视频处理放在同一个线程,写盘瞬间主循环会被卡住,画面直接掉到 15 FPS 以下。两个手段可以稳住实时性。

第一个是跳帧处理:行为判定不需要每帧都跑,因为人闭眼、打哈欠都是持续几百毫秒的动作,每秒处理 10 帧已经足够捕捉。在main.py的主循环里控制每 2 帧跑一次特征提取:

process_interval = 2 frame_no = 0 while cap.isOpened(): ok, frame = cap.read() if not ok: break if frame_no % process_interval == 0: landmarks, hands = extractor.extract(frame) behavior = judge_behaviors(landmarks, hands, THRESHOLD) state = sm.update(any([behavior["closed_eye"], behavior["yawning"], behavior["look_down"], behavior["phone"]])) logger.log(frame, state, behavior["ear"], behavior["mar"], behavior["phone"], frame_no) frame_no += 1

跳帧丢掉的不是报警本身,只是报警的精确时间点,对毕设来说完全可接受。第二个手段是把截图写盘放到线程池里,避免主循环等磁盘:

from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=1) def async_save(logger, frame, state, ear, mar, phone, frame_no): executor.submit(logger.log, frame, state, ear, mar, phone, frame_no)

ThreadPoolExecutor在这里只开一个工作线程,任务队列短,内存占用可控。注意传给线程的frame必须copy(),否则主循环里下一帧的读取操作会覆盖线程正在写的图像数据。

5. 毕设验收前的三个加固技巧

5.1 自制评估集:按时间段标注比逐帧标注更省力

答辩时老师几乎一定会问“效果怎么验证”。你需要一份自己的标注数据,而不是只拿几段演示视频说“看着没问题”。做法是录制三段 30 秒左右的视频:正常驾驶、打电话、瞌睡,用start_send_s记录每段违规行为的时间区间,存成 CSV:

start_s,end_s,label 0.0,11.5,phone 14.2,21.8,yawn 25.0,29.5,closed_eye

逐帧标注太耗时,按时间段标注只需要对着播放器记两个时间点。评估时程序按帧号除以 FPS 换算成秒,再查这个时刻是否落在标注区间内。

5.2 指标计算:精确率、召回率和 F1 的帧级实现

有了标注 CSV,写一个评估脚本,把系统输出和标注对齐成逐帧的布尔数组:

import numpy as np def frame_label(events, t): return any(s <= t < e for s, e, _ in events) def evaluate(gt_events, pred_events, total_frames, fps=10): gt = np.array([frame_label(gt_events, i / fps) for i in range(total_frames)]) pred = np.array([frame_label(pred_events, i / fps) for i in range(total_frames)]) tp = (gt & pred).sum() fp = (~gt & pred).sum() fn = (gt & ~pred).sum() precision = tp / (tp + fp + 1e-6) recall = tp / (tp + fn + 1e-6) f1 = 2 * precision * recall / (precision + recall + 1e-6) return precision, recall, f1

注意frame_label的时间对齐方式:标注 CSV 里的start_s是视频时间轴秒数,而程序输出 CSV 里的frame_no / fps也必须换算成同一个时间轴,否则指标全是错的。视频较短时用any()线性扫描没问题,长视频建议改用np.searchsorted把区间查询变成二分查找。

5.3 展示画面上的数值叠层,是最不值钱但最加分的细节

演示时,把 EAR、MAR 和当前状态直接绘制在画面左上角。这一步写的代码很少,但对答辩观感提升极大,老师一眼能看到判定依据在实时变化:

cv2.putText(frame, f"EAR {ear:.2f} | MAR {mar:.2f} | {state}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255) if state == "alert" else (0, 255, 0), 2)

顺手记录触发look_down那几帧的nose_delta值,画一个时间序列折线图,和 EAR 曲线并排放在论文的性能分析章节里。这一张图表就能同时说明特征定义、阈值来源和系统有效性,比任何文字描述都有说服力。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询