简介:这份资源面向计算机相关专业正在做课程大作业、毕业设计或需要项目实战练习的学习者,提供一套基于卷积神经网络的驾驶员疲劳检测与预警系统完整实现。项目经导师指导并通过评审,获得98分,源码均经过本地编译与严格调试,可稳定运行,难度适中,适合作为毕设参考或深度学习入门实战案例。压缩包共37个文件,约500.41MB,包含16个Python源码文件、9个编译缓存文件、5张效果图、3个模型权重文件以及说明文档与日志,覆盖模型定义、训练、评估、摄像头与视频检测等模块,并附带数据集压缩包与预训练权重,便于直接复现训练与推理流程。目前已有165人学习下载。读者可从中获得完整的网络结构设计、数据增强与损失函数实现、训练与测试脚本、实时检测演示代码及权重文件,既能理解疲劳检测的整体技术链路,也能在此基础上进行二次开发与功能扩展。
1. 从一张打哈欠的抓拍说起:驾驶员疲劳检测到底在做什么
凌晨两点跑高速,服务区监控拍到驾驶员连续打了三个哈欠,方向盘还出现了小幅度的左右修正——这是典型的疲劳前兆。基于卷积神经网络的驾驶员疲劳检测与预警系统,要干的事就是把这套“人眼判断”翻译成模型能算的数值:从摄像头画面里定位人脸、眼睛、嘴巴,判断闭眼时长和打哈欠频率,超过阈值就触发预警。它解决的是长途货运、网约车、矿区运输这类场景里“人困了但没人提醒”的问题,适合做计算机视觉方向毕业设计、想跑通一套端到端 Python 项目的同学,也适合需要快速验证疲劳检测可行性的工程团队。整套链路不复杂:数据采集、人脸对齐、CNN 分类、时序判定、预警输出,难点在于把每个环节的参数调到能稳定复现,而不是跑一次 demo 就翻车。
2. 疲劳检测的技术选型:为什么是 CNN 而不是传统特征
2.1 从 EAR 到 CNN:两条路线的取舍
早期疲劳检测主流是手工特征路线,最典型的是眼睛纵横比(Eye Aspect Ratio,EAR)。它的逻辑很直接:人眼睁开时上下眼睑距离大,闭眼时距离趋近于零,用 68 个人脸关键点算出 EAR 值,低于阈值就判定闭眼。这条路线的优点是计算量小、可解释性强,在嵌入式设备上跑得动;缺点是阈值对光照、眼镜、头部姿态极其敏感,换个驾驶员、换个时段就得重新标定,泛化能力差。
CNN 路线走的是另一条逻辑:不手工设计特征,而是把眼睛、嘴巴区域裁剪成小图,直接让卷积网络学“睁眼/闭眼”“张嘴/闭嘴”的纹理差异。卷积核在局部感受野上滑动,浅层学边缘和角点,深层学眼睑轮廓和口腔结构,最后全连接层输出二分类概率。它的优势是对光照和个体差异更鲁棒,代价是需要标注数据、训练时间,以及推理时的算力开销。
实际做毕业设计,我一般建议走 CNN 为主、EAR 为辅的混合路线:CNN 负责眼睛和嘴巴状态分类,EAR 作为兜底校验,两者结果不一致时以 CNN 为准并记录日志。这样既有深度学习的技术含量,又保留了传统方法作为可解释的对照。
2.2 数据集怎么选:从 MRL 到自建标注
公开数据集里,MRL Eye Dataset 是眼睛状态分类最常用的一个,包含约 8 万多张眼部红外和可见光图像,标注了睁眼/闭眼两类。YawDD 数据集则偏向打哈欠和说话状态,视频帧里包含嘴巴开合变化。这两个数据集组合起来,基本能覆盖眼睛和嘴巴两个核心判据。
但公开数据集有个现实问题:场景太干净。MRL 的眼部图像是裁剪好的,没有复杂背景和头部大角度偏转;YawDD 的拍摄条件也偏理想。直接拿来做毕业设计,答辩时老师一问“夜间怎么办”“戴眼镜怎么办”就容易卡住。我的做法是:公开数据集做预训练,再用自己采集的 2000 到 3000 帧真实驾驶场景做微调。采集时注意覆盖白天、傍晚、夜间补光三种光照,以及戴眼镜、不戴眼镜、轻微侧脸三种姿态。
标注工具用 LabelImg 或 CVAT 都行,眼睛标两类(open/closed),嘴巴标三类(normal/yawn/talking)。标注一致性很关键,同一个人连续标 500 张后容易疲劳走神,建议每 200 张休息一次,或者两个人交叉校验。
2.3 模型结构:从 LeNet-5 到轻量级 CNN 的演进
热词里出现的 LeNet-5 是卷积神经网络的经典结构,两个卷积层、两个池化层、三个全连接层,参数量约 6 万。用它做眼睛状态分类,在 MRL 数据集上能跑到 95% 以上的准确率,训练也快,CPU 上几分钟就能跑完一个 epoch。但 LeNet-5 的输入尺寸是 32×32,对嘴巴区域这种需要更大感受野的目标,信息量不够。
我一般会用一个改进的轻量级 CNN:输入 64×64 灰度图,三个卷积块,每个块是 Conv2d + BatchNorm + ReLU + MaxPool,通道数从 32 到 64 再到 128,最后接全局平均池化和全连接层输出分类。这个结构参数量在 20 万左右,比 LeNet-5 大但远小于 ResNet,在 GTX 1650 上训练 50 个 epoch 大约 15 分钟,推理单帧不到 5 毫秒。
选这个结构的原因是:疲劳检测是二分类或三分类任务,不需要 ImageNet 级别的深层网络;BatchNorm 能加速收敛,全局平均池化替代全连接能减少过拟合;输入 64×64 是在信息量和计算量之间的折中,再大就影响实时性,再小嘴巴的纹理就糊了。
3. 从零跑通训练:数据预处理、模型定义与训练脚本
3.1 数据预处理:把视频帧变成模型能吃的张量
拿到原始视频后,第一步是抽帧。用 OpenCV 按每秒 5 帧的间隔抽,既能覆盖眨眼和打哈欠的完整过程,又不会让数据量爆炸。抽出来的帧用 MTCNN 或 dlib 做人脸检测和对齐,裁出眼睛和嘴巴区域,统一缩放到 64×64,转灰度,归一化到 [0,1]。
import cv2 import os import numpy as np from mtcnn import MTCNN detector = MTCNN() def extract_eye_mouth(frame, save_dir, frame_id): """从单帧中检测人脸并裁剪眼睛、嘴巴区域""" rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = detector.detect_faces(rgb) if not results: return False # 取置信度最高的人脸 face = max(results, key=lambda x: x['confidence']) x, y, w, h = face['box'] keypoints = face['keypoints'] # 左眼区域:以左眼关键点为中心,取 0.3 倍脸宽 left_eye = keypoints['left_eye'] right_eye = keypoints['right_eye'] mouth_left = keypoints['mouth_left'] mouth_right = keypoints['mouth_right'] eye_w = int(w * 0.3) eye_h = int(h * 0.15) mouth_w = int(w * 0.4) mouth_h = int(h * 0.25) # 裁剪并保存 for name, (cx, cy), (cw, ch) in [ ('left_eye', left_eye, (eye_w, eye_h)), ('right_eye', right_eye, (eye_w, eye_h)), ('mouth', ((mouth_left[0]+mouth_right[0])//2, (mouth_left[1]+mouth_right[1])//2), (mouth_w, mouth_h)) ]: x1 = max(0, cx - cw//2) y1 = max(0, cy - ch//2) x2 = min(frame.shape[1], cx + cw//2) y2 = min(frame.shape[0], cy + ch//2) roi = frame[y1:y2, x1:x2] roi = cv2.resize(roi, (64, 64)) roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) cv2.imwrite(os.path.join(save_dir, f'{name}_{frame_id}.jpg'), roi) return True这段代码的逻辑是:MTCNN 返回人脸框和五个关键点,用关键点坐标推算眼睛和嘴巴的裁剪区域,再统一缩放和灰度化。参数上,眼睛区域取脸宽的 0.3 倍、脸高的 0.15 倍,嘴巴取脸宽的 0.4 倍、脸高的 0.25 倍,这是我在多个数据集上试出来的经验值——太小会切掉眼角和嘴角,太大会引入脸颊和鼻子的干扰。抽帧间隔设为 5 帧每秒,是因为正常眨眼约 0.2 到 0.4 秒,5 帧每秒能保证每个眨眼过程至少被采到 1 到 2 帧。
3.2 模型定义:一个可复现的轻量级 CNN
模型用 PyTorch 写,结构清晰,方便改通道数和层数。
import torch import torch.nn as nn class FatigueCNN(nn.Module): def __init__(self, num_classes=2): super(FatigueCNN, self).__init__() # 输入 1x64x64 self.block1 = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2) # 32x32 ) self.block2 = nn.Sequential( nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) # 16x16 ) self.block3 = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2) # 8x8 ) self.gap = nn.AdaptiveAvgPool2d(1) # 128x1x1 self.fc = nn.Linear(128, num_classes) self.dropout = nn.Dropout(0.5) def forward(self, x): x = self.block1(x) x = self.block2(x) x = self.block3(x) x = self.gap(x) x = x.view(x.size(0), -1) x = self.dropout(x) x = self.fc(x) return x三个卷积块的设计逻辑:第一块 32 通道学边缘和角点,第二块 64 通道学眼睑和嘴角的局部形状,第三块 128 通道学整体开合状态。每个卷积后接 BatchNorm 是为了让每层输入分布稳定,训练时可以用更大的学习率;Dropout 设 0.5 是防止全连接层过拟合,因为疲劳检测的训练样本通常只有几千到几万张,容易记住训练集。全局平均池化替代展平后的大全连接,参数量从百万级降到十几万,推理速度也更快。
3.3 训练脚本:损失函数、优化器与学习率调度
训练用交叉熵损失,优化器选 Adam,初始学习率 1e-3,每 20 个 epoch 衰减到原来的 0.1 倍。
from torch.utils.data import DataLoader, Dataset from torchvision import transforms import torch.optim as optim from PIL import Image import glob class EyeMouthDataset(Dataset): def __init__(self, data_dir, transform=None): self.images = glob.glob(os.path.join(data_dir, '*.jpg')) self.transform = transform # 文件名前缀决定标签:closed_ 为 0,open_ 为 1 self.labels = [0 if 'closed' in os.path.basename(p) else 1 for p in self.images] def __len__(self): return len(self.images) def __getitem__(self, idx): img = Image.open(self.images[idx]).convert('L') if self.transform: img = self.transform(img) return img, self.labels[idx] transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) train_dataset = EyeMouthDataset('data/train', transform=transform) val_dataset = EyeMouthDataset('data/val', transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = FatigueCNN(num_classes=2).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.1) for epoch in range(50): model.train() running_loss = 0.0 for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}, ' f'Val Acc: {100*correct/total:.2f}%')数据增强只用了水平翻转和 ±10 度旋转,没有用颜色抖动,因为输入已经是灰度图,颜色变换没有意义;旋转角度限制在 10 度以内,是因为驾驶场景中头部大角度偏转时人脸检测本身就会失败,增强过度反而引入噪声。Batch size 设 64 是在 6GB 显存下的折中,再大容易 OOM,再小训练不稳定。学习率衰减用 StepLR,每 20 个 epoch 乘 0.1,这是训练小规模 CNN 的常用策略,比 CosineAnnealing 更直观,方便在答辩时解释。
训练过程中重点看两个指标:训练 loss 是否持续下降,验证准确率是否在 30 个 epoch 后趋于稳定。如果训练 loss 下降但验证准确率不涨,说明过拟合,需要增加 Dropout 或减少全连接层;如果训练 loss 震荡,把学习率降到 1e-4 再试。
4. 预警逻辑与系统集成:从单帧分类到时序判定
4.1 疲劳判定的三个核心指标
单帧分类只能告诉你“这一帧眼睛是闭的”,但疲劳检测需要的是“连续闭眼超过 2 秒”或“30 秒内打哈欠 3 次”。所以要在 CNN 输出之上加一层时序逻辑。
我一般用三个指标:闭眼持续时长(Continuous Eye Closure Duration)、打哈欠频率(Yawn Frequency)、PERCLOS(Percentage of Eye Closure,单位时间内闭眼帧占比)。闭眼时长超过 2 秒触发一级预警,超过 3 秒触发二级预警;打哈欠频率超过每分钟 3 次触发一级预警;PERCLOS 超过 0.4 触发二级预警。三个指标取或逻辑,任一满足就报警。
实现上用滑动窗口:维护一个长度为 150 帧的队列(按 30 帧每秒算,约 5 秒),每帧记录眼睛状态和嘴巴状态,实时计算闭眼连续帧数和打哈欠次数。
from collections import deque class FatigueMonitor: def __init__(self, fps=30, eye_close_thresh=2.0, yawn_thresh=3): self.fps = fps self.eye_close_thresh = eye_close_thresh # 秒 self.yawn_thresh = yawn_thresh # 每分钟次数 self.eye_buffer = deque(maxlen=fps * 5) # 5 秒窗口 self.yawn_timestamps = deque(maxlen=60) # 60 秒窗口 self.consecutive_closed = 0 def update(self, eye_state, mouth_state): """eye_state: 0=闭眼, 1=睁眼; mouth_state: 0=正常, 1=打哈欠""" self.eye_buffer.append(eye_state) if eye_state == 0: self.consecutive_closed += 1 else: self.consecutive_closed = 0 if mouth_state == 1: self.yawn_timestamps.append(time.time()) # 闭眼时长判定 close_duration = self.consecutive_closed / self.fps if close_duration >= self.eye_close_thresh: return 'LEVEL_2', f'闭眼 {close_duration:.1f} 秒' # 打哈欠频率判定 now = time.time() recent_yawns = sum(1 for t in self.yawn_timestamps if now - t <= 60) if recent_yawns >= self.yawn_thresh: return 'LEVEL_1', f'一分钟内打哈欠 {recent_yawns} 次' # PERCLOS 判定 if len(self.eye_buffer) == self.eye_buffer.maxlen: perclos = 1 - sum(self.eye_buffer) / len(self.eye_buffer) if perclos > 0.4: return 'LEVEL_2', f'PERCLOS={perclos:.2f}' return 'NORMAL', ''这段代码的关键参数:eye_close_thresh 设 2 秒,是因为正常眨眼在 0.2 到 0.4 秒,超过 2 秒的闭眼基本可以判定为微睡眠;yawn_thresh 设 3 次每分钟,是参考了疲劳驾驶研究中的常见阈值;PERCLOS 窗口取 5 秒,是因为太短容易受单帧误判影响,太长则反应迟钝。实际部署时,这些阈值可以根据驾驶员个体差异做自适应调整,比如记录前 10 分钟的基线数据,再动态设定阈值。
4.2 预警输出:声音、灯光与日志
预警触发后,输出层要做三件事:播放提示音、点亮红色 LED、写入日志。提示音用 pygame 播放一段 2 秒的蜂鸣,LED 通过串口发给 Arduino 控制,日志用 Python 的 logging 模块按天切分。
import pygame import serial import logging from datetime import datetime class AlertSystem: def __init__(self, serial_port='COM3', baudrate=9600): pygame.mixer.init() self.sound = pygame.mixer.Sound('alert.wav') self.serial = serial.Serial(serial_port, baudrate, timeout=1) logging.basicConfig( filename=f'fatigue_{datetime.now().strftime("%Y%m%d")}.log', level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) def trigger(self, level, message): if level == 'LEVEL_1': self.sound.play() self.serial.write(b'Y') # 黄色 LED logging.warning(f'Level 1: {message}') elif level == 'LEVEL_2': self.sound.play(loops=3) self.serial.write(b'R') # 红色 LED logging.error(f'Level 2: {message}')串口通信的波特率设 9600 是 Arduino 的默认值,实际用的时候要确认 COM 口编号,Windows 在设备管理器里看,Linux 一般是 /dev/ttyUSB0。日志按天切分是为了方便回溯,答辩时可以用日志证明系统连续运行的稳定性。
5. 避坑与排查:疲劳检测系统最容易翻车的五个地方
5.1 人脸检测在夜间红外画面下频繁丢失
现象:白天测试正常,夜间切换到红外摄像头后,MTCNN 检测不到人脸,帧率从 30 掉到 5 以下。
原因:MTCNN 的训练数据以可见光为主,红外图像的单通道特性和可见光差异大,级联网络的浅层特征不匹配。
解决:换用 dlib 的 HOG 检测器做兜底,或者用红外数据微调 MTCNN 的第一级 P-Net。更省事的做法是夜间直接用 EAR 路线,因为红外画面下眼睛轮廓比纹理更稳定,EAR 反而比 CNN 更可靠。
5.2 戴眼镜时眼睛区域裁剪偏移
现象:不戴眼镜时眼睛分类准确率 96%,戴上反光镜片后掉到 70% 以下。
原因:眼镜框和镜片反光干扰了关键点定位,MTCNN 返回的眼部关键点偏移,裁剪区域切到了镜框而不是眼睛。
解决:在裁剪前加一个眼镜检测分支,检测到眼镜时把眼睛区域扩大 1.3 倍,并在训练数据里加入戴眼镜的样本做微调。如果条件允许,用近红外摄像头可以大幅减少镜片反光。
5.3 模型在验证集上准确率高但实时视频里误报多
现象:验证集准确率 95%,但接上摄像头后,正常睁眼被频繁判为闭眼。
原因:验证集是裁剪好的静态图,而实时视频里人脸角度、光照、运动模糊都在变化,分布不一致。
解决:在训练数据里加入运动模糊和随机遮挡增强,验证时不要只看静态图准确率,要用一段真实驾驶视频做端到端测试,统计误报率和漏报率。我一般要求误报率低于 5%、漏报率低于 2% 才算通过。
5.4 闭眼时长计算受帧率波动影响
现象:同一段视频,在不同电脑上跑出来的闭眼时长不一样,预警触发时间差了一秒多。
原因:代码里用帧数除以固定 fps 算时长,但实际摄像头帧率会波动,CPU 占用高时掉帧严重。
解决:不要用帧数算时长,改用时间戳。每帧记录 time.time(),闭眼时长用当前时间减去闭眼开始时间,这样帧率波动不影响判定结果。
5.5 串口通信在长时间运行后阻塞主线程
现象:系统跑 2 小时后画面卡死,日志停止写入。
原因:serial.write 在缓冲区满时会阻塞,如果 Arduino 端没有及时读取,主线程就被卡住。
解决:把串口写入放到独立线程,用队列传递指令;或者设置 write_timeout,超时就丢弃本次指令,保证主循环不被阻塞。
6. 把误报压下去:自适应阈值与多模型投票的进阶技巧
基础版系统跑通后,最影响体验的就是误报。我试过两个有效的改进方向:自适应阈值和多模型投票。
自适应阈值的思路是:每个驾驶员的眼睛开合幅度不同,固定 EAR 阈值或固定闭眼时长阈值都不合理。系统启动后先采集 5 分钟正常驾驶数据,统计眼睛睁开时的 EAR 均值和标准差,把闭眼阈值设为均值减去 2 倍标准差;闭眼时长阈值也根据前 5 分钟的最大闭眼时长动态调整,取最大值的 1.5 倍。这样不同驾驶员、不同摄像头位置都能自适应。
多模型投票的思路是:单独一个 CNN 对眼睛状态分类,在侧脸或遮挡时容易出错。我训练了三个模型——左眼 CNN、右眼 CNN、嘴巴 CNN,三个模型独立输出,再用多数投票决定最终状态。如果左眼和右眼判断不一致,就看嘴巴状态:嘴巴正常时以睁眼为准,嘴巴打哈欠时以闭眼为准。这个逻辑在实测中把误报率从 8% 压到了 3% 左右。
def ensemble_predict(left_prob, right_prob, mouth_prob): """三个模型的输出概率做投票,返回最终眼睛状态""" left_closed = left_prob[0] > 0.5 right_closed = right_prob[0] > 0.5 mouth_yawn = mouth_prob[1] > 0.5 votes = sum([left_closed, right_closed]) if votes == 2: return 0 # 闭眼 elif votes == 0: return 1 # 睁眼 else: # 分歧时看嘴巴 return 0 if mouth_yawn else 1验证方法上,我习惯用一段 10 分钟的真实驾驶视频做端到端测试,人工标注每一秒的疲劳状态作为 ground truth,然后统计系统的准确率、误报次数和漏报次数。如果误报集中在某个时间段,就回放那段视频看是光照变化还是头部姿态导致的,针对性补数据。这个习惯帮我省了很多答辩时的尴尬——老师问“你这个系统误报多少”,我能直接拿出统计表。
做这套系统最大的教训是:不要等全部做完再测,每加一个模块就接上摄像头跑 10 分钟,问题越早暴露越好修。希望帮到你。
本文还有配套的精品资源,点击获取