简介:一份面向毕业设计、课程设计与期末大作业的Python项目:基于卷积神经网络的人脸识别驾驶员疲劳检测与预警系统;该系统利用摄像头实时采集驾驶员图像,通过预处理、CNN特征提取和状态分类,综合分析眼睛闭合时长、眨眼频率等面部动态指标,及时判断疲劳状态并触发警报,帮助降低因疲劳驾驶带来的安全风险。资源覆盖数据采集、预处理、特征提取、疲劳分类到预警的完整流程,可帮助相关专业学生快速掌握深度学习目标检测与分类任务的工程实现。压缩包共37个文件,包含16个Python源码脚本(摄像头检测、训练、测试、界面交互等)、9个pyc编译文件、5张测试图像、3个已训练模型权重文件、2个文本说明文档、1个数据压缩包和1个日志文件,整体约500.4MB。已有233人学习浏览;读者可直接运行源码,配合已训练权重与配套数据集快速测试效果;文档中介绍了目录结构与使用流程,方便复现实验、调试模型,并在此基础上进行算法改进与功能扩展,满足毕业设计答辩或课程项目验收的实际需要。
1. 深夜高速上的一次打盹,让基于CNN的驾驶员疲劳预警成了刚需
疲劳驾驶是长途物流、网约车和通勤场景里真正能出人命的问题,法规管不住生理上的困意。于是,基于卷积神经网络(CNN)的人脸识别驾驶员疲劳检测与预警系统成了车辆安全方向的高频毕业设计选题:一只摄像头盯住驾驶员面部,实时判断眼睛开闭和打哈欠状态,闭眼超过阈值立刻声音加弹窗预警。标题里的源码、数据集和项目介绍,意味着这是一套能直接复现的完整工程,而不只是零散算法片段。它适合两类人:一是需要完整交付物的计算机视觉方向学生,二是想在低算力设备上验证实时检测可行性的车载应用工程师。
2. 卷积神经网络在这里解决什么:两段式识别框架与疲劳判据
2.1 CNN的两段式路线:先找脸,再看眼睛状态
这个项目里的卷积神经网络不直接输出“疲劳/清醒”这样的终态,而是拆成两段,各管一摊。第一段做人脸检测和关键点定位,常见模型有MTCNN、RetinaFace,最保守的是dlib的68点检测器,输出人脸包围框和68个关键点坐标。第二段才是状态分类:把眼睛和嘴巴的局部区域裁剪出来,喂给一个轻量分类网络,比如ResNet18或MobileNetV3,输出“睁眼/闭眼”“张嘴/闭嘴”的概率。
为什么拆两段而不是用一个大网络端到端判断?这里面有个很实际的数据问题。端到端模型需要大量带“疲劳/清醒”整帧标注的数据,这种数据公开集里很难找,自己标又容易主观。拆开后,人脸检测用现成的高精度模型即可,状态分类只需要在局部眼图/嘴图上训练,数据量少一个数量级,还能单独调优。看卷积神经网络结构图你会发现,不管MTCNN还是MobileNetV3,基本骨架都是卷积、池化、激活交替堆叠,换网络只是换拓扑深度和宽度,前向推理的逻辑不变,这给后面换轻量模型留下了余地。
2.2 疲劳判据为什么要先算EAR和MAR
眼睛状态分类输出的是概率,但要变成“闭眼几帧”这种可统计的连续量,最常用的指标是EAR(眼睛纵横比)。它不依赖分类网络也能算,用关键点坐标直接得出,表达式是:
def compute_ear(eye_pts): # eye_pts: 单个眼睛的6个关键点坐标,形状为(6, 2) p1, p2, p3, p4, p5, p6 = eye_pts # 上下眼睑的两组垂直距离 v1 = np.linalg.norm(p2 - p6) v2 = np.linalg.norm(p3 - p5) # 眼角水平距离 h = np.linalg.norm(p1 - p4) # 加1e-6防止侧脸时水平距离趋近0导致除零 return (v1 + v2) / (2.0 * h + 1e-6)这段代码的关键在两点:一是p1到p6的索引顺序必须固定,dlib的68点中左右眼分别对应索引36~41和42~47,顺序乱了EAR整体就不可比;二是分母加了1e-6,侧脸时眼角距离接近0,不加这个保护EAR会直接爆炸。正常人睁眼EAR在0.25~0.35之间,闭眼会掉到0.15以下,具体数值跟镜头距离、脸型都有关,所以阈值不能只靠经验值。嘴部同理用MAR计算,张口时垂直距离突然拉大,MAR大于0.5就可以判为一次哈欠事件。我一般建议EAR和分类模型双路并行走or,原因在于戴眼镜或角度偏时关键点会漂移到镜框上,几何量不可信,但分类网络在裁剪图上还能给出一个合理概率。
疲劳帧统计上用PERCLOS。它的定义是单位时间内眼睛闭合面积超过80%的时间比例,60秒窗口内PERCLOS大于0.4算疲劳,这是车载系统里最常被引用的判据。把这一套参数汇总成表,方便平时调优时对照:
| 指标 | 计算方式 | 常用阈值 | 作用 |
|---|---|---|---|
| EAR | 眼周垂直/水平距离比 | 单帧 < 0.2 判闭眼 | 识别眨眼与闭眼 |
| MAR | 嘴周垂直/水平距离比 | > 0.5 判哈欠 | 识别打哈欠 |
| 连续闭眼帧数 | EAR低于阈值的连续帧数 | 15~20帧 | 区分正常眨眼和打盹 |
| PERCLOS | 窗口内闭眼帧占比 | 60秒窗口内 > 0.4 | 疲劳综合判据 |
这里的0.2、0.4并不是通用值。不同驾驶员眼睛大小和眯眼习惯差别很大,严谨做法是先让测试者正常睁眼10秒记录EAR均值,再闭眼10秒记录下限,取中间值做个人阈值。这个自适应标定逻辑在答辩时很加分,因为它把“拍脑袋阈值”变成“可解释的标定流程”。
提示:0.2和0.4都是经验值,正式部署前一定要按测试者做一轮单人标定,直接套默认阈值容易在实车测试里翻车。
2.3 数据集怎么选:公开集打底,自拍集补泛化
带“疲劳/清醒”整帧标注的公开数据集很少,常见可用的有:NTHU-DDD驾驶员困倦视频集、CEW眼睛闭合状态图像集、YawDD打哈欠视频集。先跑通流程用CEW就够,它是成对的睁眼/闭眼图像,分类网络目标明确;想覆盖打哈欠再补YawDD。我个人建议在公开集之外自拍补一批数据,因为公开集里的人脸和实际测试人差异很大,模型容易走捷径,比如只学会“看到这张脸就闭眼”。
自建数据的标准流程是:录视频、抽帧、关键点裁剪、人工筛选。抽帧这一步建议用固定间隔而不是关键帧提取,避免引入人工偏差:
import cv2 cap = cv2.VideoCapture("driver.mp4") sample_step = 5 # 每5帧取1帧 out_dir = "dataset/eyes/" frame_id = 0 while True: ret, frame = cap.read() if not ret: break # 按帧间隔抽帧,避免连续相似帧造成的数据冗余 if frame_id % sample_step == 0: gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 假设detect_face返回每只眼睛的bbox,实际用MTCNN或dlib替代 for eye_idx, bbox in enumerate(detect_face(gray)): x, y, w, h = bbox patch = gray[y:y + h, x:x + w] patch = cv2.resize(patch, (64, 64)) # 统一尺寸,适配分类网络输入 label = "closed" if is_closed(gray, bbox) else "open" cv2.imwrite(f"{out_dir}/{label}_{frame_id}_{eye_idx}.jpg", patch) frame_id += 1抽帧时最容易犯的错是不筛选清晰度,把运动模糊帧也写进训练集。建议加一步人工抽查:每个类别随机挑20张图看一眼,模糊、曝光过度的直接删。另外,正脸以外一定要掺入20%左右的侧脸和低头样本,否则实车检测时只要驾驶员一转头,模型就会开始乱跳。人脸识别算法选型的经验在这里同样适用:数据里没有的拍摄角度,最终系统就不认。
3. 把源码跑通:Python环境、模型训练、实时检测与预警
3.1 环境与推理命令:先跑通再谈优化
上手这类毕业设计源码的第一步,不是读代码,而是先建干净环境。常见组合是Python 3.8以上、PyTorch 1.10+或TensorFlow 2.x、OpenCV 4.5+、dlib和NumPy。多数翻车都发生在依赖冲突上,比如opencv-python大版本接口差异、NumPy 2.x下旧代码调用cv2接口直接报错。建议从第一步就用虚拟环境:
python3 -m venv venv source venv/bin/activate # Windows下换成 venv\Scripts\activate pip install --upgrade pip pip install -r requirements.txt # 项目自带依赖清单为什么强调这一步?因为这个项目同时牵涉OpenCV、PyTorch、dlib三套底层库,系统环境里任何一处的版本污染都会被放大成“模型跑不起来”的玄学问题。用venv的好处是后悔药很便宜,翻车了直接删除重建,不需要去系统目录里徒手清依赖。python环境配置这一步在python入门教程里通常只是带过,但在疲劳检测这类带摄像头和GUI的项目里,它反而是启动成本最高的一环。写代码时我更推荐用VS Code打开项目,把解释器指到venv,这样调试时能直接看到变量值,省掉一堆print。
环境就绪后,先只用CPU跑一次最小推理,确认模型文件和数据集路径都对:
python src/detect.py \ --face_model models/mobilenetv3_face.pt \ --classifier models/eye_state.onnx \ --source 0 \ --threshold_ear 0.2注意:
--source 0表示打开0号摄像头;如果没有摄像头,可以先改成一张测试图片的路径,把检测链路单独验证。
参数说明:--threshold_ear 0.2是EAR闭眼下限,和上一章说的标定流程呼应;--face_model和--classifier分别指向两段式模型的权重,这里的路径只是常见命名,实际要以你自己的项目为准。很多源码把整个人脸检测、关键点、状态分类都打包进同一个detect.py,权重路径一旦写错,界面只剩黑屏没有报错,这种静默失败是排查成本最高的。
3.2 训练眼睛状态分类模型:冻结特征层,只调分类头
如果自带源码里有训练脚本,直接读train.py。没有的话,我一般按这个模式补训练入口:加载预训练MobileNetV3或ResNet18,替换最后的全连接层为2分类输出,再冻结浅层特征,只训练深部和分类头。这套做法在小数据集上几乎是唯一不会翻车的路线。
# src/train.py import torch import torch.nn as nn from torchvision import models # 用ImageNet预训练权重初始化,而不是随机初始化 model = models.mobilenet_v3_large(pretrained=True) # 替换最后分类头:960是MobileNetV3-Large最终特征维数 model.classifier = nn.Sequential( nn.Dropout(0.2), nn.Linear(960, 2) ) # 冻结前6个阶段,只让高层和分类头参与训练 for name, param in model.named_parameters(): parts = name.split(".") if "features" in name and len(parts) > 1 and parts[1].isdigit() and int(parts[1]) < 6: param.requires_grad = False criterion = nn.CrossEntropyLoss() # 小数据集用1e-4,权重衰减用1e-5,能明显缓解过拟合 optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-5 )参数说明:pretrained=True是这套代码里最关键的一行,没有它,一两千张眼图从零训练收敛质量会大打折扣;冻结前6个阶段的目的是保留预训练模型在通用纹理上的特征,让眼睛图像上的训练只去调整高层语义;Linear(960, 2)的960是MobileNetV3-Large的最终通道数,换成ResNet18时要改成512,这是换网络最容易漏改的地方。训练20个epoch左右,睁闭眼分类准确率能做到95%以上。但这里必须提醒一句:单帧95%准确率放到实时检测里是不够看的。假设每秒30帧,5%的错误率意味着每秒都有1~2帧误判,报警逻辑必须依赖连续帧统计而不是某一帧的结果。
3.3 实时检测与预警:滑窗统计比单帧阈值可靠
实时主循环的常见结构是:摄像头逐帧读取、人脸检测与关键点定位、计算EAR和MAR、维护一个最近30帧的滑动窗口、连续闭眼帧数累积到阈值才触发报警。下面这段是我最常用的核心结构,可以直接嵌进detect.py的主循环:
# src/detect.py 实时循环核心 from collections import deque ear_history = deque(maxlen=30) closed_frames = 0 # 连续闭眼帧计数 ALARM_EAR_THRESHOLD = 0.2 ALARM_FRAMES = 15 # 连续15帧才报警,约0.5秒 while cap.isOpened(): ret, frame = cap.read() if not ret: break # 人脸检测和关键点定位省略,返回左右眼的眼周6点 eyes = locate_eyes(frame) ear = min(compute_ear(eyes["left"]), compute_ear(eyes["right"])) ear_history.append(ear) if ear < ALARM_EAR_THRESHOLD: closed_frames += 1 else: closed_frames = 0 # 睁眼立刻复位,正常眨眼不会累计 if closed_frames >= ALARM_FRAMES: trigger_alarm(frame) # 弹窗 + 声音告警 # 按30帧窗口估算PERCLOS,用于疲劳事件上报 if len(ear_history) == 30: perclos = (np.array(ear_history) < ALARM_EAR_THRESHOLD).mean() if perclos > 0.45: log_fatigue_event(frame, perclos)这个循环的要点有三个。第一,EAR取两只眼的较小值,这样单眼被遮挡也能报警;第二,closed_frames在睁眼时立刻清零,正常眨眼持续6~9帧,阈值设在15帧就能把眨眼和打盹分开;第三,deque(maxlen=30)每次自动丢弃最老数据,省了自己管理数组边界的麻烦。
阈值和帧率是绑定的。如果你的摄像头实际只有15帧,那15帧就等于1秒,系统灵敏度完全不同。所以上线前一定要先打印日志确认实际帧率,再反推ALARM_FRAMES该设多少。报警触发后还要加一个5秒冷却,否则驾驶员刚睁眼就再次报警,声音告警会变成噪音。这是预警系统里常被忽略的细节:报警系统本身不能变成一个新的干扰源。
4. 避坑指南:训练和实时检测里最容易翻车的5个问题
4.1 闭眼样本太少,模型学成了“永远睁眼”
现象:训练日志里准确率98%以上,拿闭眼测试图一测,输出全是“睁眼”,闭眼召回率不到一半。
原因:公开数据里睁眼图远多于闭眼图,交叉熵损失在多数类上收敛得很舒服,网络没有动力去学少数类。
解决:给损失函数加类别权重,把闭眼类的权重提到睁眼的4倍,一行代码解决问题:
class_weight = torch.tensor([1.0, 4.0]) # [睁眼, 闭眼] criterion = nn.CrossEntropyLoss(weight=class_weight)加权重后闭眼召回率通常能拉回到90%以上。如果还不行,再对闭眼图做过采样:复制后加轻微旋转、亮度抖动,把训练集闭眼比例补到40%左右。注意过采样不要原图复制,否则模型只是在记忆重复样本。
4.2 戴眼镜和强逆光让关键点漂移
现象:测试时不戴眼镜一切正常,戴上眼镜后,慢速眨眼根本检测不到;阳光从侧窗打进来时,人脸框跳来跳去,EAR曲线高频抖动。
原因:68点检测器在眼镜反光和面部阴影下会退化,眼周关键点经常落在镜框或眉毛上,EAR被污染。
解决:关键点输出后做指数平滑,抑制单帧抖动:
alpha = 0.35 # 平滑系数,越小越平滑但滞后越明显 smoothed = None for frame in video_stream: current = detect_landmarks(frame) # (68, 2) if smoothed is None: smoothed = current else: smoothed = alpha * current + (1 - alpha) * smoothedalpha取0.3~0.4比较合适,太小会把眨眼动作也平滑掉。另外,检测前先把图转灰度,再配合直方图均衡化,能明显缓解逆光下的漂移。这几招对戴眼镜场景有效,但墨镜无解,关键点在墨镜上根本不存在,诚实做法是检测不到人脸就提示“请移除遮挡”而不是继续猜。
4.3 夜间补光后模型突然失效
现象:白天测试正常,晚上开启红外补光后画面整体偏灰,模型把大量睁眼帧判成闭眼,误报频发。
原因:红外图像是单通道亮度图,色彩分布和训练用的RGB图差异巨大。RGB模型没见过这种分布,所有置信度都会被拉偏。
解决:最省事的是训练阶段就把输入转成灰度,再加随机亮度抖动和对比度抖动,让模型不依赖颜色。另一种做法是用支持红外切换的USB摄像头,白天走彩色通道,晚上切红外通道时自动关掉彩色分量的参与,保证喂给模型的始终是可见光图。如果源码里没有这两个选项,至少要加一个“图像模式”参数,让夜间部署时可以手动切成灰度模式。
4.4 正常眨眼被误报成疲劳,报警声反而成了干扰
现象:静态坐在摄像头前,平均每10分钟报一次警,把正在调试的人吓了一跳;日志里一查全是“EAR低于阈值超过15帧”。
原因:眨眼持续200~300毫秒,如果摄像头帧率是15帧,一次眨眼就有约3~5帧处于闭合状态;如果ALARM_FRAMES设成5,正常眨眼直接被算成闭眼事件。
解决:把连续闭眼帧数提到15以上,并且闭眼计数在睁眼时立即清零;报警前再用PERCLOS复核一次,两路条件同时满足才触发。也就是说,报警条件是“连续闭眼超过0.5秒,并且最近30帧闭眼比例超过45%”。这样正常眨眼既过不了连续帧数关,也过不了比例关,误报能被压到可以接受的范围。
4.5 CPU上跑不到30帧,画面卡成幻灯片
现象:i5笔记本上跑完整流程只有8~12帧,驾驶员一转头画面就滞后,报警至少慢1秒。
原因:人脸检测、关键点定位、状态分类三个模型串行推理,ResNet50这种大网络单帧就要120ms,还没算OpenCV的前处理开销。
解决:分类网络从ResNet50换成MobileNetV3,输入尺寸从224x224降到64x64,三个模型共用一次预处理结果;如果部署目标是无风扇嵌入式板卡,再把模型导出为ONNX做INT8量化,帧率能从15提到28左右。换模型后准确率会有小幅下跌,但这里的核心不是极限准确率,而是“误报和漏报里选后者”。实时性不足的系统,精度再高也没有意义。
| 模型 | CPU单帧耗时 | 输入尺寸 | 适用场景 |
|---|---|---|---|
| ResNet50 | 约120ms | 224x224 | 算力充足的台式机 |
| MobileNetV3 | 约40ms | 96x96 | 笔记本实时检测 |
| MobileNetV3+INT8量化 | 约25ms | 64x64 | 嵌入式板卡 |
5. 验收这套系统:录制回测、混淆矩阵与鲁棒性测试
5.1 用录制视频回测代替纯现场测试
现场测试最大的问题是不可复现。今天开车灵、明天换个人开就不灵,很难判断是模型退化还是环境变化。我一般会先录三段视频作业:10分钟清醒行驶、10分钟频繁瞌睡模拟(闭眼大于2秒)、10分钟打哈欠场景。然后让detect.py以视频为输入跑离线路测,同时记录每帧预测标签、EAR曲线、报警时刻。这个回测产物是答辩时最有说服力的证据,因为评审能看到每一帧画面和预警时刻对齐。
5.2 混淆矩阵与鲁棒性表:把“玄学”变成指标
眼睛状态分类的验收不要只看准确率,要看混淆矩阵,重点看闭眼召回率。举一个验收时的实际数据:我的闭眼召回率达到96%,但PERCLOS超过0.45的报警灵敏度只有82%。原因是前段几十次“接近闭眼”状态被EAR阈值卡住,没进入闭眼统计。后来把阈值从0.2放宽到0.22,灵敏度上来5个百分点,代价是误报多一些。这类权衡只有用回测指标才能发现。
再加一张鲁棒性测试表,每一行代表一个真实场景:
| 测试条件 | 摄像头位置 | EAR均值 | 报警延迟 | 误报次数 | 结论 |
|---|---|---|---|---|---|
| 白天正光 | 仪表台中央 | 0.28 | 0.6s | 0 | 通过 |
| 白天逆光 | 仪表台中央 | 0.25 | 0.8s | 1 | 灰度均衡后通过 |
| 夜间红外 | A柱侧装 | 0.22 | 1.1s | 5 | 需改灰度训练 |
| 戴眼镜 | 仪表台中央 | 0.24 | 1.0s | 2 | 需增加关键点平滑 |
| 低头看手机 | 中央偏下 | 0.12 | 未触发 | 0 | 人脸漏检,需加大检测角度 |
这张表里最有价值的是“低头看手机”那一行:系统没有任何误报,但也没报警,因为人脸直接出了画面。这是疲劳检测系统的真实边界,答辩时主动讲出来比被问出来要体面得多。最后的验收还可以把人脸检测模块单独换成更轻量的模型,对比一下延时变化,用来证明你的框架是可替换的——这套替换逻辑和人脸识别门禁系统设计里的核心思路是一样的。
写到这里,我把一个血泪经验留给你:别把精力全放在训练准确率上。我第一次用这个方向做项目时,白天测试一切正常,晚上给司机戴上墨镜后系统直接哑火,手忙脚乱改了三天,才发现问题出在训练数据的色彩分布而不是网络结构。现在我都把验收表先列出来,再回去碰模型。希望帮到你。
本文还有配套的精品资源,点击获取