用CNN实现疲劳驾驶检测:从数据准备到模型部署全流程解析
2026/9/14 1:26:29 网站建设 项目流程

简介:面向计算机专业毕业设计项目,这套基于卷积神经网络CNN的疲劳驾驶识别检测系统完整资源,以驾驶员面部/眼部状态为识别目标,覆盖从数据准备、模型训练到实时摄像头与视频检测的完整流程,同时提供预训练权重和数据集,适合做毕设、课程设计或深度学习实战练习。ZIP压缩包共37个文件,整体约500MB,以Python源码为核心,包含16个py脚本、模型权重文件、示例图片、数据集压缩包和训练日志等,能够支持训练、评估、推理与界面化检测等多种用途。目前已有128人学习浏览。该资源围绕人脸识别驾驶员疲劳检测展开,涉及CNN网络搭建、SSD/VGG结构使用、数据增强、损失函数设计和模型测试等关键点,同时附带的实验日志与说明文档有助于还原训练过程。使用者可以直接加载多个预训练权重快速复现实验,也可借助摄像头检测、视频检测、训练与测试等模块梳理完整代码逻辑,便于在此基础上调整网络结构并进行改进与扩展。

1. 疲劳驾驶检测为什么先选CNN而不是传统图像算法

疲劳驾驶识别这个毕设题目的第一道坎,不是“怎么用CNN”,而是“凭什么用CNN”。传统图像处理做法也很成熟:用dlib或OpenCV标出眼睛轮廓,计算眼睛宽高比EAR,低于阈值就判定闭眼,连续闭眼几秒就报警。这套方案简单、速度快,但真实驾驶舱里坑非常多——逆光时眼白反光、戴墨镜时瞳孔特征丢失、低头看手机时鼻尖遮挡眼睑,这些场景下几何规则很容易误判或者漏判。CNN的方案是把检测和分类都交给数据驱动,输入一张裁剪好的人脸区域,网络自己去学“睁眼和闭眼在像素上的差异”,再叠加眨眼频率、打哈欠、头部姿态这些时序特征做疲劳判定。

这个系统的完整构成一般分四块:人脸检测定位、眼睛和嘴巴区域提取、CNN疲劳状态分类、滑动窗口统计疲劳指标。毕设加分项则在后两块,也就是你如何设计疲劳指标、如何组织数据集、如何把准确率做实而不是只跑通一个demo。适合的人群是有Python和基础深度学习知识、想做计算机视觉方向毕业设计的学生,也适合想快速落地一个疲劳检测原型的工程师。下面按我平时做这类项目的习惯,从前置特征定义、数据组织、模型选型、训练脚本、部署验证一步步展开。

2. 先定疲劳标准再做模型:PERCLOS、眨眼频率和数据集的目录组织

2.1 疲劳不是“闭眼”一个标签,而是多路信号的联合判定

绝大多数初学者犯的第一个错,是把问题简化为“二分类:闭眼/睁眼”。实际驾驶场景里,单帧闭眼可能只是正常眨眼,真正有意义的疲劳指标是统计意义上的:

  • PERCLOS:单位时间内闭眼帧数占总帧数的比例,也是交通领域引用最多的疲劳指标。一般取P80标准,即眼睑遮挡瞳孔面积超过80%记为闭眼。
  • 眨眼频率:正常成年人每分钟眨眼15到20次,疲劳时眨眼频率会下降,但单次闭眼持续时间变长。
  • 打哈欠频率:用嘴巴长宽比MAR判断张口程度,连续多次大幅度张口计为一次哈欠。
  • 点头幅度和频率:疲劳时头部会无意识地快速下垂。

所以我做疲劳检测项目时,第一步不是训练分类器,而是先写一个疲劳特征统计模块。它会接收每一帧的关键点数据,维护一个长度为T的滑动窗口,窗口内计算闭眼帧占比、哈欠次数、点头次数。这个模块与CNN是解耦的,CNN只负责输出眼睛和嘴巴的状态概率,疲劳判定逻辑在窗口层完成。这样做的优势是:CNN的误判是单帧的,滑动窗口统计能把它平滑掉,模型和判定逻辑可以分别调优。

2.1.1 关键点提取和脸部区域裁剪的常见做法

