ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于CNN与PERCLOS的驾驶员疲劳检测系统设计与实现

基于CNN与PERCLOS的驾驶员疲劳检测系统设计与实现 简介面向计算机相关专业毕业生与项目实战学习者提供基于Python卷积神经网络的人脸识别驾驶员疲劳检测与预警系统源码及配套数据集覆盖数据预处理、模型训练、测试评估与实时检测等核心环节可作为课程设计、期末大作业或毕业设计的起点。压缩包共三十七个文件包括十六个Python脚本、三个模型权重文件、测试图片、说明文档、数据集压缩包及日志文件等整体约五百兆其中训练好的权重可直接加载结合源码与数据集能快速复现实验。已有一百八十二人学习浏览适合希望理解目标检测与疲劳状态判断流程的同学参考也便于在毕业设计中梳理系统架构与关键技术点。包内还包含摄像头实时检测与视频检测脚本便于换用自有数据继续调试和拓展整个工程目录结构清晰适合按模块阅读和二次开发。1. 疲劳检测不是识别“困”而是算“闭眼比例”驾驶员从进入微睡眠到真正失去对车辆控制中间往往有十几秒的“清醒错觉期”人觉得自己还醒着但眼睑开合已经不受控制。这套基于Python卷积神经网络人脸识别的驾驶员疲劳检测与预警系统本质要解决的不是“这个人现在困不困”而是“在图像序列里用眼睛的开闭状态算出他是否进入持续闭眼”。整个方案可以拆成三个能独立复现的部分前端用卷积神经网络做人脸检测与关键点定位拿双眼区域训练一个轻量CNN进行开闭眼分类后端按时间窗口统计闭眼占比PERCLOS作为疲劳判定指标最后触发分级的语音与界面预警。文章按毕业设计的落地路径来写覆盖从摄像头取流、数据集组织、模型训练到预警参数标定的全部细节适合需要把整套代码跑通并解释清楚的开发者。2. 摄像头取流与人脸检测用MTCNN稳定输出人脸框和眼睛关键点2.1 为什么疲劳检测的人脸检测要选MTCNN常见的人脸检测路线有三条OpenCV自带的Haar级联、基于SSD/ResNet的OpenCV DNN检测器、以及MTCNN这类多任务卷积网络。Haar是传统手工特征速度尚可但角度鲁棒性差戴上墨镜或稍微侧脸就容易跟丢OpenCV DNN检测器精度不错但默认输出只有人脸框拿不到眼睛关键点坐标。MTCNN把“人脸框回归”和“关键点回归”放在同一个多任务框架里一次前向既能给出人脸边界也能给出左眼、右眼、鼻尖、左右嘴角五个关键点的坐标。这个特性对疲劳检测非常关键因为后续裁剪双眼区域依赖的就是眼睛坐标。下面这张表可以把三者对比着看方便在答辩里说明你为什么选CNN路线而不是直接用Haar。检测方案是否卷积神经网络是否带眼睛关键点CPU单帧耗时640x480侧脸/遮挡鲁棒性Haar级联否否约3-8ms差正面勉强OpenCV FaceDetectorYN是是5点/15点约15-30ms较好MTCNN是P-Net/R-Net/O-Net三级联是5点约30-80ms好误检少从表格能看出MTCNN在精度和关键点能力上占优代价是单帧耗时偏高。实际项目里很少对每一帧都跑全图检测常见做法是检测到人脸后在下一帧用上一帧的人脸区域做局部跟踪把全图检测降到每秒5-10次可以明显缓解CPU压力。2.2 用MTCNN跑通最小取流代码先安装依赖常见做法是pip install mtcnn opencv-python。如果机器上已经有TensorFlow或PyTorch环境MTCNN的初始化会顺利不少因为它底层依赖TensorFlow来跑图计算。下面是一个最小可运行的摄像头取流人脸检测脚本import cv2 from mtcnn import MTCNN # min_face_size 控制最小人脸尺寸steps_threshold 是三级网络的判定阈值 detector MTCNN(min_face_size40, steps_threshold[0.6, 0.7, 0.7], scale_factor0.7) cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while True: ret, frame cap.read() if not ret: break # MTCNN 内部按 RGB 处理OpenCV 读进来是 BGR必须先转换 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) faces detector.detect_faces(rgb) if not faces: continue face faces[0] # 第一个检测结果按置信度排序 x, y, w, h face[box] if w 40: continue kp face[keypoints] # left_eye / right_eye / nose / mouth_left / mouth_right cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.circle(frame, kp[left_eye], 2, (0, 0, 255), -1) cv2.circle(frame, kp[right_eye], 2, (0, 0, 255), -1) cv2.imshow(face, frame) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()这段代码里值得说明的参数有三个。min_face_size40表示小于40像素的人脸直接忽略驾驶场景中驾驶员人脸通常占画面比例很大设置这个值能过滤后排乘客或窗外人脸减少误检。steps_threshold依次对应P-Net、R-Net、O-Net三个子网络的置信度阈值调低整体召回率上升但虚检也会变多我一般从默认值开始调只在漏检明显时降低第一级到0.5。scale_factor是图像金字塔的缩放步长0.7是常用值越小检测越精细但越慢。注意MTCNN 返回的box是(x, y, w, h)人脸贴近画面边缘时可能出现负数坐标直接拿去裁剪会让 OpenCV 报错。裁剪前先做一次边界处理x1, y1 max(0, x), max(0, y)再取x2 min(frame.shape[1], x w)。2.3 换用 OpenCV FaceDetectorYN 的替代方案如果你不想额外引入MTCNN对TensorFlow的依赖目前更轻的做法是使用OpenCV 4.5.4以上版本自带的FaceDetectorYN。这个模型也是卷积神经网络结构并且能直接输出人脸框和5个关键点detector cv2.FaceDetectorYN.create( face_detection_yunet_2023mar.onnx, # 模型文件需从opencv_zoo下载 , (320, 320), score_threshold0.7, backend_idcv2.dnn.DNN_BACKEND_OPENCV ) retval, faces detector.detect(frame) # faces 的形状是 (N, 15)前4列是人脸框第5列置信度 # 后面10列是右眼、左眼、鼻尖、右嘴角、左嘴角的 x/y 坐标这里detect直接接收BGR图像不需要做颜色转换比MTCNN少一步。输出坐标的比例是相对输入尺寸的使用前要乘回原图宽高。需要下载一个ONNX模型文件常见做法是从opencv_zoo仓库获取face_detection_yunet_2023mar.onnx放到项目models目录下。这样输出的是两个眼角坐标比MTCNN的中心点更精确。3. 构建眼睛状态数据集训练轻量CNN眼睛开闭分类器3.1 数据集从哪来、怎么标注疲劳检测里最难的不是训练本身而是数据集。公开的驾驶员疲劳数据集不少但分辨率、摄像头角度、光照条件未必和你的实验环境一致直接拿来做迁移效果很容易打折扣。毕业设计答辩时老师第一问往往就是“数据集来源和标注方式”所以更靠谱的路线是自己用同一个摄像头、同一个坐姿采集一部分数据再配合公开数据集做补充。采集策略是这样的摄像头固定在仪表台附近模拟驾驶视角分别录制三段视频——正常睁眼驾驶、频繁眨眼、故意闭眼2秒以上。用第2章的人脸检测脚本一帧帧跑把左右眼区域裁出来人工按键归档到open和closed两个文件夹。闭眼样本天然稀少可以通过“目视下前方自然闭眼”和“短暂闭眼再睁眼”两种动作增加多样性。采集结束后对closed目录做一次人工清洗把半闭眼、遮挡、运动模糊的样本剔除或单独处理。3.2 双眼区域裁剪脚本左右眼要拼成一张图再进网络原因是多数疲劳检测场景中两只眼睛的状态是一致的合并输入能让网络同时利用双侧特征单眼被遮挡时还有另一侧兜底。下面这段裁剪脚本可以直接接在第2章的检测循环后面import os import cv2 import numpy as np def crop_eye(gray, pt, half_w18, half_h12): 以关键点为中心裁一块 36x24 的灰度区域越界则补边 x, y int(pt[0]), int(pt[1]) h, w gray.shape[:2] x1, y1 max(0, x - half_w), max(0, y - half_h) x2, y2 min(w, x half_w), min(h, y half_h) patch gray[y1:y2, x1:x2] # 靠近边界时尺寸会缩水统一resize回来 if patch.shape[0] ! half_h * 2 or patch.shape[1] ! half_w * 2: patch cv2.resize(patch, (half_w * 2, half_h * 2)) return patch def save_eye_pair(gray, kps, label, idx): le crop_eye(gray, kps[left_eye]) re crop_eye(gray, kps[right_eye]) if le is None or re is None: return # 右眼水平翻转后与左眼拼在一起保证两张眼睛的方向一致 composite np.hstack([le, cv2.flip(re, 1)]) path os.path.join(eye_samples, label, f{idx:06d}.jpg) cv2.imwrite(path, composite)crop_eye最关键的是处理越界人脸贴近画面边缘时以关键点为中心的矩形会超出图像范围直接切片会得到不完整的patch。这里先裁再resize保证输出固定是36x24避免后续拼图时维度对不上。cv2.flip(re, 1)把右眼水平翻转这样左右眼在合成图里都是“从外侧看向鼻梁”的视角CNN不用学两套方向特征。3.3 轻量CNN网络结构与训练配置这种二分类任务不需要百层残差网络反而小模型更适合部署。常见做法是用三层卷积堆一个分类器输入为48x24的灰度图。下面是用PyTorch实现的完整训练脚本数据加载部分单独抽了一个Dataset类import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import Dataset, DataLoader from torchvision import transforms import cv2 import os class EyeDataset(Dataset): def __init__(self, root, transformNone): self.paths, self.labels [], [] for label, name in enumerate([open, closed]): folder os.path.join(root, name) for f in sorted(os.listdir(folder)): self.paths.append(os.path.join(folder, f)) self.labels.append(label) self.transform transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img cv2.imread(self.paths[idx], cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (48, 24)) if self.transform is not None: img self.transform(img) # ToPILImage - 增强 - ToTensor return img, self.labels[idx] class EyeNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 16, 3, padding1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Dropout(0.4), nn.Linear(64 * 6 * 3, 2) ) def forward(self, x): return self.classifier(self.features(x)) train_tf transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(), transforms.RandomAffine(degrees5, translate(0.05, 0.05)), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) ]) dataset EyeDataset(eye_samples, transformtrain_tf) loader DataLoader(dataset, batch_size32, shuffleTrue) model EyeNet() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) criterion nn.CrossEntropyLoss(weighttorch.tensor([1.0, 2.0])) for epoch in range(20): model.train() total_loss 0.0 for x, y in loader: out model(x) loss criterion(out, y) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch}, loss {total_loss / len(loader):.4f}) torch.save(model.state_dict(), eye_model.pt)训练配置的每一项都有它存在的理由答辩时可能被逐个追问提前理清楚配置项取值作用输入尺寸48x24 灰度左右眼拼接后的实际大小足够保留眼睑轮廓网络结构3层Conv BatchNorm Dropout参数量小CPU也能跑到50fps以上batch_size32在梯度稳定性和显存占用之间取平衡学习率Adam 1e-3小数据集上的通用起点不收敛时降到3e-4损失权重闭眼类权重2.0闭眼样本更少且漏检闭眼的代价远高于误报CrossEntropyLoss的权重参数在这里很关键。采集到的闭眼样本通常只有睁眼的二分之一甚至更少如果不加权模型会倾向把所有输入都判成“睁眼”因为那样整体损失已经足够小。给闭眼类设2.0权重相当于强制模型把闭眼样本的梯度放大减少漏检。3.4 训练指标与半闭眼样本陷阱训练集准确率到90%以上不代表能用。你需要在训练集之外留出10%的验证集验证集里同样要包含闭眼样本。常见的坑有两个一是过拟合到某个人的眼型换一个驾驶员测试准确率立刻掉下来解决办法是数据增强里的RandomAffine和ColorJitter尽量开大或者采集时多找几个人二是半闭眼样本的处理连续快眨眼时相邻两帧会出现眼皮半垂的状态这种图人工标注都容易产生分歧我一般直接把这些样本从数据集中剔除只保留明确睁眼和明确闭眼让分类边界更干净。注意训练出的eye_model.pt只是“眼睛状态分类器”它不管人脸在哪里。整个系统跑测试时检测和人脸关键点仍然由第2章的MTCNN提供分类器只负责对裁剪出的双眼区域做判定。4. 从闭眼概率到PERCLOS把单帧判定变成分级预警4.1 单帧闭眼判定阈值设多少取决于模型输出分布将双眼区域送入训练好的EyeNet后得到的是两个类别的logits经过softmax变成“闭眼概率”p_close。直接拿0.5做阈值是常见做法但实际使用时我建议先跑一段正常驾驶数据把p_close的分布画出来再定阈值。正常情况下p_close应该在0.1以下闭眼时能冲到0.8以上这时把阈值设在0.6附近能过滤掉眨眼过程中的中间状态。如果模型输出普遍偏高比如正常状态下就有0.3说明训练数据或预处理有问题而不是阈值没调好。4.2 滑动时间窗口里的PERCLOS与连续闭眼计时单帧判定没有意义因为人每分钟要眨眼15-20次每次闭眼100-150毫秒这些都应视为正常行为。PERCLOS的定义是单位时间内眼睛闭合时间所占的比例经典P80标准指眼睑遮盖瞳孔面积超过80%的时间比例。将每帧判定结果存入一个队列按时间窗口统计实现起来并不复杂from collections import deque class FatigueMonitor: def __init__(self, fps20, window_sec60, p_th0.6): self.fps fps self.history deque(maxlenint(fps * window_sec)) self.p_th p_th self.continuous_frames 0 def update(self, p_close): closed int(p_close self.p_th) self.history.append(closed) # 连续闭眼计时一旦睁眼就清零 if closed: self.continuous_frames 1 else: self.continuous_frames 0 perclos sum(self.history) / len(self.history) closed_seconds self.continuous_frames / self.fps return closed, perclos, closed_secondsdeque(maxlenN)在这里起了关键作用队列满后新元素进队会自动弹出最旧元素天然维护“最近60秒”的数据你不用手动清理过期帧。perclos是闭眼帧数除以窗口内总帧数窗口越长指标越平滑但反应越慢60秒是疲劳检测论文里的常见取值。连续闭眼计时单独维护一个计数器闭眼帧连续累加任意一帧睁眼就归零这个计数能捕捉“单次闭眼超过1.5秒”的微睡眠事件这类事件即使PERCLOS还没超标也已经有足够风险值得报警。4.3 分级预警参数与状态机疲劳预警不应该只有“响”和“不响”两个状态应该分成提醒和强警报两级给驾驶员不同强度的反馈。下面这组参数可以作为初始值具体数值要配合第5章的标定方法调整参数初始值含义p_th0.6单帧闭眼判定阈值window60秒PERCLOS统计窗口perclos_warn0.3触发“提醒”的PERCLOS下限perclos_alarm0.4触发“强警报”的PERCLOS下限closed_alarm1.5秒单次连续闭眼超过该值直接强警报最少持续帧数5帧状态切换的防抖条件状态迁移用一段简单逻辑就能覆盖state 0 # 0正常 1提醒 2强警报 _, perclos, closed_seconds monitor.update(p_close) if perclos perclos_alarm or closed_seconds closed_alarm: candidate_state 2 elif perclos perclos_warn: candidate_state 1 else: candidate_state 0 # 防抖新状态要连续5帧成立才真正切换 if candidate_state state: state_hold 0 else: state_hold 1 if state_hold 5: state candidate_state state_hold 0防抖逻辑能避免因为一两帧的误检导致状态在提醒和正常之间反复横跳。5帧的取值对应约0.25秒既不会让预警卡顿也能过滤掉偶发噪声。4.4 想用EAR几何法做对照验证时怎么办如果你在毕业论文里想同时呈现“CNN分类”和“经典EAR特征”两种方案做对比需要额外接入dlib的68点人脸关键点检测。EAREye Aspect Ratio由眼周6个关键点计算公式是垂直距离与水平距离的比值代码实现如下def eye_aspect_ratio(landmarks, idx_range): p1 landmarks[idx_range[0]] p2 landmarks[idx_range[1]] p3 landmarks[idx_range[2]] p4 landmarks[idx_range[3]] p5 landmarks[idx_range[4]] p6 landmarks[idx_range[5]] vertical (np.linalg.norm(p2 - p6) np.linalg.norm(p3 - p5)) / 2.0 horizontal np.linalg.norm(p1 - p4) return vertical / horizontal这里idx_range对dlib 68点模型来说是左眼36到41或右眼42到47EAR正常值在0.25-0.35之间低于0.25视为闭眼。但需要注意MTCNN输出的5个关键点没有上睑和下睑的点位算不出EAR所以走EAR对比路线时检测器要换成dlib或能输出15点的人脸模型。EAR的麻烦在于对头部姿态非常敏感低头时眼睑距离被压缩数值会系统性偏低容易产生假阳性。4.5 语音播报预警强警报需要触发语音提示让驾驶员听到明确指令而不是只看屏幕。用pyttsx3是最快的方案import pyttsx3 engine pyttsx3.init() engine.setProperty(rate, 180) # 语速调快一点避免长篇播报分散注意力 engine.say(检测到疲劳请立即停车休息) engine.runAndWait()pyttsx3在Windows上依赖pywin32首次初始化报错时先pip install pywin32macOS和Linux则依赖espeak或nsss。语音播报放在状态机判定为强警报的分支里提醒级别只做界面闪烁避免频繁打扰。5. 用视频回放复现疲劳检测全流程标定阈值并生成答辩图表5.1 用固定视频替代摄像头保证可复现调试阶段不要每次都对着摄像头做动作因为每次的动作时间、幅度都不一致阈值调完没法复现。我一般会先录一段包含“正常驾驶-频繁眨眼-强制闭眼3秒-恢复”四个阶段的测试视频然后用配置文件切换输入源cap cv2.VideoCapture(fatigue_sample.mp4) # 改成视频文件路径在测试视频的旁边放一个同名的标注文件记录真实疲劳事件的时间区间。跑完检测后把每帧的p_close、perclos、closed_seconds和预警状态存成CSV跟标注时间戳对比就能算出检测延迟和误报次数。这个过程是答辩时最有力的证据比口头说“准确率还行”有用得多。5.2 画出p_close曲线与预警触发点光看控制台输出的数字不够直观把时间序列画出来阈值是否合理一眼就能看出来。我习惯在测试后生成一张完整的曲线图横轴是时间秒数纵轴是闭眼概率同时把预警触发点标在图上import matplotlib.pyplot as plt import numpy as np # p_close 是每帧的闭眼概率alerts 是同长度的状态序列(0/1/2)fps为实际帧率 t np.arange(len(p_close)) / fps alerts np.array(alerts) warn_idx np.where(alerts 1)[0] alarm_idx np.where(alerts 2)[0] plt.figure(figsize(10, 4)) plt.plot(t, p_close, lw0.8, labelp_close) plt.axhline(0.6, colorred, ls--, lw0.8, labelthreshold0.6) plt.scatter(t[warn_idx], np.array(p_close)[warn_idx], s16, colororange, labelwarn) plt.scatter(t[alarm_idx], np.array(p_close)[alarm_idx], s16, colorred, labelalarm) plt.xlabel(time (s)) plt.ylabel(p_close) plt.legend() plt.savefig(fatigue_curve.png, dpi200, bbox_inchestight)从曲线上能直接读出两个关键指标一是从真实闭眼开始到预警触发点之间的延迟时间二是在正常眨眼阶段有没有误报。这两项一个是召回率一个是误报率答辩时老师问“你阈值怎么定的”把图上高亮的预警点指出来说“p_close超过0.6且持续1.5秒才报警”比念概念有说服力。5.3 标定疲劳状态的实用技巧建一个三人小样本标定流程让三个人分别坐在摄像头前每人做三组动作——正常眨眼、低头看手机、故意闭眼不同时长。低头的动作很重要因为低头时双眼区域会明显下移裁剪出的patch可能只含有额头模型输出不可信需要用鼻子关键点相对人脸框的位置做头部姿态粗判断鼻尖相对人脸框中心下移超过20%就判定为低头暂时跳过疲劳统计。这个处理能让系统在驾驶员频繁低头换挡、看方向盘时不出误报。标定阈值时给每个候选阈值组合p_th、perclos_alarm、closed_alarm各跑一遍测试视频统计延迟时间和误报次数。优先保证强警报不延迟超过0.5秒再梯度放宽误报接受度。我一般会再让一个人专门做“低头、侧脸、眯眼”三组动作各10秒如果p_close曲线能在这三组动作下和真闭眼明显分开这套参数才敢拿去跑正式测试。本文还有配套的精品资源点击获取
返回列表