☰
基于Python的驾驶员疲劳检测系统:CNN与关键点算法解析
2026/9/26 18:56:20 网站建设 项目流程

简介:一套基于Python与卷积神经网络构建的驾驶员疲劳检测与预警系统毕业设计资源,集成了人脸识别与疲劳状态分析功能,主要面向计算机、通信、人工智能、自动化等相关专业的学生和从业者。项目为作者个人毕设,答辩评审98分,代码经调试测试可直接运行,非常适合小白入门,也可作为期末课程设计、课程大作业或二次开发基础。资源包共37个文件,涵盖16个Python脚本、9个pyc编译模块、5张测试图片、3个预训练权重、训练日志与说明文档,并内置数据集压缩包,整体约500.41MB;源码模块涉及SSD/VGG网络结构、数据增强、损失函数、摄像头实时检测与视频文件检测等,便于理解从算法到应用的完整链路。目前已有116人学习使用,适合对照完整的高分毕业设计流程进行学习与功能扩展。

1. 疲劳驾驶检测系统:不是玄学,是一套能从监控画面里算出“困了没”的工程

深夜在高速上连续开三个小时,眼睛闭合时间超过 0.4 秒,这一瞬间的视觉特征足以让一个训练好的卷积神经网络抓住它——这就是基于 Python 的驾驶员疲劳检测与预警系统要做的事。这套资源包含两条主线:一条用 CNN 做人脸识别,确认当前坐在驾驶位的人是谁,锚定司机身份;另一条用 68 点关键点和 PERCLOS 近似算法,把“眼皮半遮瞳孔”“持续眨眼变慢”量化成可以触发报警的 EAR 数值。只要摄像头正常、光照不过于极端,单人驾驶场景下误报率能压到工程上可接受的范围。这份源码加数据集适合正在做毕业设计、想快速跑通一整套检测 demo、或者打算把方案移植到门禁考勤项目里的人,拿到手就能从环境配置开始一步步复现。

2. 人脸框不住,后面全是空转:MTCNN 与 68 点关键点的落地链路

2.1 为什么这条链路不选 Haar 而选 CNN

OpenCV 自带的 Haar 级联检测器一行代码就能调用,但它在遮挡、暗光、侧脸场景下非常容易把检测框丢掉或者框偏。疲劳检测的后续所有计算都依赖第一步的人脸框,框一旦歪了,EAR 的分子分母就全乱套。所以资源里的检测器用的是基于卷积神经网络的 MTCNN,它包含 P-Net、R-Net、O-Net 三级网络:P-Net 先快速产出候选框,R-Net 做精筛,O-Net 在最后输出边框回归、置信度和 5 点关键点坐标。

MTCNN 在 CPU 上处理一帧大约需要 30 到 80 毫秒,加后面 68 点回归,i5 级处理器能跑到 15 到 20 FPS,对驾驶预警来说已经够用。如果你的机器只有核显,把输入分辨率从 1080p 压到 720p,检测框上限控制在 640 像素以内,帧率能再翻一倍。这条选型路线在资源对应的代码里也是默认配置,优先保证的是稳定而不是炫技。

2.2 MTCNN 检测与 5 点 landmark 的代码实现

先看基本的检测流程,注意图像通道顺序在这个库里的特殊要求。

from mtcnn import MTCNN import cv2 # 初始化检测器,参数是资源和项目代码里的常见配置 detector = MTCNN( min_face_size=60, # 小于 60px 的人脸直接忽略,减少误检 steps_threshold=[0.6, 0.7, 0.7], # P-Net / R-Net / O-Net 置信度阈值 scale_factor=0.709 # 图像金字塔缩放系数 ) frame = cv2.imread("driver_frame.jpg") # 读进来是 BGR rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # MTCNN 内部用 RGB results = detector.detect_faces(rgb) if results: x, y, w, h = results[0]["box"] conf = results[0]["confidence"] lmk = results[0]["keypoints"] # lmk 里有 left_eye / right_eye / nose / mouth_left / mouth_right cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)