CNN分类器需要输入固定尺寸的图像块。常见做法是先用人脸检测器(OpenCV的Haar Cascade、RetinaFace、或YOLOv8的人脸模型)框出人脸,再按人脸关键点裁剪出左眼、右眼、嘴巴三个区域。不建议直接把整张人脸扔进分类器,因为那样模型需要同时学“人脸在哪”和“眼部状态”,数据量不够时很容易过拟合。裁剪思路如下:

  • 检测到人脸框后,按比例取上部1/3作为眼睛区域(双眼一起),下部1/3作为嘴巴区域;
  • 或者用68点关键点,取左眼6个点、右眼6个点、嘴巴20个点的外包矩形,向外扩20像素;
  • 两个区域分别缩放到64x64或48x48输入CNN。

2.2 数据集怎么组织:类别目录、样本平衡和标注格式

有了裁剪思路,下一步是准备数据集。疲劳驾驶识别常见的数据集路径有三种:第一种是使用公开的疲劳检测数据集如NTHU-DDD和YawDD;第二种是从视频中自动切帧并人工筛选;第三种是自己拍摄。毕设层面,我建议“公开数据集为主,自采数据做补充验证”。

公开数据集已经是分类好的,用起来最省事。自采数据要注意的坑是:不要用手机对着屏幕翻拍,要在驾驶舱视角拍摄,光照覆盖白天逆光、夜晚仪表盘光、隧道内灯光等场景。目录组织直接决定了后续PyTorch的ImageFolder能不能直接用,所以我一般这样组织:

dataset/ ├── train/ │ ├── open_eyes/ # 睁眼样本 │ │ ├── 001.jpg │ │ └── ... │ ├── closed_eyes/ # 闭眼样本 │ ├── yawning/ # 打哈欠样本 │ └── normal_mouth/ # 正常嘴部样本 ├── val/ │ ├── open_eyes/ │ ├── closed_eyes/ │ ├── yawning/ │ └── normal_mouth/ └── test/

注意这里我把眼睛和嘴巴分成两组四类来训练两个模型,而不是训练一个“疲劳/清醒”二分类器。原因是:打哈欠时眼睛可能是睁开的,闭眼时嘴巴可能是闭合的,混在一起会让模型学到错误的相关性。两个模型各自的输入尺寸可以相同,但样本来源不同。

2.2.1 自动切帧脚本:视频转图片的Opencv写法

如果你拿到的是视频数据,需要用脚本切帧。注意切帧不是每帧都保存,而是按间隔抽样,否则连续帧高度相似,会造成训练集和验证集数据泄漏。我通常每秒抽2到3帧:

import cv2 import os def extract_frames(video_path, output_dir, sample_rate=3, target_size=(64, 64)): """ sample_rate: 每秒抽取的帧数,不是“每隔几帧抽一帧” 讲解:视频帧率通常是25fps或30fps,如果按帧间隔抽取,帧率不同导致抽取密度不同; 这里按时间维度抽帧,保证无论是25fps还是30fps,每秒都得到相同数量的样本。 """ os.makedirs(output_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) frame_interval = int(fps / sample_rate) count = 0 saved = 0 while True: ret, frame = cap.read() if not ret: break if count % frame_interval == 0: # 裁剪人脸区域:这里假设已经有人脸框,实际项目中应接检测器 # 简单做法是取画面中央区域作为ROI h, w = frame.shape[:2] roi = frame[int(h*0.2):int(h*0.8), int(w*0.2):int(w*0.8)] resized = cv2.resize(roi, target_size) cv2.imwrite(os.path.join(output_dir, f"frame_{saved:05d}.jpg"), resized) saved += 1 count += 1 cap.release() print(f"从视频提取了 {saved} 帧,保存在 {output_dir}")

切出来的帧要人工筛选,把模糊帧、非驾驶场景帧删除。这一步虽然枯燥,但对模型精度的影响远超调参。

2.3 数据增强:驾驶场景不能用随机翻转和随机旋转

数据增强是毕设提升准确率性价比最高的手段,但驾驶场景有特殊性。水平翻转是安全的,因为乘客位和驾驶位镜像后疲劳特征不变。随机旋转就要谨慎,驾驶时头部很少大幅度倾斜,旋转超过10度会引入不存在的姿态。随机亮度、对比度调整非常有效,因为驾驶舱光照变化剧烈。我常用的增强配置如下:

