ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Python与CNN的驾驶员疲劳检测预警系统:人脸识别与阈值标定实战

基于Python与CNN的驾驶员疲劳检测预警系统:人脸识别与阈值标定实战 简介基于Python与卷积神经网络的驾驶员疲劳检测与预警系统毕业设计项目包含完整源码和数据集将人脸识别技术与疲劳状态判断相结合覆盖模型训练、测试、实时检测等环节。项目主要面向计算机、通信、人工智能、自动化等相关专业的学生、老师或从业者可支撑期末课程设计、课程大作业或毕业设计也适合基础薄弱者从零学习与进阶提升。zip压缩包内共37个文件以16个Python脚本为核心包括训练、检测、配置等模块另含3个PyTorch模型权重文件pth、1个数据集压缩包、5张测试图片、2个说明文档、9个pyc编译文件及1个日志文件整体大小约500.41MB。源码经过调试测试可稳定运行作者在毕设答辩中评分达98分项目附带权重和数据集可直接用于复现目前已有116人学习浏览。作为一份高完成度的毕业设计参考读者可从中学习SSD、VGG等卷积神经网络的工程实现并在现有框架上修改调整扩展出更多功能。1. 为什么这个毕设的难点不是人脸识别而是“如何判断你还清醒”画面里驾驶员眼睛明明是睁着的系统却开始报警另一个画面里人已经点头瞌睡系统却毫无反应。这是基于Python卷积神经网络实现的人脸识别与驾驶员疲劳检测预警系统最常出现的两种翻车现场。这个标题看起来是一个毕业设计题目实际上是把两项任务串成一条流水线先用CNN做人脸识别确认“你是谁”再做疲劳检测判断“你还清醒吗”最后触发声音预警。对正在选毕设题目的同学来说它是最容易凑齐素材的题目之一但也是最容易低估难度的题目——人脸识别部分已经很成熟真正让你熬夜调试的是疲劳检测的阈值标定和误报压制。2. 系统架构与CNN选型先把一条流水线拆成三个独立任务2.1 任务拆分与数据流向检测、识别、状态估计各司其职先说结论别把整个系统设计成一个端到端的大模型。常见做法是把一条视频流拆成三个串行任务——人脸检测、身份识别、疲劳状态估计每个任务用独立的模型或算法最后在一个主循环里整合。数据流是这样的摄像头拿到一帧BGR图像先交给检测器找到人脸框把框内图像对齐、缩放后送给CNN做特征提取与已登记的驾驶员特征比对判定身份是否合法同时在同一帧里用关键点定位模型取出68个面部关键点计算眼睛开合度EAR和嘴巴开合度MAR。当身份不对时直接提示“非授权驾驶员”当身份正确但疲劳指标连续超过阈值时触发两级预警。这样拆的好处是每一环都可以单独调试和替换。检测器漏检了问题在人脸检测不会牵连识别模块疲劳阈值标歪了只影响预警逻辑不用重训模型。把三者分开还有一个现实理由数据量不匹配。人脸识别可以直接用公开的预训练权重微调但疲劳状态眼睛闭合程度、打哈欠程度的标注数据很难拿自己录自己标成本很高。如果强行做一个多任务端到端模型负责疲劳分支的数据往往只有几十段视频训练时容易拖垮整个网络。分开做识别部分用成熟方案疲劳部分用几何特征打底、再用小CNN兜底训练负担小得多。这个方案也对应常见的人脸识别门禁系统的思路但有一个关键差异门禁可以要求人正对摄像头、保持视线平视驾驶员场景不行——驾驶员可能出现侧脸、低头、戴墨镜、暗光等各种姿态。所以检测模块不能只处理正脸。我一般会在检测层同时跑两个检测器一个HOG正脸检测器用于快速粗筛一个MTCNN用于姿态变化大的情况。粗筛结果稳定时直接用粗筛连续失败超过几帧再切到MTCNN兼顾实时性与召回率。2.2 CNN模型选型从LeNet-5到MobileNetV2为什么我不建议堆深度两个任务对CNN的需求完全不同。人脸识别的核心是让网络学到“同一张脸在不同光照、姿态下的不变特征”思路和LeNet-5这类早期网络的设计逻辑一脉相承卷积层提取局部纹理池化层做空间降采样最后接全连接输出特征向量。区别在于深度和损失函数。早期结构只有几层卷积现在人脸识别标配至少是ResNet-18级别的骨干配合ArcFace、CosFace这类带角度间隔的损失效果才有保障。网上很多卷积神经网络结构图画得很深但在车载场景里没必要盲目照抄——骨干网络每深一层推理延迟就多几毫秒而驾驶员监控对延迟非常敏感疲劳预警晚一秒钟都可能出事故。实际的选型逻辑要看部署目标。如果整个系统跑在桌面电脑上识别部分用ResNet-18微调就够如果要部署到树莓派或Jetson Nano这类边缘设备换成MobileNetV2的嵌入层输入分辨率压缩到112×112。疲劳检测部分的CNN更轻——它只需要判断眼睛是开还是闭、嘴巴是正常还是张开本质是二分类到三分类问题用LeNet-5的改进版或MobileNetV2的前几层就够用了。我见过有人把疲劳检测也换成ResNet-50训练时间长、推理帧率掉一半精度提升却不到两个百分点得不偿失。任务常用网络输入尺寸输出备注人脸检测MTCNN640×480 原图缩放人脸框5点关键点比dlib HOG稳稍慢人脸识别MobileNetV2 / ResNet-18112×112512维归一化向量用ArcFace微调疲劳状态轻量CNN / LeNet改进48×48 眼部嘴部开闭眼、打哈欠概率也可用传统EAR/MAR有一点要提醒很多人拿到预训练模型直接跑发现识别效果不错但疲劳检测不准。原因是疲劳检测本质是一个时序问题单帧判断容易抖动必须依赖连续帧的统计。模型选型阶段就应当把“帧间稳定性”作为指标而不是只看单帧精度。所以我在疲劳分支里保留了一套传统视觉算法作为主力CNN只用来做辅助分类两者结果做投票。这样牺牲一点理论上的“AI含量”换来的是答辩演示时不会当众翻车的稳定性。用CNN做身份特征提取时训练数据量和损失函数直接决定效果。数据量少于几千张时不要自己从零训练用公开人脸数据集上预训练好的权重再拿驾驶员自己的照片微调最后几层。损失函数上三元组损失实现简单但容易不收敛ArcFace的收敛稳定性和类间区分度更好是当前更稳妥的选择。这两者的差别在欧氏距离阈值上体现得很明显三元组模型对阈值变化敏感ArcFace模型的相似度分布更集中后续标定阈值时省事很多。3. 人脸识别模块落地数据集组织与余弦相似度阈值怎么定3.1 数据集怎么组织known_faces目录、按人划分训练集先定目录结构。最常见也是最省事的组织方式是把“已知驾驶员”的人脸照片按文件夹放好每个文件夹一个人名这样读取、增删、比对都直观。疲劳检测的数据集另放一个目录按睁眼、闭眼、打哈欠三个类别分文件夹类别名直接用英文避免路径中文引起的编码问题。data/ known_faces/ driver_zhang/ 01_front.jpg 02_left.jpg 03_right.jpg 04_night.jpg driver_li/ 01_front.jpg 02_front.jpg unknown/ # 非驾驶员照片用于负样本测试 fatigue_data/ open_eye/ closed_eye/ yawn/目录建好之后预处理这一步很多人会跳过但我建议不要省把每张人脸裁出来再做一次仿射变换让两只眼睛在水平线上缩放到112×112统一尺寸。不做对齐直接喂图最大的问题不是精度下降而是同一个人在不同角度下的特征距离可能比不同人的距离还大阈值根本无法标定。常见做法是用dlib的68个关键点或MTCNN的5个关键点取双眼坐标计算旋转矩阵后做仿射变换代码量不大但对后续识别效果影响非常明显。# preprocess_faces.py import cv2 import dlib import numpy as np detector dlib.get_frontal_face_detector() predictor dlib.shape_predictor(models/shape_predictor_68_face_landmarks.dat) def align_face(img, rect, size112): # rect是dlib检测出的人脸框 landmarks predictor(img, rect) left_eye (landmarks.part(36).x, landmarks.part(36).y) right_eye (landmarks.part(45).x, landmarks.part(45).y) dx right_eye[0] - left_eye[0] dy right_eye[1] - left_eye[1] angle np.degrees(np.arctan2(dy, dx)) # 以左眼为基准旋转使双眼水平 M cv2.getRotationMatrix2D(left_eye, angle, 1.0) rotated cv2.warpAffine(img, M, (img.shape[1], img.shape[0])) # 旋转后重新裁人脸中心区域 x, y, w, h rect.left(), rect.top(), rect.width(), rect.height() center_x, center_y x w // 2, y h // 2 face rotated[center_y - 60:center_y 60, center_x - 60:center_x 60] face cv2.resize(face, (size, size)) return cv2.cvtColor(face, cv2.COLOR_BGR2RGB)这段预处理里最关键的参数有两个size112和裁剪半径60。112×112是当前人脸识别模型的主流输入尺寸兼顾信息和速度裁剪半径60对应约120×120的人脸区域比直接按rect缩放多裁了一圈目的是把下巴和额头边缘也包含进来避免关键点定位误差导致人脸内容被切掉。旋转矩阵以左眼为基准点右眼y坐标在旋转后与左眼基本一致这个操作对侧脸尤其重要。数据和预处理都就绪后接下来要做的不是立刻训练而是先做一次“特征空间体检”把每个人的照片都过一遍特征提取模型看同类样本的余弦相似度分布。如果同一个人不同照片的相似度低到和不同人差不多那问题不在阈值而在上一节的预处理或数据采集质量上。这个体检步骤能帮你在训练之前就发现数据问题省得后面反复调阈值。3.2 用Keras加载CNN嵌入模型把人脸变成512维向量特征提取模型我推荐用预训练好的ArcFace权重Keras加载非常方便。没有现成ArcFace权重时退一步用MobileNetV2去掉顶部分类层再接一个全连接层压缩到512维效果也能接受只是类间区分度会差一些。无论如何模型输出的向量都要做L2归一化这样人脸比对可以直接用余弦相似度或内积计算数值稳定且阈值语义直观。# embedder.py import numpy as np from tensorflow.keras.models import load_model model load_model(models/arcface_mobile_112.h5) # 假设模型输入 (1,112,112,3)输出 (1,512) def get_embedding(face_rgb): # face_rgb 是上一节 align_face 的输出RGB格式值域0-255 x np.expand_dims(face_rgb.astype(np.float32) / 127.5 - 1.0, axis0) emb model.predict(x, verbose0)[0] norm np.linalg.norm(emb) if norm 1e-6: return None return emb / norm注意归一化方式。很多人在这一步用的是/255.0但ArcFace系的模型通常要求/127.5 - 1.0把像素值映射到[-1,1]区间。用错预处理会导致特征分布偏移识别率直接掉一大截而且这种问题在代码层面不明显很容易被当成“模型不行”。model.predict里我加了verbose0因为主循环里每帧都要调用一次不关掉输出信息终端会被日志刷到卡顿。embedding的输出维度是512这是业界比较通用的设置。维度太低类间区分不够维度太高存储和比对开销变大小数据量下反而容易过拟合。实际调试中512维在余弦相似度上的区分度已经足够不需要刻意加大。3.3 识别主循环与阈值标定为什么不能照搬网上的人脸识别算法参数身份比对这一步不复杂把当前帧的512维向量与数据库里的驾驶员向量逐个算余弦相似度取最大值超过阈值判为本人低于阈值判为unknown。但阈值不要照搬论文或开源项目网上给的大多是LFW等数据集上的最优值换到你的摄像头、你的光照环境、你的驾驶员照片分布完全不同。# recognize.py import numpy as np known_embs { driver_zhang: np.load(data/known_faces/driver_zhang/emb.npy), driver_li: np.load(data/known_faces/driver_li/emb.npy), } def recognize(emb, theta0.45): best_name, best_sim unknown, -1.0 for name, known_emb in known_embs.items(): sim float(np.dot(emb, known_emb)) if sim best_sim: best_sim, best_name sim, name if best_sim theta: return unknown, best_sim return best_name, best_sim这里有两个参数值得说theta0.45和相似度取内积。余弦相似度范围是[-1,1]理论上一张很端正的正脸照同一人的相似度能到0.6以上侧脸或暗光下掉到0.3也不奇怪。如果你用的模型是ArcFace微调过的同一人不同姿态的相似度会比较集中阈值0.45能把部分非法用户挡在门外如果你用的是裸MobileNetV2这个阈值要放宽到0.35左右代价是误识率上升。门禁系统可以要求用户“正视摄像头、保持光线充足”但驾驶员场景做不到所以阈值必须往“拒识多一点”的方向调——宁可把驾驶员误报成unknown也不能把非驾驶员放进来。4. 疲劳检测与预警EAR/MAR/PERCLOS的参数标定与状态机4.1 用dlib的68个关键点算眼睛和嘴巴的开合度疲劳检测里dlib的68点关键点定位是目前最省事的方案。它是传统回归方法不需要GPUCPU上跑一帧也就几毫秒比再跑一个CNN划算得多。关键点索引是固定的左眼是36到41右眼是42到47嘴巴外轮廓是48到59。用这些点就能计算眼睛纵横比EAR和嘴巴纵横比MAR。# fatigue_features.py import dlib import numpy as np import cv2 detector dlib.get_frontal_face_detector() predictor dlib.shape_predictor(models/shape_predictor_68_face_landmarks.dat) def eye_aspect_ratio(eye_pts): # eye_pts: 6个点按dlib索引顺序传入 A np.linalg.norm(eye_pts[1] - eye_pts[5]) B np.linalg.norm(eye_pts[2] - eye_pts[4]) C np.linalg.norm(eye_pts[0] - eye_pts[3]) return (A B) / (2.0 * C) def mouth_aspect_ratio(mouth_pts): # mouth_pts: 12个点dlib索引48-59 A (np.linalg.norm(mouth_pts[2] - mouth_pts[8]) np.linalg.norm(mouth_pts[3] - mouth_pts[7]) np.linalg.norm(mouth_pts[4] - mouth_pts[6])) / 3.0 B np.linalg.norm(mouth_pts[0] - mouth_pts[5]) return A / B def get_ear_mar(frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces detector(gray, 0) if len(faces) 0: return None, None, None rect faces[0] shape predictor(gray, rect) pts np.array([(shape.part(i).x, shape.part(i).y) for i in range(68)]) left_eye pts[36:42] right_eye pts[42:48] mouth pts[48:60] ear (eye_aspect_ratio(left_eye) eye_aspect_ratio(right_eye)) / 2.0 mar mouth_aspect_ratio(mouth) return ear, mar, rectEAR的几何意义很直观分子是上下眼皮的垂直距离分母是眼裂宽度。睁眼时EAR大约在0.28到0.35之间闭眼时降到0.1左右。MAR是上下嘴唇的平均距离除以嘴角宽度正常说话时在0.2到0.4波动打哈欠时能到0.6以上。两个指标都是无量纲比例不受摄像头距离影响这是它们能跨设备使用的基础。但要注意不同人脸型不同眼睛大小差异很大某些人睁眼EAR只有0.24某些人闭眼EAR能到0.18所以阈值必须针对你的驾驶员数据库单独标定。Mouth里的关键点取法有一点讲究。dlib的48到59这12个点中49、50、51、52、53、54是上嘴唇55到59是下嘴唇和嘴角区域。我没有直接用单个垂直距离而是取了三条垂直线的平均值这样对关键点抖动更鲁棒单帧误差不会直接导致误判。嘴巴张开的程度在连续帧里往往有过渡和一秒跳变不同这个特征对打哈欠检测很有用。4.2 从单帧判断到PERCLOS统计预警状态机怎么搭才不误报单帧EAR小于阈值只能说明这一刻眼睛闭合不能说明疲劳。真实场景里正常眨眼大约持续150到250毫秒如果每眨一次眼都报警系统第一次上路就会被司机关掉。所以业界标准做法是PERCLOS即在一定时间窗口内眼睛闭合时间所占的比例配合连续帧数去抖作为预警条件。# main_alert.py from collections import deque import winsound import time EAR_THRESH 0.22 # 闭眼判据需要按人标定 MAR_THRESH 0.60 # 打哈欠判据 CLOSE_FRAMES 3 # 连续3帧闭眼才算一次闭眼事件 WINDOW_SEC 60 # PERCLOS统计窗口60秒 PERCLOS_THRESH 0.40 # 窗口内闭眼占比超过40%触发预警 FPS 30 ear_history deque(maxlen15) # 滑动平均抑制关键点抖动 close_count 0 closed_frames 0 total_frames 0 window_start time.time() while True: frame get_camera_frame() ear, mar, rect get_ear_mar(frame) if ear is None: continue ear_history.append(ear) smoothed_ear sum(ear_history) / len(ear_history) if smoothed_ear EAR_THRESH: close_count 1 else: close_count 0 if close_count CLOSE_FRAMES: closed_frames 1 total_frames 1 elapsed time.time() - window_start if elapsed WINDOW_SEC: perclos closed_frames / total_frames if perclos PERCLOS_THRESH: winsound.Beep(1000, 2000) # 声音预警 closed_frames 0 total_frames 0 window_start time.time() if mar MAR_THRESH: pass # 打哈欠单独计数这里留接口这段逻辑里去抖的关键是ear_history这个队列。它保存最近15帧的原始EAR取平均值作为当前值能有效抵抗dlib关键点的随机抖动。CLOSE_FRAMES3意味着连续3帧约0.1秒低于阈值才算闭眼单帧误检直接被过滤掉。PERCLOS的分子closed_frames统计的是“被认定为闭眼的帧数”分母是窗口内的总帧数两个数都必须在窗口结束时清零重计。参数上EAR_THRESH0.22和PERCLOS_THRESH0.40是最需要重新标定的两个值。0.22是广泛经验值但如果你用的是广角摄像头或分辨率很低的设备关键点定位误差会直接抬高闭眼阈值0.40是参考了驾驶疲劳研究里常说的P80标准——眼睛闭合程度超过80%的时间占比超过40%属于疲劳状态。实际项目里我会录三段视频正常驾驶、频繁眨眼、真实犯困用这三段数据扫一遍阈值画误报率和漏报率曲线再定值。预警状态机再往上就会涉及两级策略第一级是“提醒”当闭眼事件在60秒内出现2次以上时播放一次短促提示音让驾驶员意识到状态下滑第二级是“强烈警告”PERCLOS超过0.4或连续闭眼超过2秒时连续播放高音量警报直到睁眼。这里的关键是报警后必须检测到驾驶员回应——比如睁眼或打哈欠——才解除警报否则报警本身也会被疲劳的人忽略。5. 避坑与排查五条从“检测不到”到“误报疲劳”的血泪经验5.1 dlib在侧脸和低头时完全检测不到人脸现象驾驶员正常看路时没问题一旦低头看手机或侧头看后视镜画面里人还在但人脸框直接消失疲劳检测模块停摆。原因dlib的HOG检测器是正脸模型对姿态变化极其敏感。解决检测层换成MTCNN或者在HOG检测失败时保留上一帧的人脸位置继续跟踪连续丢失超过30帧才判定无人脸。我建议至少加一个简单的tracker否则系统在真实驾驶中会有一大半时间处于“失明”状态。5.2 换了摄像头之后识别率暴跌现象实验室用罗技C920调好的系统换到笔记本内置摄像头后同一个人识别不出来陌生人的相似度反而升高。原因不同摄像头的色彩响应、焦距、视场角不一样出厂的白平衡和自动增益算法也不同。解决固定摄像头型号之后重新采集驾驶员的登记照片不要沿用旧照片拍摄环境尽量贴近实际使用环境比如在驾驶位、同角度、同光线条件下采集登记照。这一步相当于给人脸识别算法做“环境校准”比调阈值有效得多。5.3 睁着眼的EAR算出来比阈值还低现象驾驶员明明睁着眼系统连续报疲劳测试者对着镜头瞪眼也没用。原因EAR公式没有错错在摄像头离人太远人脸在画面里只占几十个像素关键点定位误差被放大到真实距离的30%以上。解决先看一眼画面里人眼区域的对角线像素数如果小于40像素换摄像头或调整安装位置。像素不够时任何算法都救不回来这不是玄学是物理分辨率问题。5.4 训练集准确率99%验证集只有60%现象人脸识别模型在自己的训练数据上表现完美换一批照片立即崩。原因训练集按照片随机划分同一个人在不同照片之间高度相似模型实际上记忆了人而不是学到特征。解决训练集和验证集必须按“人”划分同一人的所有照片只能出现在一边。疲劳检测同理同一段视频的帧不能同时出现在训练和验证里否则帧间相关性会让评估结果虚高。5.5 dlib和TensorFlow的环境配置让项目卡三天现象运行import dlib直接报错或者Keras版本和TensorFlow版本对不上模型加载到一半崩溃。原因dlib从源码编译需要CMake和C工具链很多人的机器上Visual Studio没装“使用C的桌面开发”组件Keras 2.x和TensorFlow 2.x之间也有兼容矩阵。解决先按官方文档装依赖再装dlib预编译wheel最后装TensorFlow全程在VSCode的终端里确认当前解释器是项目专用的虚拟环境不要混用全局环境。环境配置是这套系统里最没有技术含量但最能拖时间的坑建议第一步就搞定。6. 从能跑到能答辩三个验证方法和一个值得投入的进阶方向系统能跑起来之后真正的验收工作才开始。我一般会做三个验证。第一个是录制一段5到10分钟的真实驾驶视频包含正常驾驶、频繁眨眼、明显犯困三个阶段。把这段视频逐帧或隔帧标注成三个状态清醒、轻度疲劳、重度疲劳然后让系统跑一遍算出每个状态的准确率。重点看“重度疲劳漏报率”——这直接关系到系统是否有实用价值答辩评审也喜欢问这个数字。第二个是阈值标定测试。写一个小脚本让EAR阈值从0.15扫描到0.30每档都跑一遍标准视频记录误报次数和漏报次数画两条曲线取交点作为最终阈值。这个扫描结果要打印出来附在毕设论文的附录里是非常有说服力的实验数据比口头解释“阈值凭经验设置”强太多。第三个是稳定性测试。同一段输入视频连续跑10次每次输出的报警时间戳应当完全一致。如果两次运行结果不同说明系统存在非确定性因素——可能是线程调度、可能是累计误差这种问题在答辩现场复现的概率极高必须提前排查。如果时间有余量有一个方向很值得投入把dlib替换成MediaPipe的Face Mesh。MediaPipe不仅输出468个关键点眼睛、嘴唇精度更高还自带面部几何模型能直接估计头部姿态角和双眼注视方向这些都是疲劳检测最有价值的补充特征。再往下就是把CNN模型量化到INT8用TensorRT部署到Jetson Nano上推理延迟能压到10毫秒以内整套系统才能真正往车载嵌入式方向走。这套系统我前后调过三轮最大的教训是不要相信论文里的阈值也不要相信别人代码里的阈值只相信你自己摄像头拍摄、你自己标注、你自己扫描出来的阈值。疲劳检测这件事数据分布一变所有经验值都可能作废。把标定流程沉淀成脚本比调整某一个具体阈值更重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表