代码逻辑是先把 OpenCV 读进来的 BGR 图像转成 RGB,因为 MTCNN 训练时用的是 RGB 输入,不做转换会导致检测率和关键点位置出现肉眼可见的偏移。这里如果直接把 results 里的box拿去做裁剪,要注意它给的是左上角坐标和宽高,不是中心点,后面切 ROI 时不要惯性除以 2。

参数说明:min_face_size=60是实测里的一个折中值,再往下调会把远处的人误检出来;steps_threshold三级网络阈值保持前低后高,P-Net 松一些先广撒网,O-Net 收紧保证精度。实际夜间场景如果检测不到脸,优先把 0.6 降到 0.5,而不是去动后面两级。

2.3 疲劳判据依赖的 68 点:dlib 模型加载与调用

MTCNN 输出的 5 点只能描述眼睛中心、鼻尖、嘴角的大概位置,算不出眼睑张开程度。计算 EAR 需要眼睑边缘的精确坐标,这一步资源里用的是 dlib 的 68 点回归模型,输出 68 个脸部关键点,分布覆盖眉毛、眼睛、鼻子、嘴巴和下颌轮廓。

这个 68 点属于传统机器学习里的回归方法,不是 CNN,但它是疲劳判据的标准拍档,卷积神经网络负责把你的人脸位置找出来,回归器负责给出几何细节。两者分工明确,谁也别想替代谁。

import dlib predictor_path = "models/shape_predictor_68_face_landmarks.dat" predictor = dlib.shape_predictor(predictor_path) # rect 来自 2.2 节 MTCNN 的检测结果,dlib 需要接收 dlib.rectangle rect = dlib.rectangle(int(x), int(y), int(x + w), int(y + h)) # dlib 同样要求 RGB 图像,直接用 2.2 节的 rgb 变量 shape = predictor(rgb, rect) # 把 shape 转成 numpy 数组,方便后续做向量运算 import numpy as np coords = np.array([[p.x, p.y] for p in shape.parts()])

代码逻辑是先把 MTCNN 的人脸框转换成 dlib 的 rectangle 类型,然后调用回归器。注意这里用的输入图像还是 RGB,dlib 如果收到 BGR 图,关键点整体位置仍然正确,但细节特征的位置会有系统偏移,轻则 EAR 轻微偏高,重则闭眼判断失效。

提示:资源里的models/目录如果缺了这个.dat文件,程序会直接报错。这个模型文件开源自带,不用训练,放到代码指定的相对路径即可。

3. EAR 与 PERCLOS:把“眼神涣散”变成可以报警的数字

3.1 EAR 眼睑闭合度的几何定义与计算

EAR(Eye Aspect Ratio)算的是单只眼睛的纵横比,原理很朴素:眼睛睁开时垂直方向的两个关键点距离远,EAR 值大;眼睛闭合时垂直距离趋近于零,EAR 值骤降。正常平视时左眼右眼的 EAR 通常在 0.25 到 0.35 之间,闭眼时会跌到 0.1 以下。

公式落在代码上是这样:

def eye_aspect_ratio(eye_pts): # eye_pts 是按顺序排列的 6 个关键点坐标,形状为 (6, 2) p2_p6 = np.linalg.norm(eye_pts[1] - eye_pts[5]) # 垂直距离 1 p3_p5 = np.linalg.norm(eye_pts[2] - eye_pts[4]) # 垂直距离 2 p1_p4 = np.linalg.norm(eye_pts[0] - eye_pts[3]) # 水平距离 return (p2_p6 + p3_p5) / (2.0 * p1_p4)

逻辑说明:分子是两个垂直距离之和,分母是水平距离的两倍。这么做是为了抵消人脸到摄像头的距离变化——人往后靠或者往前凑,水平和垂直距离等比缩放,比值基本不变。这也是 EAR 比直接看“眼睛高度像素数”更稳定的原因。

参数说明:眼周 6 个关键点在 68 点模型里的索引是固定的,左眼 37 到 42,右眼 43 到 48。写代码时用coords[37:43]和coords[43:49]切片,不要自己手工去数坐标,数错一个索引整个判断就反了。

3.2 PERCLOS 时间窗统计与疲劳分级