增强方式参数范围适用区域理由
水平翻转p=0.5眼睛、嘴巴镜像不改变疲劳语义
随机亮度0.7~1.3眼睛、嘴巴模拟逆光/阴影
随机对比度0.8~1.2眼睛、嘴巴模拟不同曝光
高斯噪声std=0.005眼睛模拟摄像头暗光噪声
随机旋转-8°~8°眼睛、嘴巴模拟轻微头部偏转
随机裁剪0.9~1.0眼睛、嘴巴模拟检测框偏移

这里有一个原则:增强幅度宁可小不要大。很多人在眼睛区域上用10度以上的旋转,结果模型学到的是“非正脸全被分为闭眼”,因为闭眼样本里侧脸比例增大了。

3. CNN模型选型和训练参数:从LeNet到轻量网络的取舍

3.1 为什么不用大模型:输入尺寸小,精度收益有限

疲劳状态分类的输入是48x48或64x64的小图,这类任务的特征相对简单——眼睑边缘、瞳孔区域、嘴巴开合度。用ResNet50、EfficientNet这种为大分辨率图像设计的大网络,参数量动辄几千万,在几万张样本的小数据集上很容易过拟合,训练时间也长。我一般会在三个方向里选:

模型参数量单帧推理耗时(CPU)验证集准确率适用情况
自建轻量CNN约20万1~2ms97%左右毕设演示,代码清晰易解释
MobileNetV3-Small约250万3~4ms98%左右有预训练权重,迁移学习
ResNet18约1100万5~8ms98.5%左右追求最终准确率,不差算力

毕设答辩时,自建轻量CNN反而更受老师认可,因为它结构简单,每一层的设计意图都能讲明白。如果项目要求“改进创新”,再在自建模型上做修改,比如把普通卷积换成分组卷积或深度可分离卷积。

3.1.1 一个可解释的轻量CNN结构设计

我自己常用的结构是三层卷积加两层全连接,故意不用BN,因为输入是单通道灰度图,BN在小batch下反而导致训练不稳定。结构如下:

  • 输入层:64x64x1灰度图(把RGB转灰度可以减少计算量,且颜色对疲劳识别无用)
  • Conv1:32个3x3卷积核,padding=1,输出64x64x32
  • ReLU + MaxPool(2x2),输出32x32x32
  • Conv2:64个3x3卷积核,padding=1,输出32x32x64
  • ReLU + MaxPool(2x2),输出16x16x64
  • Conv3:128个3x3卷积核,padding=1,输出16x16x128
  • ReLU + MaxPool(2x2),输出8x8x128
  • Flatten:8x8x128 = 8192
  • FC1:128
  • 输出层:2(睁眼/闭眼)或2(张嘴/闭嘴)

这个结构在眼睛和嘴巴两个任务上通用,只需要改最后一个全连接的输入尺寸。

3.2 训练脚本中最容易出错的两个参数:学习率和batch size

疲劳数据集不大,几千到几万张,训练时的batch size一般取32或64。学习率初始值1e-3配合Adam优化器,每10个epoch降为原来的0.1倍。这里有个实际经验:如果loss在训练初期不下降,先检查数据是否有问题,而不是调学习率。

import torch import torch.nn as nn import torch.optim as optim class FatigueCNN(nn.Module): def __init__(self, num_classes=2): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 8 * 8, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), # 防止全连接层过拟合 nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

代码说明:输入是四维张量,形状为(batch, 1, 64, 64),第一维是batch。两个任务共用这个模型类,眼睛模型num_classes=2,嘴巴模型num_classes=2。Dropout放在全连接前,推理时要调用model.eval()关闭Dropout,否则预测结果会有随机性。

3.3 训练参数默认值表:照抄能跑通,微调能提点

下面的参数表是我在多个疲劳数据集上试过的稳定组合,毕设阶段直接照抄,不要一开始就想着调参。模型不收敛先排查数据和标签,准确率卡住再动这些参数。

