简介:一套基于Python卷积神经网络的人脸识别驾驶员疲劳检测与预警系统完整项目,适用于毕业设计、课程设计与实际项目开发。系统通过摄像头实时采集人脸,综合打哈欠、眨眼、点头三种行为特征,并分析人脸朝向、眼睛开合度、眨眼频率、瞳孔收缩率等数据,实现对驾驶员注意力状态的有效评估与疲劳预警。压缩包共20个文件,主要包括11个py源码文件,覆盖数据预处理、CNN模型训练、检测评估与Tkinter图形界面;2个XML文件用于人脸与眼睛特征检测;1个HDF5模型权重文件及可直接运行的EXE程序,附带系统说明、运行说明与README文档,便于部署和二次开发,整体包体约78.33MB。目前已有723人学习下载。项目架构清晰、注释完整,适合学习CNN落地应用,可直接在此基础上扩展检测指标或优化模型。
1. 疲劳检测系统到底是什么:一个能跑通的CNN毕业设计项目
深夜跑长途,最危险的往往不是路况,而是驾驶员自己先撑不住。市面上很多疲劳检测方案都在做“闭眼超过几秒就报警”的规则,可一旦遇到戴墨镜、眼睛小、光照剧烈变化的场景,误报率高到没人敢用。这份基于Python卷积神经网络的人脸识别驾驶员疲劳检测与预警系统资源,换了个思路——OpenCV的haarcascade先把人脸和眼睛定位出来,轻量CNN再对裁剪区域做疲劳/正常的分类,最后结合眨眼、打哈欠、点头三路信号和瞳孔、人脸朝向数据给出综合预警。整套源码在Pycharm + Python3.6环境下测试通过,附预训练模型、文档和可直接运行的exe,适合毕业设计、课程设计拿来复现,也适合在此基础上换数据继续拓展。
2. 检测逻辑与模型选型:三路疲劳信号怎么被CNN识别
先说结论:这个系统不是那种“喂一帧图像、吐一个疲劳分数”的黑匣子端到端方案。它实际上分两层——前一层是传统计算机视觉,负责找脸、定位眼睛、算几何特征;后一层是卷积神经网络,负责把裁剪出来的人脸区域映射成“疲劳/正常”的概率。这么拆的好处很直接:传统方法擅长快速定位,CNN擅长处理光照变化和个体差异,两者各干各擅长的活。
2.1 三路物理信号:眼睛开合度、嘴部张合与点头频率
疲劳在视觉上有三个最直观的表现:眨眼变频繁、打哈欠、点头。系统把这三路信号分开提取,最后再汇总到预警模块。眼睛部分依赖haarcascade_eye.xml给出的眼部包围盒,代码里实时计算眼睛框的高度与宽度之比,睁眼时这个比值在0.25到0.35之间,闭眼瞬间趋近于0。系统统计连续N帧内的比值变化,把一次快速闭合计为一次眨眼;如果一分钟内眨眼次数明显高于正常基线,通常正常是每分钟15到20次,就记为一个疲劳特征。
嘴巴的张合度用类似思路:检测到嘴巴开度突然变大且持续超过一定时长,就计为一次哈欠。点头则是看人脸框在画面中的垂直位移和面积变化——坐标往下掉、框面积变大,代表头部在低垂,连续多次低头就计入疲劳评分。瞳孔朝向和瞳孔收缩率在系统里作为辅助特征参与最终判定,不单独触发报警。这套“几何特征提取 + 行为统计 + 状态分类”的组合方式,比我最早接触的单一阈值方案稳健得多。单纯阈值方案把眼睛宽度当判断依据,换个眼型的人误报率直接翻倍;而这里的CNN作用是“兜底”,把几何特征拿不准的边界情况通通交给模型去判断。baojin.py作为报警模块,负责在综合评分越线时发出声音和视觉提示,它与detect_class.py通过一个触发条件衔接,后面第5章会细说这个衔接处最容易踩的坑。
2.2 为什么是mini_XCEPTION:轻量CNN与人脸状态分类的匹配
模型文件命名为_mini_XCEPTION.102-0.66.hdf5,按命名习惯看,102是训练轮数,0.66是验证准确率。XCEPTION是Chollet在2017年提出的网络结构,核心是深度可分离卷积(SeparableConv2D),把普通卷积拆成逐通道卷积加逐点卷积两步,计算量大幅下降。mini_XCEPTION是它的裁剪版,把通道数和层数都砍了一截,参数量控制在适合CPU实时推理的量级。回顾同类项目,VGG16参数量过亿,一个权重文件动辄几百MB,ResNet系列虽然精度高但推理延迟大,在笔记本CPU上做实时检测很吃力。mini_XCEPTION在这类“人脸状态分类”任务里是性价比很高的选择,因为疲劳/正常分类本身是粗粒度任务,不需要ImageNet级别的判别能力。
可能有同学会问,用它和直接用MobileNet比哪个好?我在类似场景里两种都试过,MobileNet的提速空间更大,但mini_XCEPTION的模型文件更小、迁移学习更容易收敛。疲劳检测本质上和人脸表情识别是同一类问题,输入尺寸通常统一到48x48或64x64的灰度图,模型结构差异带来的精度差距远小于数据质量和阈值标定带来的差距。如果你只是想先跑通流程,完全不需要重新训练,直接加载这个hdf5文件做推理就行;想提高精度时,再用自己的数据在cnn.py里做微调。
2.3 完整数据流:从人脸定位到报警触发
把整个链路摊开看,数据是这样流动的:摄像头或视频文件逐帧读取,先经过haarcascade_frontalface_default.xml检测人脸区域,拿到人脸框坐标后裁剪ROI,统一resize到模型输入尺寸,做灰度化和归一化,然后送入mini_XCEPTION前向推理,输出“正常/疲劳”的类别概率。与此同时,前面提到的眼睛开合度、嘴部张合、点头频率等几何信号也在并行计算,最终这两路信息在决策层汇合,超过阈值就触发报警。
这个项目里几个脚本的分工很清晰:extract_face.py负责从原始图片或视频里批量抽取人脸图,用于构建训练集;data_provider.py和load_and_process.py负责数据加载和预处理;split_train_test.py划分训练集和测试集;convert.py负责格式转换;detect_class.py是做实时检测的主入口;evaluate.py用于评估模型在测试集上的表现;tkinter_UI.py是图形界面。check.py从命名和调用关系看,是一个自检脚本,建议在正式跑摄像头之前先运行一次,确认hdf5模型能加载、摄像头能打开,省得在UI里一卡半天查不出原因。第一次拿到项目时,沿着这个顺序把脚本读一遍,基本能摸清整套系统的数据流,比直接看UI代码高效得多。
3. 环境搭建与完整运行:从解压到预警界面弹出的完整步骤
拿到压缩包后,解压出来是一个Python_FatigueDrivingDetection-master目录,里面系统说明.txt和运行说明.txt两个文档还是值得先花十分钟读一遍的。很多坑在文档里其实写了,但大多数人习惯跳过文档直接跑源码,结果在环境上卡一晚上。下面按我实际跑通的顺序,把环境、数据准备和两条运行路线都过一遍。
3.1 环境版本对齐:Python 3.6与TensorFlow/Keras版本组合
开发环境明确写的是Pycharm + Python3.6,搭配卷积神经网络。这里版本不是随便选的,Python 3.6对应TensorFlow 1.x时代,而hdf5模型文件是那个版本下保存的,换到新版环境容易出现反序列化错误。我推荐的版本组合如下表:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.6 | 项目基准版本,3.7+跑通概率也高,但没必要冒险 |
| TensorFlow | 1.15.0 | 与Keras 2.2.4搭配最稳,加载hdf5不出错 |
| Keras | 2.2.4 | 项目源码里的模型定义和加载API基于这个版本 |
| OpenCV | 4.5.5 | 安装时用opencv-python,包里包含haarcascade所需文件 |
| NumPy | 1.19.5 | 版本过高会与TensorFlow 1.x出现兼容警告 |
用conda建独立环境是最省心的做法,命令如下:
conda create -n fatigue python=3.6 -y conda activate fatigue pip install numpy==1.19.5 opencv-python==4.5.5.64 pip install tensorflow==1.15.0 keras==2.2.4 pillow注意这里tensorflow装的是CPU版,正常pip默认就是CPU版,不需要额外指定。很多新手在这里翻车是把tensorflow装成了tensorflow-gpu,结果没有对应CUDA环境,import直接报错。另外千万别单独执行pip install cv2,PyPI上根本没有叫cv2的包,正确做法是装opencv-python。装完后在Python交互环境里执行import cv2和import tensorflow,确认没有红字再继续。
3.2 数据准备两步走:split_train_test与convert
进入项目根目录后,依次执行数据划分和格式转换脚本。这里要注意,顺序不能反,先划分再转换,否则数据泄漏的风险会比较高。
cd Python_FatigueDrivingDetection-master python split_train_test.py python convert.pysplit_train_test.py做的事是把数据集按比例随机切分成训练集和测试集,常见的切分比例是8比2。这个脚本会读取按类别组织的图片目录,输出两个文件列表,供后续训练和评估使用。convert.py则是把图片统一处理成CNN模型能接受的形态,包括灰度化、缩放到48x48、像素值归一化到0到1范围,最终输出为npy数组文件。为什么要单独拆一个convet步骤而不是在训练时实时处理?因为重复的resize和归一化在每轮训练中都会执行,提前转换一次能省下大量训练时间。如果你打算换自己的数据集,把图片按normal、fatigue两个文件夹放好,重新跑这两步就行。
3.3 两条运行路线:detect_class命令行与tkinter_UI界面
项目提供了两套运行入口,一套是命令行实时检测,另一套是带按钮的图形界面。先试命令行版本,启动后摄像头画面里会画出人脸框,旁边显示当前状态和疲劳概率:
python detect_class.py按Q键退出。如果命令行版本能正常出画面,说明模型加载、摄像头调用、预测链路都是通的。接着再启动图形界面:
python tkinter_UI.py界面里通常会有开始检测、停止、退出这类按钮,以及实时状态显示区域。免Python环境的同学可以直接双击解压目录里的tkinter_UI.exe,但注意exe运行时读取的是当前目录下的模型文件,所以不要单独把exe拷走,除非连models文件夹一起带上。整个流程走通之后,你会看到某个疲劳动作触发后,baojin.py响起报警声,提示界面状态从“正常”切到“疲劳”。这里我建议一开始先把报警阈值调高一点,避免演示时频繁误报,具体怎么标定阈值放在最后一章讲。
4. 数据管道与核心代码解读:data_provider、模型加载与检测分类
跑通之后就该看核心代码了。这个项目的关键是三块:数据怎么供进去、模型怎么加载、预测结果怎么变成报警决策。
4.1 data_provider:自定义数据生成器怎么写
data_provider.py的作用是给训练脚本提供数据,核心是一个生成器,每次产出一个小批量的图片数组和对应的标签。我按项目的结构重写了一个等价实现,逻辑可以对照参考:
import os import cv2 import numpy as np from tensorflow.keras.utils import to_categorical class DataProvider: def __init__(self, data_root, image_size=(48, 48), batch_size=32, class_names=('normal', 'fatigue')): self.data_root = data_root self.image_size = image_size self.batch_size = batch_size self.class_names = class_names def build_index(self): paths, labels = [], [] for cls_id, cls in enumerate(self.class_names): cls_dir = os.path.join(self.data_root, cls) if not os.path.isdir(cls_dir): continue for fn in os.listdir(cls_dir): paths.append(os.path.join(cls_dir, fn)) labels.append(cls_id) return paths, np.array(labels) def generator(self, paths, labels, shuffle=True): while True: if shuffle: perm = np.random.permutation(len(paths)) paths_shuf = [paths[i] for i in perm] labels_shuf = labels[perm] else: paths_shuf, labels_shuf = paths, labels for i in range(0, len(paths_shuf), self.batch_size): batch_paths = paths_shuf[i:i + self.batch_size] batch_labels = labels_shuf[i:i + self.batch_size] images = [] for p in batch_paths: img = cv2.imread(p, cv2.IMREAD_GRAYSCALE) if img is None: continue img = cv2.resize(img, self.image_size) images.append(img.astype(np.float32) / 255.0) if not images: continue x = np.expand_dims(np.array(images), axis=-1) y = to_categorical(batch_labels[:len(images)], num_classes=len(self.class_names)) yield x, y这段代码里几个关键点说一下。build_index把normal和fatigue两个目录下所有图片路径和类别ID对应起来,类别ID就是目录的枚举顺序。generator返回的是一个无限循环的迭代器,这是Keras训练时fit_generator的要求,每个epoch自动取完一轮数据后重新洗牌。图片读取用IMREAD_GRAYSCALE,原因是模型输入是单通道灰度图。resize到48x48后立刻除以255做归一化,这一步如果漏了,模型输出的概率会整体漂移,训练时loss也降不下去。np.expand_dims在末尾加通道维度,灰度图本身是H×W,加了之后变成H×W×1,和模型输入对齐。最后to_categorical把整数标签变成one-hot向量,两个类别就是形如[1, 0]或[0, 1]的编码。这个生成器对应到训练脚本里,通常会配合steps_per_epoch使用,值等于样本总数除以batch_size。
4.2 加载预训练模型:load_model与hdf5文件对接
模型文件在models目录下,是一个hdf5格式文件。加载方式很简单,但如果中间步骤没对齐就会报错,这是最常见的坑之一。
from tensorflow.keras.models import load_model model_path = 'models/_mini_XCEPTION.102-0.66.hdf5' model = load_model(model_path) model.summary() # 推理单帧(与detect_class.py内部逻辑一致) import cv2 import numpy as np frame = cv2.imread('sample.jpg') gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) face = cv2.resize(gray, (48, 48)) x = np.expand_dims(np.expand_dims(face, axis=-1), axis=0).astype(np.float32) / 255.0 prob = model.predict(x)[0] print('class probs:', prob)load_model读取hdf5文件时会同时恢复网络结构和权重,所以推理场景下不需要先手写cnn.py里的结构定义。但如果你打算在现有权重基础上继续训练,就要用cnn.py先创建模型结构,再调用load_weights加载权重,两者不要混用。predict返回的是一个二维数组,第一维是batch,第二维是类别概率;这里只有一个样本,所以取[0]拿第一个样本的输出。注意输入x的维度是(1, 48, 48, 1),四维,少了任何一个维度模型都会直接抛错。预处理必须和训练时完全一致:灰度、48x48、除以255。
4.3 detect_class:预测概率如何转成疲劳判定
模型输出的概率本身不能直接拿来报警,因为摄像头的单帧画面受抖动影响很大,单帧误判率比想象中高。detect_class.py里用的是连续帧计数策略,我把它简化成核心逻辑:
fatigue_frame_count = 0 ALARM_THRESHOLD = 30 # 连续疲劳帧数达到该值才触发报警 while True: ret, frame = cap.read() # haarcascade定位人脸并裁剪,得到roi,过程略 pred = model.predict(roi)[0] fatigue_prob = pred[1] if len(pred) > 1 else pred[0] if fatigue_prob > 0.5: fatigue_frame_count += 1 if fatigue_frame_count >= ALARM_THRESHOLD: baojing.trigger() # 连接baojin.py报警模块 else: fatigue_frame_count = 0这里ALARM_THRESHOLD是工程上最有调参空间的一个数值。设得太大,人都点头半天了才报警;设得太小,路面颠簸一下就可能误报。常见做法是先设30,跑一段时间真实验证后,再根据误报率和漏报率的平衡去调整。疲劳概率阈值0.5也不是硬性规定,正常状态和疲劳状态的预测分数分布如果存在明显分隔,可以打印出来看一眼,这个后面第6章专门讲标定方法。
5. 避坑指南:路径、版本与摄像头三类常见问题排查
这个项目我在不同机器上反复跑过,翻车点高度集中在路径、版本、摄像头这三类。下面五条踩坑记录都是我实际遇到过的,每一条都按现象、原因、解决三步写清楚。
5.1 读图返回None:中文路径与OpenCV的编码限制
现象:训练脚本跑起来后日志一直提示加载不到图片,cv2.imread返回None,但打开文件管理器看,图片明明就在那儿。
原因:OpenCV的imread底层用的是fopen读文件,不处理Windows下中文路径的Unicode编码。只要项目路径里出现“毕业设计”“新建文件夹”这类中文,就会静默失败,返回None而不是抛异常,所以排查起来特别隐蔽。
解决:最省事的是把整个项目移到纯英文路径下。不想移动项目的,用二进制读取再解码的方案:
import numpy as np import cv2 def cv_imread(path): data = np.fromfile(path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_GRAYSCALE)把源码里所有cv2.imread替换成这个函数就行。我个人习惯在替换后加一层断言,读出来是None就直接抛异常,避免图片缺失导致模型在残缺数据上静默跑偏,那种问题出得很晚、查起来成本极高。
5.2 hdf5模型加载报错:'str' object has no attribute 'decode'
现象:执行load_model,在反序列化阶段抛AttributeError,错误信息指向decode属性。
原因:这个hdf5文件是TensorFlow 1.x + Keras 2.x年代保存的,权重里包含以bytes类型存储的字符串。TensorFlow 2.x把序列化格式换了,默认反序列化器遇到旧格式就报错,而这种错误信息对新手很不友好,完全看不出来是版本问题。
解决:最稳的是按前面版本表建一个TF1.15 + Keras2.2.4的环境,五分钟搞定,不要纠结。如果坚持用TF2,可以改tf.keras方式加载并传入custom_objects,或者用h5py手动把权重读出来后重新赋值,但后者工作量大且容易出错。我的经验是,这种毕业设计项目不值得在版本兼容性上耗时间,直接降级环境最快。跑之前顺手打印一下tensorflow.version,确认环境没串。
5.3 摄像头黑屏:VideoCapture索引和权限
现象:tkinter_UI点开始检测,画面黑屏,程序不报错,但frame一直是空;有时直接卡死无响应。
原因:cv2.VideoCapture(0)里的“0”在设备多的时候不一定对应内置摄像头。另外Linux环境下摄像头设备节点权限不足,也会导致黑屏。
解决:先写三行代码确认索引和连接状态:
import cv2 for idx in range(3): cap = cv2.VideoCapture(idx) print(idx, cap.isOpened()) ret, frame = cap.read() print(ret, frame.shape if ret else None) cap.release()哪个索引能读出画面就改代码里对应的数字。Linux权限问题用sudo usermod -a -G video $USER解决,重启后生效。还有一种是内置摄像头被其他程序抢占,比如开了OBS或者会议软件没退出,把占用程序关了再跑UI。
5.4 报警不触发:阈值与连续帧逻辑没走到
现象:检测画面一切正常,人脸框也画出来了,但状态一直显示normal,怎么疲劳都触发不了报警。
原因:一类是阈值设太高,模型预测概率普遍落在0.4到0.6区间,0.5的硬阈值把真实疲劳帧全部压掉了。另一类是detect_class里判断疲劳的阈值和baojin.py报警模块的触发条件各调各的,中间没衔接上,疲劳计数到了但报警函数没被调用。还有一类隐蔽原因,模型输入尺寸和训练时不一致,推理概率整体被拉低。
解决:在detect_class.py里临时加一行print,把所有帧的预测概率打出来,跑10分钟正常状态、10分钟模拟疲劳状态,看两组分布。如果正常段还有不少帧超过0.5,说明分类边界不够清晰,需要回头检查预处理是否一致,而不是急着调阈值。确认预处理没问题后,把连续帧数从30降到20试几轮,找到漏报和误报的平衡点。这个排查流程是通用的,换上任何阈值模型都适用。
5.5 训练不收敛:归一化与类别均衡
现象:用自己的数据重训,loss在0.7附近震荡,准确率始终在50%左右,相当于模型什么都没学到。
原因:最常见的是数据没归一化,直接把0到255的像素值喂进网络,梯度更新不稳定。其次是normal和fatigue两个类别的图片数量差距悬殊,模型偏向多数类,输出始终是多数类。再有就是学习率用了默认的0.001以上,mini_XCEPTION这种小网络对学习率很敏感。
解决:确认训练脚本里的预处理器和data_provider保持一致,灰度、48x48、除以255.0缺一不可。统计两个类别的样本数,数量少的做上采样,比如复制粘贴几轮,或者做随机旋转、水平翻转的数据增强。最后把学习率调到1e-4这个量级重新训练。我在重训这种小模型时还会加早停,patience设为10到15轮,避免过拟合还把最优权重覆盖掉。
6. 进阶调优:用录制视频标定阈值与换数据重训的实用技巧
默认的0.5阈值只是让系统“能跑”,要让它在真实场景下少误报,还得自己做标定。我的做法是先录两段视频:normal.mp4拍正常驾驶状态,fatigue.mp4模拟连续打哈欠、频繁眨眼和点头。然后用模型把所有帧的概率都打出来,看两组分布的重叠情况。
import cv2 import numpy as np from tensorflow.keras.models import load_model model = load_model('models/_mini_XCEPTION.102-0.66.hdf5') def collect_probs(video_path): cap = cv2.VideoCapture(video_path) probs = [] while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 这里还需要对人脸做裁剪和预处理,省略,与detect_class保持一致 face = cv2.resize(gray, (48, 48)) x = np.expand_dims(np.expand_dims(face, axis=-1), axis=0) x = x.astype(np.float32) / 255.0 probs.append(model.predict(x)[0][1]) return np.array(probs) normal_p = collect_probs('normal.mp4') fatigue_p = collect_probs('fatigue.mp4') print('normal : mean %.3f max %.3f' % (normal_p.mean(), normal_p.max())) print('fatigue: mean %.3f min %.3f' % (fatigue_p.mean(), fatigue_p.min()))如果fatigue_p的最小值远大于normal_p的最大值,说明模型区分度很好,阈值取两者中点的整数就行;如果两组分布有明显重叠,说明预处理不一致,或者这两段视频本身没拍出明显的疲劳特征。这个标定脚本每次跑完都留一份分布输出到文本文件里,方便换数据后对比。标定完阈值,再回头改detect_class里的判定代码,重新跑一遍验证。
如果满足于现有模型,用预训练hdf5就够了。想真正拥有自己的检测模型,就按3.2节的方法组织normal和fatigue两个文件夹,跑split_train_test、convert,再在cnn.py里建结构、加载预训练权重、把全连接层换掉重新微调。训练完后用evaluate.py评估精度,选验证集上表现最好的权重导出。推理速度方面,如果打算部署到嵌入式设备,可以考虑把hdf5转成ONNX,再用OpenVINO加速,CPU推理速度能再提几倍;不过毕业设计在笔记本上演示的话,原地跑就挺流畅了。
前年我用默认阈值直接跑了一段夜间模拟驾驶视频,误报率接近三成,当时还以为是模型不行,后来录了十分钟正常、十分钟疲劳的真实素材做标定,阈值一改误报立即降下来。从那以后我每次拿到这种带状态阈值的项目,第一件事就是先把阈值用自己录的视频标一遍,再谈上线或者演示。这个习惯救了我好几次,希望帮到你。
本文还有配套的精品资源,点击获取