单帧 EAR 没有统计意义,人本来就会眨眼,一帧闭眼不能说明什么。PERCLOS 统计的是单位时间内眼睛闭合帧数占总帧数的比例,工程实现上用一个滑动窗口队列,每帧推入 0 或 1,然后算均值。

from collections import deque eye_closed_history = deque(maxlen=900) # 保持 60 秒的帧记录 def update_perclos(ear, ear_thresh=0.22, history=eye_closed_history): # EAR 低于阈值记为闭眼 1,否则记为 0 history.append(1 if ear < ear_thresh else 0) closed_ratio = sum(history) / len(history) return closed_ratio

逻辑说明:deque(maxlen=900)满 900 个元素后自动弹出最旧的一帧,这样闭眼占比是个滚动值,不会因为跑到第 30 分钟累计数太大而失去敏感性。900 这个数字对应假设采集帧率 15 FPS、窗口 60 秒。

参数说明:ear_thresh=0.22是参考值,不是铁律。有人眼睛大有人眼睛小,戴眼镜和不戴眼镜基线都不同,后面第 5 章会讲怎么针对场景标定,这里先用经典值跑通流程。疲劳分级可以这样切:PERCLOS 值低于 0.15 视为正常,0.15 到 0.3 轻度疲劳,高于 0.3 直接报警。阈值最终可以在界面上做成可调参数,方便现场调试。

3.3 阈值标定:三种参考标准怎么选

疲劳判据在学术界有多个标准:P80 表示眼睑遮住瞳孔超过 80% 才计为闭合;FM 是眼皮遮住一半就算;H3 是眼球运动频率的门槛。落到代码里,P80 对应比较低的 EAR 阈值,FM 对应用一个中间值。这个资源实现的方案是拿 0.22 作为通用基线,同时留出配置接口。

常见做法是把 EAR 阈值、时间窗口、报警级联做成一个 JSON 配置文件,现场部署时改动配置不需要重新编译代码。我之前跑实验时会把不同阈值打印成一行日志存下来,回头对比误报率哪个低,最后发现纯靠调阈值改善有限,真正影响大的是摄像头安装角度和下视角。摄像头放在方向盘正前方抬高 30 度看人脸,和放在仪表台平视,同一套阈值结果能差一档。

4. 身份识别与预警联动:从单帧算法到可交付的系统

4.1 人脸识别:embedding 比对与驾驶位绑定

疲劳检测不关心坐在车里的是谁,但系统如果要做多司机管理,比如网约车司机轮班、车队后台记录是谁疲劳了,就必须有人脸识别能力。资源里的做法是用预训练的 CNN 模型把每一帧人脸编码成一个高维向量,然后和驾驶员库里提前注册的向量做余弦相似度比对。

from scipy.spatial.distance import cosine # embedding_a 是当前帧的人脸向量,embedding_b 是注册时的基准向量 def verify_driver(embedding_a, embedding_b, thresh=0.6): # 余弦距离小于阈值认为同一个人 distance = cosine(embedding_a, embedding_b) return distance < thresh, distance

逻辑说明:预训练卷积网络输出的是 128 维或 512 维的特征向量,这个向量对同一个人在不同光照下的表现足够稳定,对不同人有区分度。比对过程本身是在向量空间里算距离,不涉及传统意义上的“特征匹配”。

参数说明:thresh=0.6是一个偏保守的口令,安全优先,宁可多问几次也别认错人。如果司机库只有三五个人,阈值可以放到 0.65;如果做陌生人拦截,压到 0.5 会让误拒率变高。这里需要注意,资源里配套的人脸识别模型若是在公开人脸数据集上预训练的,直接拿来比对即可,不需要在驾驶场景重新训练,顶多补录几张注册照。

4.2 系统流程与模块划分

整个系统按职责拆成四个模块:视频采集、检测与关键点回归、疲劳判据与身份比对、界面与报警。采集放子线程防止摄像头帧堆积,推理放在单独线程,UI 主线程只负责刷新画面和响应报警信号。模块之间通过队列传递数据,不直接互相调用,这样摄像头换了或者模型换了,改动局部不影响整体。

我通常在项目里维持三条队列:原始帧队列、检测结果队列、疲劳统计队列。生产者的帧率比消费者的推理速度快,如果队列堆积超过 10 帧就强制丢旧帧,保证预警实时性。