参数推荐值说明
输入尺寸64x64再大收益有限,再小细节丢失
batch size32显存不够降到16
优化器Adambetas=(0.9, 0.999),默认即可
初始学习率1e-3预训练模型用1e-4
学习率衰减step 10, gamma 0.1每10个epoch乘以0.1
epoch30~50有早停时看验证集
loss函数CrossEntropyLoss多分类默认
类别不平衡处理加权采样或class_weight闭眼样本少时必做

如果闭眼样本只有睁眼的五分之一,直接训练会得到“全预测睁眼”的高准确率假象。解决办法是计算每个类别的权重,传给CrossEntropyLoss的weight参数。一个更稳妥的做法是控制采样器,让每个batch里闭眼样本占比不低于30%。

4. 完整训练到检测的落地:数据加载、早停、推理与二次判定

4.1 数据加载器必须做的两件事:灰度化和标准化

PyTorch的ImageFolder默认按文件夹名生成类别标签,但不会自动做灰度化和标准化。疲劳检测的实际数据是摄像头彩色帧,裁剪后转灰度,灰度值范围是0到255,CNN对这样的大数值输入比较敏感,需要标准化到接近0均值。下面是完整的加载器配置:

from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.Grayscale(num_output_channels=1), # 三通道转单通道灰度 transforms.RandomHorizontalFlip(p=0.5), # 仅有水平翻转,无随机旋转 transforms.ColorJitter(brightness=0.3, contrast=0.2), transforms.ToTensor(), # 转Tensor并归一化到[0,1] ]) val_transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.Grayscale(num_output_channels=1), transforms.ToTensor(), ]) train_dataset = datasets.ImageFolder('dataset/train', transform=train_transform) val_dataset = datasets.ImageFolder('dataset/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4)

灰度化放在Resize之后,因为先缩小再转灰度可以减少计算量。ColorJitter只调亮度和对比度,不调色相和饱和度,因为疲劳特征与颜色无关,调整颜色只会增加无用噪声。

4.1.1 类别不平衡的采样器写法

统计两个类别的样本数,然后给闭眼类更高的采样权重。PyTorch里最简单的做法是给每个样本一个权重,采样概率反比于类别频率。这样每个epoch看到的闭眼样本数量会显著增加,但要注意采样器与shuffle冲突,用了WeightedRandomSampler后DataLoader就不需要再设shuffle=True。

4.2 训练主循环:记录验证集准确率而不是训练集

很多毕设代码只打印训练loss,不评估验证集。这会导致过拟合时自己完全无感。我习惯每个epoch结束后跑一次验证集,保存验证集准确率最高的权重,而不是最后一个epoch的权重。早停设置在连续10个epoch验证集准确率没有提升时终止训练。

def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0 correct = 0 total = 0 for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss = 0 correct = 0 total = 0 with torch.no_grad(): # 推理阶段不计算梯度,省内存且加速 for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) total_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return total_loss / total, correct / total

验证时用torch.no_grad()包住推理过程,既减少显存占用,也避免意外的梯度累积。保存权重时,要同时保存模型结构和参数,否则换机器加载容易出错。

4.3 推理脚本:把单帧状态转成疲劳报警的完整逻辑

CNN输出的是每个类别的概率,不是疲劳结论。把概率变成报警,需要一个状态机。我常用的判定逻辑分三层:

  1. 单帧层:眼睛概率>0.7记为闭眼,嘴巴概率>0.7记为张嘴;
  2. 窗口层:统计过去30帧内闭眼帧占比,超过40%标记为“眼睛疲劳”;
  3. 持久层:连续3个窗口都标记为疲劳,才真正报警。

第三层的作用是防止短暂低头、眨眼等情况造成误报。下面是推理代码的核心部分:

