简介:这是一套基于Python与卷积神经网络的驾驶员疲劳检测与预警系统完整源码,面向计算机、人工智能等相关专业准备毕业设计或大作业的学生,也适合希望实战图像识别项目的开发者。项目通过面部关键点与眼睛状态识别判断疲劳程度,内置Tkinter图形界面与可直接运行的exe程序,配套CNN模型文件,整体难度适中且经评审达到95分以上。资源共19个文件,压缩包78.33MB,主要包含11个Python源码、hdf5模型文件、人脸与眼睛检测xml、依赖说明txt及可视化界面所需的exe等;Python脚本覆盖数据预处理、模型训练、检测分类与UI交互等环节,目录结构清晰。已有789人学习/下载,下载后可直接对照运行,快速复现检测流程,便于在此基础上改进算法或调整界面,是高效完成毕业设计的优质参考资料。
1. 把卷积神经网络搬进驾驶舱:这套疲劳检测源码到底解决什么问题
凌晨两点的货运司机、连续赶路的网约车司机、盯着监控屏的调度员——疲劳驾驶是事故率最高的场景之一,而绝大多数车队目前仍然靠人工盯防。传统的疲劳检测方案要么依赖昂贵的红外眼动仪,要么靠方向盘传感器间接推断,都没能大规模落地。这几年卷积神经网络成熟以后,用普通USB摄像头实时捕捉人脸、识别眼睛开合与打哈欠状态、再算出疲劳评分,已经成为成本最低也最容易复现的技术路线。这套基于 Python + CNN 的驾驶员疲劳检测与预警系统源码,做的正是这样一件事:摄像头画面进去,疲劳等级和预警信号出来。它的核心价值在于把“采集人脸 → 训练模型 → 实时推理 → 触发报警”整条链路做成了一套可以跑通的毕业设计级代码,适合正在做相关课题的学生、想快速搭原型验证的算法工程师,以及想给车队做低成本预警方案的技术人员。
2. 整体架构与数据准备:先搞定人脸检测和样本标注,再谈 CNN 训练
2.1 疲劳检测选型:为什么是 CNN 而不是传统图像处理
早期疲劳检测常见做法是用 OpenCV 的人脸关键点检测(比如 dlib 的 68 点模型)直接算眼睛纵横比(EAR)和嘴巴纵横比(MAR),阈值一卡就判断闭眼或打哈欠。这个方案在实验室环境里表现尚可,但一遇到侧脸、遮挡、暗光、戴眼镜,关键点检测就开始飘,误报率直线上升。
CNN 方案的不同在于:它不再手工设计“眼睛长宽比”这类特征,而是让卷积核自己去学“什么样子算闭眼”“什么动作算打哈欠”。以本源码使用的轻量 CNN 分类模型为例,输入是一张对齐后的人脸 ROI,输出是“正常 / 闭眼 / 打哈欠 / 分心”之类的类别概率。这样做的好处是鲁棒性更强——训练数据里覆盖了足够的戴眼镜、暗光、低头样本,模型就能在真实摄像头画面上稳定工作。代价是需要先准备一批标注好的样本,这也是很多人拿到源码后第一个卡住的地方。
2.2 数据集准备与标注:人脸 ROI 提取与三类样本组织
源码默认的训练数据组织方式是典型的图像分类目录结构:
dataset/ ├── train/ │ ├── normal/ # 正常驾驶状态人脸图 │ ├── eye_closed/ # 闭眼状态人脸图 │ └── yawn/ # 打哈欠状态人脸图 └── val/ ├── normal/ ├── eye_closed/ └── yawn/我一般会建议按 8:2 切分训练集和验证集,每类样本量尽量不少于 1500 张。如果原始视频素材不够,不要硬撑,后面的数据增强能帮你把样本量翻几倍。
预处理流水线的关键代码在源码的preprocess.py里。它会先从视频帧中用 OpenCV 的级联分类器或 MTCNN 检测人脸,再把检测框裁出来缩放到统一尺寸(常见做法是 64×64 或 128×128,本源码默认 64×64,训练速度快但精度稍低,想提精度可以改成 96×96 重训)。
import cv2 import os import numpy as np def extract_face_roi(video_path, output_dir, target_size=(64, 64)): """ 从视频中逐帧检测人脸,裁出人脸区域并统一尺寸 video_path: 原始视频路径 output_dir: 保存人脸图的目录 target_size: 输出图片尺寸,默认64x64 """ face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' ) cap = cv2.VideoCapture(video_path) count = 0 while True: ret, frame = cap.read() if not ret: break # 转为灰度图,降低光照影响,也减小计算量 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 检测人脸,scaleFactor越小检测越细但越慢 faces = face_cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48) ) for (x, y, w, h) in faces: # 稍微往外扩一点边缘,避免裁掉额头和下巴 margin = int(0.1 * w) x0 = max(0, x - margin) y0 = max(0, y - margin) x1 = min(frame.shape[1], x + w + margin) y1 = min(frame.shape[0], y + h + margin) roi = frame[y0:y1, x0:x1] roi = cv2.resize(roi, target_size, interpolation=cv2.INTER_AREA) cv2.imwrite( os.path.join(output_dir, f'face_{count:05d}.jpg'), roi ) count += 1 cap.release() print(f'提取完成,共保存 {count} 张人脸图')这段代码的逻辑很直白:逐帧读视频,灰度化后人脸检测,每个检测框外扩 10% 裁图,缩放后落盘。scaleFactor=1.1每轮按 10% 缩小搜索窗口,检测更细但慢;minNeighbors=5表示至少 5 个邻域窗口确认才算人脸,减少误检。INTER_AREA在缩小时效果好,能避免明显锯齿。
2.3 数据增强策略:把 1500 张样本用成 9000 张
CNN 训练最怕样本量不足,尤其是“闭眼”和“打哈欠”这两类状态,录制成本高、样本天然稀缺。源码里的augment.py实现了几个低成本高收益的增强操作:水平翻转、小角度旋转、亮度抖动、高斯噪声。
from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen = ImageDataGenerator( rotation_range=10, # 随机旋转 ±10 度 width_shift_range=0.05, # 水平平移 5% height_shift_range=0.05, # 垂直平移 5% brightness_range=(0.8, 1.2), # 亮度随机变化 80%~120% horizontal_flip=True, # 水平翻转 fill_mode='nearest' # 平移后空白的填充方式 ) # 配合 flow_from_directory 读取目录结构数据集 train_generator = datagen.flow_from_directory( 'dataset/train', target_size=(64, 64), batch_size=32, class_mode='categorical' )一个容易忽略的细节是fill_mode='nearest'——旋转和平移后图像边缘会出现空白区域,nearest模式用边缘像素填充,不会产生突兀的黑色边框干扰模型。brightness_range的范围控制在 0.8~1.2 比较稳妥,调太大模型会把“明暗变化”误学成关键特征,实拍暗光反而影响真实表现。
3. CNN 模型构建与训练:从 LeNet 到轻量化网络的实战取舍
3.1 模型结构设计:为什么不用大网络跑这种小任务
疲劳检测本质上是一个图像分类任务,输入尺寸小、类别少(通常 3~4 类),不需要 ResNet 这种上百层的深度网络。源码里采用的是类似 LeNet 的轻量 CNN 结构:两层卷积 + 两层池化 + 两层全连接。得益于卷积核的权值共享特性,参数量只有几万,在纯 CPU 上也能跑到每秒 20 帧以上推理速度,这对实时检测很关键。
网络结构的关键参数如下:
| 层 | 输出尺寸 | 卷积核/参数 | 激活函数 |
|---|---|---|---|
| Conv1 | 64×64×32 | 3×3×32,步长1 | ReLU |
| MaxPool1 | 32×32×32 | 2×2 池化 | - |
| Conv2 | 32×32×64 | 3×3×64,步长1 | ReLU |
| MaxPool2 | 16×16×64 | 2×2 池化 | - |
| Flatten | 16384 | - | - |
| Dense1 | 128 | 16384×128 | ReLU + Dropout |
| Dense2 | 3 | 128×3 | Softmax |
这个结构在分类精度和推理速度之间取得了不错的平衡。第一层 3×3 卷积核负责提取边缘、纹理等低级特征,第二层 64 个卷积核提取眼睛开合度、嘴巴张合度这类更高阶的模式。Flatten 之后接 128 维全连接加 Dropout,最后 Softmax 输出三个类别的概率。
3.2 训练脚本与核心超参数:直接照着跑也行,改两个数更稳
源码附带的训练脚本train.py核心逻辑如下:
import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout from tensorflow.keras.optimizers import Adam def build_cnn(input_shape=(64, 64, 3), num_classes=3): model = Sequential([ Conv2D(32, (3, 3), activation='relu', input_shape=input_shape), MaxPooling2D(pool_size=(2, 2)), Conv2D(64, (3, 3), activation='relu'), MaxPooling2D(pool_size=(2, 2)), Flatten(), Dropout(0.5), Dense(128, activation='relu'), Dropout(0.3), Dense(num_classes, activation='softmax') ]) return model model = build_cnn() model.compile( optimizer=Adam(learning_rate=0.001), loss='categorical_crossentropy', metrics=['accuracy'] ) history = model.fit( train_generator, steps_per_epoch=200, # 每个 epoch 迭代 200 个 batch epochs=30, # 训练轮数 validation_data=val_generator, validation_steps=50 ) model.save('driver_fatigue_cnn.h5')几个容易踩坑的参数:learning_rate=0.001是 Adam 的常用默认值,但如果 loss 震荡不下降,可以先降到 0.0003 试一下;Dropout(0.5)放在 Flatten 之后,是防止全连接层过拟合的经典位置;steps_per_epoch=200配合 batch_size=32,相当于每个 epoch 看 6400 张增强后样本,如果自己的数据集不够,可以把它改小到 100,避免每个 epoch 反复重复读图造成过拟合。
3.3 训练监控与过拟合控制:准确率高不等于能用
训练阶段最常见的翻车是训练准确率 99%、验证准确率也到了 97%,但一到真实摄像头画面就各种误判。原因通常是训练数据太“干净”——全部是正脸、光线均匀、无遮挡。解决办法是上面提到的增强策略里加入随机遮挡(Random Erasing),或者直接混入一些真实摄像头采集的难例。
我这里建议每次训练后都做一次坏样本收集:把测试视频喂给模型,把所有预测置信度低于 0.85 的帧保存下来,看一眼是模型错了还是标注本身就有问题。这一步虽然费时间,但却是模型从“能跑通”到“能上线”的必经之路。源码的predict.py里留了置信度输出接口,可以改造成自动保存低置信度帧的功能。
4. 实时检测与预警实现:摄像头取流、EAR 辅助判断与防误报
4.1 实时视频流处理:为什么先检测人脸再分类
源码的实时检测部分detect.py是一个独立的线程循环:从摄像头逐帧读取画面,每隔一帧做一次人脸检测,检测到人脸后裁出 ROI 送进 CNN 分类器。跳帧这个细节很关键——完整跑 30fps 的人脸检测加分类,CPU 占用会到 80% 以上,而隔一帧处理一次,每秒处理 15 帧,卡顿感基本无感知,CPU 占用能降一半。
import cv2 import numpy as np from tensorflow.keras.models import load_model class FatigueDetector: def __init__(self, model_path='driver_fatigue_cnn.h5'): self.model = load_model(model_path) self.face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' ) # 类别顺序要与训练时的 class_indices 保持一致 self.class_names = ['normal', 'eye_closed', 'yawn'] self.frame_skip = 1 # 每隔1帧处理一次 def predict_frame(self, frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = self.face_cascade.detectMultiScale(gray, 1.1, 5) results = [] for (x, y, w, h) in faces: roi = frame[y:y+h, x:x+w] roi = cv2.resize(roi, (64, 64)) roi = roi.astype('float32') / 255.0 roi = np.expand_dims(roi, axis=0) pred = self.model.predict(roi, verbose=0)[0] cls_idx = int(np.argmax(pred)) confidence = float(pred[cls_idx]) results.append({ 'box': (x, y, w, h), 'label': self.class_names[cls_idx], 'confidence': confidence }) return results这里有个新手很容易翻车的点:模型输入需要归一化到 [0,1],但很多人在预处理阶段把像素值除以了 255,训练时做了,预测时忘了,结果模型输出全是垃圾概率。另一个坑是类别顺序——Keras 的flow_from_directory按目录名排序,训练时class_indices是{'eye_closed': 0, 'normal': 1, 'yawn': 2},预测脚本里如果写死['normal', 'eye_closed', 'yawn'],输出全错。源码用注释明确了这一点,实际跑的时候最好打印model.class_indices对一下。
4.2 疲劳评分机制:单帧分类不够,时间窗口才是关键
单帧判断闭眼没有意义——正常人本来就会眨眼,眨眼持续 0.1~0.3 秒属于生理现象。源码的预警逻辑采用滑动窗口统计:连续 N 秒内,闭眼帧占比超过阈值就触发预警。
from collections import deque import time class FatigueScorer: def __init__(self, window_size=30, closed_threshold=0.4): """ window_size: 统计窗口帧数 closed_threshold: 闭眼帧占比阈值 """ self.window = deque(maxlen=window_size) self.closed_threshold = closed_threshold def add_frame(self, label): # 入队:1 表示闭眼或打哈欠,0 表示正常 self.window.append(1 if label in ('eye_closed', 'yawn') else 0) def is_fatigue(self): if len(self.window) < self.window.maxlen: return False # 窗口未填满不判断 ratio = sum(self.window) / len(self.window) return ratio > self.closed_thresholdwindow_size=30按 15fps 处理速度算,大约是 2 秒的滑动窗口。closed_threshold=0.4意味着 2 秒内超过 40% 的帧是闭眼/哈欠状态,才会触发疲劳预警。这个阈值一开始可以设 0.5,误报多了再调低,实车测试时我一般从 0.4 起步。
4.3 预警触发策略:声音报警与界面提示的最小实现
源码的报警模块用pygame.mixer播放一段 WAV 音频,同时用 OpenCV 在实时画面上画红色警告框。之所以没用winsound,是为了跨平台兼容——pygame.mixer在 Windows 和 Linux 上都能正常播放 WAV 文件。
import pygame class Alarm: def __init__(self, sound_path='alarm.wav'): pygame.mixer.init() self.sound = pygame.mixer.Sound(sound_path) self.last_trigger_time = 0 def trigger(self, cooldown_seconds=5): """ 触发报警,cooldown_seconds 防止同一状态反复报警 """ now = time.time() if now - self.last_trigger_time > cooldown_seconds: self.sound.play() self.last_trigger_time = now防重复报警这个逻辑在实际场景里非常重要。如果没有冷却时间,司机打了个哈欠,系统在 2 秒窗口内持续判定疲劳,报警声会连续响 5~6 次,烦到司机直接把系统关了。加了 5 秒冷却,情况会好很多。
5. 避坑指南:训练到部署最常见的五个问题,从环境配置到模型加载
5.1 环境安装翻车:TensorFlow 装完导入报错
现象:按 README 执行pip install tensorflow后,Python 里import tensorflow直接报 DLL 加载失败或提示找不到指定模块。
原因:绝大多数情况是 Python 版本太新(3.11+)或 32 位环境,TensorFlow 官方预编译的 wheel 只覆盖有限版本组合。还有一部分是 GPU 版装了没有对应 CUDA 的机器上。
解决:先无脑降级到 Python 3.8~3.10,然后执行pip install tensorflow-cpu==2.10.*(Windows)或tensorflow==2.10.*(Linux)。如果是 Apple Silicon Mac,用tensorflow-metal插件。装完后跑一句print(tf.__version__)确认导入成功再继续。
提示:强烈建议为这个项目单独建虚拟环境,不要在系统 Python 里混装。
python -m venv fatigue_env然后激活,干净环境能省掉一半玄学报错。
5.2 摄像头读取失败:笔记本内置摄像头也能翻车
现象:cv2.VideoCapture(0)返回 True,但read()拿到的画面是黑屏或者直接报错。
原因:摄像头被其他软件占用(微信、OBS、浏览器视频会议),OpenCV 打开设备时拿不到帧数据;部分联想笔记本的摄像头需要先物理开关打开。
解决:先关掉所有占用摄像头的程序,再用cap.isOpened()判断。如果编号 0 不行,试着改成cv2.VideoCapture(1)或cv2.VideoCapture(-1)让 OpenCV 自动枚举可用设备。还是不行,就在系统相机应用里确认摄像头本身能出图。
5.3 模型训练 loss 不下降:学习率和数据归一化的双重坑
现象:训练了 10 多个 epoch,loss 一直在 1.0 左右晃,准确率没有上升迹象。
原因:最常见的是输入没做归一化,像素值 0~255 直接喂进网络,激活函数输出饱和;其次是学习率设置过高,loss 在震荡中无法收敛。
解决:检查数据流里是否执行了/255.0归一化,确认后用更小的学习率重新训练。我一般会把 Adam 的learning_rate从 0.001 降到 0.0003,同时把 batch_size 从 32 提到 64,稳定性会好很多。
5.4 预测结果和训练时类别对不上:Keras 类别索引的暗坑
现象:训练准确率 97%,但预测时“闭眼”图片被分成“哈欠”,“正常”图片被分成“闭眼”,而且概率还挺高。
原因:验证集用的是同一个ImageDataGenerator,类别索引一致;但预测脚本里如果手写了class_names列表,顺序没有和flow_from_directory的class_indices对齐,就会张冠李戴。
解决:预测前打印模型的class_indices属性,把真实索引打印出来,然后以此为准写映射表。不要靠猜测目录顺序。
# 加载后先确认类别映射,防止搞错顺序 print(detector.model.class_indices) # 输出示例: {'eye_closed': 0, 'normal': 1, 'yawn': 2}5.5 画面上画框卡顿:每一帧都做完整 CNN 推理的代价
现象:画面像幻灯片一样一卡一卡的,明显掉帧严重。
原因:实时循环里每一帧都跑人脸检测 + 模型推理。人脸检测本身比较耗时,CNN 虽然是轻量网络,但逐帧跑在 CPU 上依然吃紧。
解决:把frame_skip从 1 改成 2 或 3,即每 3 帧才做一次完整检测。另外可以缩小检测窗口,先用小分辨率图识别人脸,定位后再裁剪原图区域送分类器。实测这样处理,15fps 的画面流畅度基本够用。
6. 进阶:模型量化压缩与疲劳指数调优,让这套系统真正跑进量产门槛
如果你已经跑通了上面的完整流程,下一步值得做的事情是模型量化和疲劳评分逻辑的细调。H5 格式的 Keras 模型体积通常在几百 KB 到 1MB 之间,对于树莓派或 Jetson Nano 这类端侧设备来说,推理延迟还是偏高。用 TensorFlow Lite 做量化压缩,模型体积能缩小到原来的四分之一,推理速度提升 2~3 倍。
import tensorflow as tf # 加载训练好的 Keras 模型 model = tf.keras.models.load_model('driver_fatigue_cnn.h5') # 转换为 TFLite 格式,默认 float32 converter = tf.lite.TFLiteConverter.from_keras_model(model) tflite_model = converter.convert() # 保存量化前后的模型,方便对比 with open('driver_fatigue_cnn.tflite', 'wb') as f: f.write(tflite_model) # 量化版:int8 动态范围量化,体积更小、速度更快 converter_quant = tf.lite.TFLiteConverter.from_keras_model(model) converter_quant.optimizations = [tf.lite.Optimize.DEFAULT] tflite_quant_model = converter_quant.convert() with open('driver_fatigue_cnn_quant.tflite', 'wb') as f: f.write(tflite_quant_model)量化后的模型需要在推理脚本中换用tf.lite.Interpreter加载,输入输出张量的访问方式也随之变化。第一版量化模型跑起来,精度通常会有一两个百分点的波动,这是正常现象;如果出现明显退化,优先调整训练阶段的数据增强强度,而不是回退到 float32 版本。
疲劳评分逻辑的调优,核心在于 PERCLOS(单位时间内眼睛闭合时间占比)标准的落地。学术研究常用 P80 标准(闭眼超过 80% 才报警),但实际驾驶场景里 2 秒窗口 40% 闭眼已经具备预警价值。你可以把多个窗口长度做成可配置参数,实测时以司机真实反馈为准调整——阈值太紧司机嫌吵,太松起不到预警作用,没有万能数值。
从那以后,我每接触一套检测类源码,都会先跑通最小闭环,再去做数据集扩充和参数量化,而不是一上来就追求最高精度。毕竟疲劳检测这种系统的核心指标不是“准”,而是“在噪声环境里稳定可信”。源码给的是一个经过验证的起点,真正能落地的部分,恰恰是你根据自己的驾驶场景一点点磨出来的。希望这篇拆解能帮你在复现时少走几步弯路。
本文还有配套的精品资源,点击获取