4.3 界面与报警线程:PyQt5、声音与日志落盘

界面这一层资源里用的是 PyQt5,报警逻辑放到 QThread 里,UI 线程通过信号槽接收预警等级,避免在子线程里直接操作控件导致崩溃。

from PyQt5.QtCore import QThread, pyqtSignal class AlarmThread(QThread): alarm_signal = pyqtSignal(int) # 0 正常 1 轻度 2 严重 def __init__(self, perclos_getter, parent=None): super().__init__(parent) self.perclos_getter = perclos_getter def run(self): while True: ratio = self.perclos_getter() # 从共享队列读 PERCLOS 值 if ratio >= 0.30: self.alarm_signal.emit(2) elif ratio >= 0.15: self.alarm_signal.emit(1) else: self.alarm_signal.emit(0) self.msleep(1000) # 每秒判断一次,避免高频刷信号

逻辑说明:msleep(1000)是报警轮询频率,每秒一次足够,声音和界面提示都不需要毫秒级刷新。信号只发整数枚举值,UI 侧根据数值决定是亮黄灯还是红灯播放蜂鸣声。

参数说明:如果摄像头实际帧率只有 10 FPS,PERCLOS 窗口 900 帧对应 90 秒,报警响应会晚半拍,这时应该把窗口长度改成 600,让时间窗保持在 60 秒左右。报警响起的同时把当前帧、EAR 值、PERCLOS 值、司机 ID 写进 CSV 日志,字段按时间,司机ID,EAR,PERCLOS,预警等级排列,后面追责或者做数据分析都能回溯。

5. 避坑清单:光照、眼镜、口罩和“蹦迪”的关键点坐标

5.1 光照直射导致检测框“蹦迪”

现象:白天经过高架桥下,或者夜里对面来车远光灯一闪,画面里的人脸框前后两帧位置跳变超过几十像素,EAR 值跟着剧烈抖动,偶尔还会误报疲劳。

原因:MTCNN 对极端亮度变化敏感,过曝时脸部对比度下降,候选框置信度在阈值附近震荡,检测头一会儿锁住眼周一会儿锁住下巴。人脸框本身就抖,68 点坐标随之飘移,EAR 就会瞬时而快速地往下跌。

解决:先用 OpenCV 对每帧做 CLAHE 自适应直方图均衡,再做检测。具体做法是cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)),作用在 YUV 空间的 Y 通道上。我实测加这一步后夜间检测率提高约两成,框的稳定性明显改善。另外对前后帧框位置做一阶低通滤波,也让坐标点输出平滑不少。

注意:直接对整个 RGB 图做 CLAHE 会把颜色特征破坏掉,人脸识别 embedding 会受影响。处理顺序是原始帧给 MTCNN 和 dlib,CLAHE 后的帧给疲劳判据,身份比对那条路原样走原始帧。

5.2 眼镜反光与闭眼误判的纠缠

现象:戴眼镜的司机录制测试时,系统在正常睁眼状态下报出闭眼,尤其在眼镜下方或者镜片边缘有反光的时候,误判发生频率显著上升。

原因:68 点回归器把镜框边缘或反光高光误当成眼睑中点,导致垂直距离被压扁,EAR 掉到闭合阈值以下。本质原因是训练 68 点模型的数据集里眼镜样本不足,眼镜腿和镜框的遮挡让关键点回归出现系统性偏移。

解决:不要试图靠提高 EAR 阈值来补偿,那样会把真实闭眼也放过。我在代码里加了“左右眼一致性校验”:如果左右眼 EAR 差值超过 0.12,判定当前是外部干扰,丢弃这一帧不参与 PERCLOS 统计。闭眼时两只眼几乎同步闭合,差值很小,反光通常只影响单侧,这个简单规则能消掉大部分误报。

5.3 口罩遮挡下嘴部特征失效

现象:司机戴上口罩后,程序如果启用了张嘴打哈欠检测,会频繁误报疲劳甚至报出陌生人脸。