import numpy as np class FatigueDetector: def __init__(self, eye_model, mouth_model, window_size=30, closed_ratio=0.4): self.eye_model = eye_model self.mouth_model = mouth_model self.window_size = window_size self.closed_ratio = closed_ratio self.eye_history = [] self.mouth_history = [] def predict(self, eye_img, mouth_img): """ 输入为裁剪好的眼睛区域和嘴巴区域,输出疲劳状态 """ eye_prob = self.eye_model.predict_proba(eye_img) # 假设返回闭眼概率 mouth_prob = self.mouth_model.predict_proba(mouth_img) self.eye_history.append(1 if eye_prob > 0.7 else 0) self.mouth_history.append(1 if mouth_prob > 0.7 else 0) # 只保留最近window_size帧 if len(self.eye_history) > self.window_size: self.eye_history.pop(0) self.mouth_history.pop(0) # 窗口统计 eye_ratio = np.mean(self.eye_history) if self.eye_history else 0 yawn_count = self._count_yawns() # 返回多级状态:0正常 1疲劳 2严重疲劳 if eye_ratio > self.closed_ratio and yawn_count >= 2: return 2 elif eye_ratio > self.closed_ratio or yawn_count >= 3: return 1 return 0

预测接口返回0、1、2三个等级,UI层根据等级决定显示绿色、橙色还是红色报警。为什么闭眼比例设为0.4而不是0.5?因为正常眨眼单次约0.2秒,25fps下是5帧,哪怕正常状态,30帧窗口也可能有5帧闭眼,占比16%。0.4的比例给正常眨眼留了充足余量,又能在连续闭眼1.2秒时触发报警。

5. 部署时的输出端调优:模型量化、误报率验证和UI状态映射

5.1 用torchscript和int8量化把推理时间压到10ms以内

毕设答辩时经常被问“系统能否实时运行”。如果模型是自建轻量CNN,CPU上单帧检测本身只要几毫秒,瓶颈往往不在模型,而在数据预处理和线程调度。我一般先用torch.jit把模型打包成TorchScript格式,推理时省去Python前向传播的解释开销;再用动态量化把模型转成int8,代价是1%左右的精度损失,换取更稳定的推理延迟。量化代码只需要几行:

import torch # 把训练好的state_dict加载回模型结构 model = FatigueCNN(num_classes=2) model.load_state_dict(torch.load('eye_model.pth', map_location='cpu')) model.eval() # 转TorchScript,推理时不再依赖模型类定义 example_input = torch.rand(1, 1, 64, 64) scripted_model = torch.jit.trace(model, example_input) scripted_model.save('eye_model_scripted.pt') # 动态量化:只量化全连接层,卷积层保留float32 quantized_model = torch.quantization.quantize_dynamic( scripted_model, {torch.nn.Linear}, dtype=torch.qint8 ) torch.jit.save(quantized_model, 'eye_model_quantized.pt')

注意量化对精度的影响要实测。如果你的验证集准确率原本是98%,量化后可能掉到96%以下,说明模型本身冗余度不够,不适合量化。这种情况下不要强行压缩,直接把原模型部署即可。

5.1.1 用混淆矩阵验证指标而不只看准确率

疲劳检测的测试集里,清醒样本远多于疲劳样本,准确率会虚高。一份可靠的测试报告至少包含混淆矩阵和每类精确率、召回率、F1值。尤其是闭眼类别的召回率,它代表“真正闭眼时系统能不能发现”,比整体准确率重要得多。推荐在测试集上输出如下格式:

闭眼检测结果: 预测睁眼 预测闭眼 实际睁眼 4820 37 实际闭眼 23 620 闭眼召回率:620 / (23 + 620) = 96.4% 闭眼精确率:620 / (620 + 37) = 94.4%

如果闭眼召回率偏低,说明分类阈值0.7太高,试着降到0.6;如果闭眼精确率偏低,说明误报多,阈值要往上调。这个阈值调整操作,比换模型结构提分更快。

5.2 UI状态映射与报警去抖

最后是演示系统的输出层。常见做法是OpenCV显示视频流,用cv2.putText在画面上叠加状态文字。但要注意:判定结果不能每一帧都重绘报警框,否则会在报警/正常之间快速闪烁。给报警加一个去抖机制:一旦进入疲劳状态,至少持续2秒才能回到正常,过渡到正常也要连续5帧判定为正常。这个机制与CNN无关,但直接影响演示效果,评委看到系统在疲劳时稳定报警、清醒时稳定熄灭,印象分会明显提升。部署完成后,拿两段未参与训练的视频做端到端测试,一段是正常驾驶(期望全程不报警),一段是模拟疲劳(期望每分钟至少触发两次报警),把这两个结果截图放进毕设报告里,整个项目的闭环就完整了。

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

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

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

立即咨询