原因:口罩把嘴部 68 点关键点区域完全遮住,MAR(嘴部纵横比)计算出来的数值里全是噪声。更麻烦的是 MTCNN 的 mouth keypoint 被推到口罩织物表面上,脸部对齐基准线整体偏移,间接影响眼部区域的裁剪。

解决:资源里的疲劳判据以 EAR 和 PERCLOS 为主,我在演示代码里把 MAR 模块默认关闭,保留接口但是不参与报警决策。戴口罩场景下睁眼时间反而更长,单纯靠眼部特征判断疲劳已经足够,嘴部信息在完全遮挡状态下没有可信度。

5.4 歪头睡导致坐标点整体旋转

现象:司机疲劳到一定程度会歪头靠在头枕上,此时 EAR 数值整体变小,系统误判为闭眼,但司机实际眼睛半闭半睁。

原因:EAR 是比例算出来的不变量,前提是脸部平面正对摄像头。歪头后眼睑中点到眼角的相对位置在投影几何上发生变化,垂直距离被压缩,比值失真。

解决:先估算头部欧拉角,使用 68 点中的鼻梁、下巴和两眼中心三个点做姿态估计。如果测得 yaw 或 roll 超出 ±30 度,就不是一个可靠的 EAR 测量姿态,这时候改用闭眼持续时间作为备选判据——连续 15 帧 EAR 低于 0.15 且姿态角持续偏转,直接触发严重疲劳报警。倾斜姿态下关键点回归本身也在失效,把姿态角作为置信度开关加到判断逻辑里是最稳妥的做法。

5.5 长跑几小时后内存缓慢增长

现象:系统开机运行四五个小时后,内存占用从 500MB 慢慢涨到 2GB,最后界面卡死。

原因:dlib 的 shape predictor 每次调用都会返回完整的 shape 对象,如果代码里没有及时释放引用,或者把每一帧的coords都累积进全局列表做统计,内存会线性增长。

解决:PERCLOS 的deque(maxlen=900)已经做了上限控制,检查一下日志列表或者检测框绘制中间变量有没有做同样的限制。我一般对调试用的临时列表强制maxlen=128,不管攒什么历史数据都加一个上限,写完了就清空。

6. 进阶验证:给自己模型一份“体检报告”再交差

6.1 测试集与标注口径

一套检测系统交出去之前,至少要回答“误报率和漏报率分别是多少”。资源里准备的数据集如果是按帧划分的图片,先按司机 ID 分组而不是按视频随机切分,避免同一个人出现在训练侧和测试侧造成虚假高分。标注口径统一成四类:睁眼、闭眼、打哈欠、目视前方,每一帧标注对应 EAR 真值区间。

6.2 用 ROC 曲线校准最终阈值

跑测试集时把每个样本的 EAR 和 PERCLOS 存下来,然后做一次二分类评估。

import numpy as np from sklearn.metrics import roc_curve # labels 是人工标注的标签,1 表示疲劳闭眼;scores 是系统输出的 PERCLOS 值 fpr, tpr, thresholds = roc_curve(labels, scores) # 选出等错误率最低的阈值:距离左上角最近的点 distances = np.sqrt(fpr ** 2 + (1 - tpr) ** 2) best_idx = np.argmin(distances) best_thresh = thresholds[best_idx] print("calibrated threshold:", round(best_thresh, 3))

逻辑说明:ROC 曲线能直观看到阈值放到多少时误报率和漏报率平衡。源码里默认 0.22 或 0.3 都只是经验值,数据集跑出来的最优阈值可能偏到 0.25,拿这个值写进配置文件才算完成闭环。

数据增强是另一个容易被忽略的环节。给数据集里每张图做亮度随机增减、水平翻转、加少量高斯噪声,能让 MTCNN 和 68 点回归在夜间和摄像头差的环境里更稳。翻转要小心:人脸是半脸对称的,闭眼状态翻转后语义不变,但左眼右眼的关键点顺序要跟着换。从那以后我每次交付疲劳检测项目都强制走一遍“采集样本 → ROC 标定 → 实车路测”,路径上每抠出来一个阈值参数都写进配置文件的注释里。这套习惯帮我少背了不少锅,也把误报率从 demo 阶段的 10% 压到了实车可接受的 2% 上下,希望帮到你。

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

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

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

立即咨询