尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于CNN的人脸识别疲劳检测系统实战:从关键点提取到PERCLOS预警

基于CNN的人脸识别疲劳检测系统实战:从关键点提取到PERCLOS预警 简介这是一份基于Python卷积神经网络实现人脸识别驾驶员疲劳检测与预警的毕业设计项目面向计算机相关专业需要完成课程设计、大作业或毕业设计的学生也适合想进行项目实战练习的开发者。系统围绕卷积神经网络与SSD目标检测方法搭建涵盖人脸检测、疲劳状态判断与实时预警等环节能够帮助读者理解从模型训练、权重调用到摄像头/视频检测的完整流程。压缩包共37个文件以Python源码为主16个py同时包含编译后的pyc文件、训练得到的pth权重文件、数据集压缩包、jpg测试图片及说明文档等整体约500MB目录内还提供权重与数据集、介绍文本、代码模块等分类便于按需查阅。已有182人学习下载配套内容适合对照源码逐步调试、替换数据集进行复现也可作为毕业设计或项目报告的实践基础。1. 疲劳检测不是看“眼睛闭没闭”CNN 要处理的是帧序列问题基于Python卷积神经网络的人脸识别疲劳检测系统拆开看其实是三个固定环节人脸关键点定位、用CNN对眼部嘴部区域做状态分类、再对连续帧做统计判定。很多人把精力全压在第二个环节把CNN准确率调得很高接进摄像头后依然误报满天飞因为疲劳检测的落地点从来不是单帧分类而是闭眼持续时长、眨眼频率、PERCLOS这类时序指标。这篇把三个环节串起来讲清楚包括数据怎么采、模型怎么设、阈值怎么调适合准备毕业设计或第一次想把计算机视觉算法跑成实时预警系统的人。2. 人脸检测与眼部关键点提取从视频帧到 CNN 输入样本2.1 为什么放弃 Haar 和 OpenCV DNN选 MediaPipe FaceMesh标题里的“人脸识别”在驾驶员场景下并不是身份识别而是“人脸定位”。驾驶员是谁不重要重要的是能不能稳定拿到眼睛和嘴巴的位置。最早的做法是 Haar 级联分类器配置简单、CPU 上跑得动但侧脸低头时检测框会乱跳闭眼时眼框检测的稳定性也会受到影响。OpenCV 自带的 DNN 人脸检测器能给出一个稳定的脸框可惜只给框不给眼嘴关键点后续还要再套一层关键点网络。常见的可靠方案是用 MediaPipe FaceMesh它一次推理输出 468 个归一化关键点眼睑、虹膜、嘴唇轮廓都有。它的检测和跟踪是分开的跟踪模式下对连续帧开销很小而且refine_landmarksTrue时眼部轮廓点的精度会更高。这套路线的好处是裁剪出来的眼部区域图可以直接作为 CNN 输入省去二次坐标变换的复杂度。不要用cv2.CascadeClassifier做最后的方案它适合当课程实验不适合疲劳检测因为尺度变化和闭眼瞬间的检测失败会直接污染后续的 PERCLOS 统计。MediaPipe 虽然首次运行要加载模型文件但模型是打包在库里的不需要额外处理。2.2 关键点裁剪代码把眼睛和嘴部区域送进网络import cv2 import mediapipe as mp mp_face_mesh mp.solutions.face_mesh face_mesh mp_face_mesh.FaceMesh( static_image_modeFalse, max_num_faces1, refine_landmarksTrue, min_detection_confidence0.5, min_tracking_confidence0.5, ) LEFT_EYE [33, 160, 158, 133, 153, 144] RIGHT_EYE [362, 385, 387, 263, 373, 380] MOUTH [61, 291, 39, 269, 0, 17, 68, 292] def crop_region(frame, landmarks, indices, pad_ratio0.3, size48): h, w frame.shape[:2] pts [(landmarks[i].x * w, landmarks[i].y * h) for i in indices] xs [p[0] for p in pts] ys [p[1] for p in pts] x0, x1 min(xs), max(xs) y0, y1 min(ys), max(ys) bw (x1 - x0) * (1 pad_ratio) bh (y1 - y0) * (1 pad_ratio) cx, cy (x0 x1) / 2, (y0 y1) / 2 x0 int(max(0, cx - bw / 2)) y0 int(max(0, cy - bh / 2)) x1 int(min(w, cx bw / 2)) y1 int(min(h, cy bh / 2)) crop frame[y0:y1, x0:x1] crop cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) return cv2.resize(crop, (size, size))FaceMesh 输出的x, y是归一化坐标范围 0 到 1所以必须乘上当前帧的宽高。裁剪时pad_ratio0.3不是随意定的闭眼状态下上下眼睑关键点会挤在一起按紧包围盒裁剪会得到一条几乎看不见纹理的窄条CNN 容易把它当成背景噪声扩大 30% 后眼窝、眉骨和皮肤纹理进入画面分类器才能真正学到“闭合状态”的视觉特征。左右眼建议分开裁剪不合并成一张双眼图。原因是疲劳检测需要分别跟踪单眼的闭合情况而且左右眼分开后相当于训练样本量直接翻倍。嘴巴包围盒用嘴唇内圈加外圈的点打哈欠时上下嘴唇距离拉大pad_ratio可以给到 0.2避免把下巴和鼻子都裁进去。2.3 半自动标注把视频帧变成 CNN 的训练集数据集的构建是这类项目里最耗时的一步。一张一张手工截图不现实常见做法是先写一个采集脚本摄像头录制约 20 分钟的视频每 3 帧调用一次上面的crop_region自动把左右眼和嘴巴区域图落盘。文件名带时间戳方便后续回溯。闭眼和打哈欠的样本不会自然出现在普通驾驶片段里需要受试者有意识地做动作闭眼 2 到 3 秒、睁眼、再闭眼重复多组哈欠则做 3 到 5 次自然的张嘴动作。录制时把“睁眼闭眼”“正常嘴张嘴”分开成不同目录再通过一个简单的预览脚本逐张确认。我更倾向于不用 EAR 自动标注后再人工纠错因为自动标注的错误标签会直接污染 CNN 训练而疲劳检测对标签噪声非常敏感。最容易被忽略的是数据划分。训练集和验证集不能随机切分同一段视频里相邻帧几乎一样随机切分会出现“验证集里都是训练集帧的轻微变化”准确率虚高到 0.99接进实时摄像头立刻打回原形。正确做法是按录制的视频片段划分比如前 70% 时间属于训练后 30% 属于验证或者不同录制场景分到不同集合。每个类别建议至少 800 张数据不够时用随机翻转和亮度抖动扩增。3. 基于 PyTorch 的 CNN 状态分类模型结构、数据加载与训练参数3.1 轻量 CNN 结构卷积核、填充、池化的设定逻辑疲劳检测里的 CNN 不需要做 ImageNet 级别的分类输入是 48x48 的灰度眼部图分类目标只有睁眼和闭眼。用 ResNet 这类深网络反而会过拟合而且答辩时不容易把每一层的作用讲清楚。一个三层卷积的轻量网络足够代码也完全可控import torch import torch.nn as nn class FaceStateCNN(nn.Module): def __init__(self, num_classes2): super(FaceStateCNN, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 48x48 nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 24x24 nn.Conv2d(32, 64, kernel_size3, padding1), # 24x24 nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 12x12 nn.Conv2d(64, 128, kernel_size3, padding1), # 12x12 nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 6x6 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 6 * 6, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))三个卷积层全部用了kernel_size3, padding1这样卷积操作本身不改变特征图尺寸真正让尺寸减半的是MaxPool2d(2)。池化的步长取 2每次下采样都让网络看到更全局的信息第一层关注眼睫毛和皮肤条纹第二层开始组合出眼裂轮廓第三层提取的是“这是一个闭合眼缝”的整体判断。BatchNorm 放在卷积和 ReLU 之间它能稳定训练时的梯度分布让模型不至于对光照突变过于敏感。最后的全连接层输出 2 个数对应睁眼和闭眼两个类别的 logits训练时接CrossEntropyLoss推理时取argmax。这里的参数设定是有意为之输入 48x48 而不是 224x224因为闭眼判断的核心信息集中在眼裂局部区域缩小输入能大幅降低推理延迟卷积核固定 3x3两个 3x3 的堆叠效果相当于一个 5x5 的感知区域但参数量更少。这个网络跑在 CPU 上单帧推理通常只有几毫秒接实时摄像头完全够用。3.2 数据加载器与预处理 Pipelineimport os from PIL import Image from torch.utils.data import Dataset, DataLoader from torchvision import transforms class StateDataset(Dataset): def __init__(self, root, transformNone): self.samples [] self.label_map { eye_open: 0, eye_close: 1, mouth_normal: 0, mouth_open: 1, } for class_name in os.listdir(root): class_dir os.path.join(root, class_name) if not os.path.isdir(class_dir): continue label self.label_map[class_name] for file_name in os.listdir(class_dir): if file_name.lower().endswith((.jpg, .png, .jpeg)): self.samples.append((os.path.join(class_dir, file_name), label)) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(L) if self.transform: img self.transform(img) return img, label transform transforms.Compose([ transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ])数据集的目录结构就是data/eye/eye_open、data/eye/eye_close以及类似的data/mouth目录。这个StateDataset同时服务眼部和嘴部两个分类任务目录名里的关键字决定标签不用为每个任务单独写一套加载逻辑。灰度图只保留一个通道减少计算量也避免颜色对模型产生错误依赖。RandomHorizontalFlip是左右眼区域图里很安全的增强方式因为眼睛的上下眼睑结构在水平镜像下语义不变。ColorJitter模拟白天不同光照条件这对车载摄像头场景尤其重要。训练时DataLoader里建议设置num_workers2让图像解码和增强在子进程完成避免训练循环等 CPU。3.3 训练参数、验证方式与过拟合排查from torch.utils.data import random_split dataset StateDataset(data/eye, transformtransform) train_set, val_set random_split(dataset, [0.8, 0.2]) model FaceStateCNN(num_classes2) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.1) train_loader DataLoader(train_set, batch_size32, shuffleTrue, num_workers2) val_loader DataLoader(val_set, batch_size64, shuffleFalse, num_workers2) for epoch in range(12): model.train() for imgs, labels in train_loader: preds model(imgs) loss criterion(preds, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() model.eval() val_correct 0 with torch.no_grad(): for imgs, labels in val_loader: preds model(imgs).argmax(dim1) val_correct (preds labels).sum().item() print(fepoch {epoch 1}: val_acc{val_correct / len(val_set):.4f}) torch.save(model.state_dict(), eye_state_cnn.pt)训练参数和当前场景是匹配的batch_size 32 适中Adam 初始学习率 1e-3每 5 个 epoch 缩小到原来的 0.1 倍总共 12 个 epoch。这个配置在小数据集和小模型上通常能收敛。训练时model.train()让 BatchNorm 和 Dropout 处于训练模式验证时必须切换到model.eval()否则 BatchNorm 会使用当前 batch 的统计量而不是全局统计量造成验证集准确率虚高或者波动。参数设定值说明输入尺寸48x48 灰度降低推理成本保留眼裂局部信息卷积核3x3, padding1保持特征图尺寸堆叠扩大感受野池化MaxPool2d(2)步长 2空间尺寸逐层减半Dropout0.5放在全连接层前抑制过拟合优化器Adam, lr0.001收敛快适合小模型学习率调度StepLR, step5, gamma0.1后期缩小步长精调权重轮数12过多会过拟合按验证集早停训练时最容易出问题的不是模型结构而是数据划分。前面说的按视频片段划分必须在这个环节落实random_split只是示例实际项目中它会泄漏相邻帧。另一个排查点是类别不平衡如果闭眼样本只有 300 张而睁眼有 1200 张模型会倾向于把易混淆样本判成睁眼。此时看sklearn.metrics.classification_report里的宏平均 F1 而不是 accuracy。嘴部模型用同一个FaceStateCNN类再训练一份即可两个状态分类器独立部署比硬塞到一个多任务网络里更容易排查问题。4. 帧序列疲劳判定PERCLOS、连续闭眼与哈欠的联合预警4.1 从单帧概率到疲劳指标PERCLOS 的计算方式CNN 输出的只是当前帧眼部闭合概率疲劳检测需要的是“时间尺度上的统计量”。目前业内认可度最高的指标是 PERCLOS定义是单位时间内眼睛闭合时间所占的比例常用的标准是 P70眼睑遮挡瞳孔面积超过 70% 就算闭合状态。用 CNN 做近似时把闭眼类别概率大于 0.7 的帧视为闭合帧统计最近 60 秒内闭合帧占比就得到当前 PERCLOS 值。单靠 PERCLOS 不够。驾驶员偶尔看导航或低头捡东西也会产生闭眼帧但这些动作持续时间短、频率低。所以成熟的判定逻辑一般是四个指标联合PERCLOS、连续闭眼秒数、眨眼频率、哈欠频率。眨眼频率低说明警觉度下降连续闭眼超过 2 秒是微睡眠的明显信号哈欠则通过嘴部模型输出的张嘴概率来判断。需要注意闭眼帧的判定阈值和 PERCLOS 的预警阈值是两个不同参数。前者决定每一帧被归为哪一类后者决定疲劳程度是否触发预警。混淆这两个阈值是新手最常见的错误结果就是调参时怎么调都不顺。4.2 基于时间窗的疲劳判定代码import time from collections import deque class FatigueMonitor: def __init__(self, fps25): self.fps fps self.eye_close_history deque(maxlenfps * 60) self.eye_recent deque(maxlenfps * 10) self.yawn_history deque(maxlenfps * 15) self.blink_stamps deque() def update(self, eye_close_prob, mouth_open_prob, nowNone): now now if now is not None else time.time() eye_close eye_close_prob 0.7 mouth_open mouth_open_prob 0.7 prev_close self.eye_close_history[-1] if self.eye_close_history else False self.eye_close_history.append(eye_close) self.eye_recent.append(eye_close) self.yawn_history.append(mouth_open) if eye_close and not prev_close: self.blink_stamps.append(now) while self.blink_stamps and now - self.blink_stamps[0] 60: self.blink_stamps.popleft() consecutive_close 0 for v in reversed(self.eye_recent): if v: consecutive_close 1 else: break perclos sum(self.eye_close_history) / len(self.eye_close_history) yawn_ratio sum(self.yawn_history) / len(self.yawn_history) blink_per_min len(self.blink_stamps) close_seconds consecutive_close / self.fps return self.warning_level(perclos, close_seconds, blink_per_min, yawn_ratio) def warning_level(self, perclos, close_seconds, blink_per_min, yawn_ratio): score 0 if perclos 0.3: score 1 if perclos 0.4: score 2 if close_seconds 1.0: score 1 if close_seconds 2.0: score 2 if blink_per_min 8: score 1 if yawn_ratio 0.5: score 1 if score 4: return warning if score 2: return prompt return normaldeque(maxlenfps * 60)维护了一个滚动窗口新帧进入时旧帧自动弹出sum / len直接得到闭眼帧在最近 60 秒内的占比这就是 PERCLOS。连续闭眼时长单独用eye_recent计算它只保留最近 10 秒的帧避免每次 update 都从 1500 帧里倒序扫描。眨眼事件的判定条件是“上一帧为睁眼、当前帧为闭眼”也就是由睁到闭的跳变。blink_stamps只存跳变发生的时间戳并在每次 update 时把超过 60 秒的旧时间戳从队首弹出这样len(self.blink_stamps)就是最近一分钟的眨眼次数不需要额外维护计数字段。4.3 预警分级表与报警触发级别触发逻辑建议动作normalscore 2无动作promptscore 2界面提示“检测到困意”可播放轻提示音warningscore 4弹窗 短促蜂鸣要求驾驶员休息报警动作建议放到异步线程里执行因为winsound.Beep是阻塞调用直接放在主循环里会卡住画面采集。一个可用写法是import threading import winsound def beep_async(freq1200, duration300): threading.Thread(targetlambda: winsound.Beep(freq, duration)).start()非 Windows 环境下没有winsound可以用print(\a)触发终端响铃但很多终端默认关闭了响铃只适合调试。实际部署建议准备一个常见的 wav 音频文件用pygame.mixer播放。预警界面则用 OpenCV 在视频帧顶部画红色边框并叠加level文本这样截图记录时也能看到触发状态。多级预警的意义在于不是每次困意都要用刺耳声音打断驾驶员轻微的提示反而更容易让人接受。5. 实时预警落地的四个调试技巧降误报、提速与自检5.1 抑制单帧误报EMA 平滑与阈值迟滞CNN 对单帧的分类错误无法完全避免直接用硬阈值切分会造成 PERCLOS 波动。常见做法是对闭眼概率做指数移动平均ema_close 0.0 def is_close(prob): global ema_close ema_close 0.8 * ema_close 0.2 * (1.0 if prob 0.7 else 0.0) return ema_close 0.5这里的0.8和0.2意味着当前帧只贡献 20% 的权重历史帧占 80%。再加上迟滞从睁眼进入闭眼需要 EMA 超过 0.5从闭眼恢复需要降到 0.3 以下避免临界值附近的反复横跳。迟滞区间设计好后单帧误报对 PERCLOS 的污染会大幅下降。5.2 CPU 推理提速动态量化与线程分离如果整条链路跑不满 25 FPS先不要换电脑优先做两件事。第一把模型切换到推理模式并去掉梯度计算model.eval()配合with torch.no_grad()是基础操作。第二用 PyTorch 动态量化压缩全连接层model_quantized torch.quantization.quantize_dynamic( model, {nn.Linear}, dtypetorch.qint8 )动态量化不需要校准数据模型加载后可直接调用适合追求低部署成本的小项目。注意它只量化Linear层卷积层在 CPU 上仍然按浮点计算所以提速效果视全连接层占比而定。这个轻量网络里全连接层占了一半参数收益还是明显的。更进一步的思路是把推理放到独立线程用队列传递裁剪好的区域图主线程只负责采集画面和绘制结果这样即使某一帧偶发延迟也不会拖垮整条视频流。5.3 用 60 秒录屏自检阈值输出概率轨迹疲劳检测系统的阈值调节不能靠感觉最有效的自检方法是录一段标注了真实动作的视频把每一帧的闭眼概率和判定等级写入 CSV然后用 matplotlib 画轨迹图对照自己的动作时间轴微调阈值。import csv import matplotlib.pyplot as plt with open(fatigue_trace.csv, w) as f: writer csv.writer(f) writer.writerow([frame, eye_close_prob, level]) # 主循环里每帧追加一行 # with open(fatigue_trace.csv, a) as f: # f.write(f{frame_idx},{eye_close_prob:.3f},{level}\n) frames [] probs [] with open(fatigue_trace.csv) as f: for row in csv.DictReader(f): frames.append(int(row[frame])) probs.append(float(row[eye_close_prob])) plt.plot(frames, probs) plt.axhline(0.7, colorr, linestyle--) plt.xlabel(frame) plt.ylabel(eye_close_prob) plt.savefig(trace.png)先让受试者正常看路 30 秒再做闭眼 3 秒、打哈欠 3 次最后再正常 10 秒。跑完后看轨迹图上闭眼概率的抬升段是否和你自己的动作对应如果对应不上把闭眼判定阈值朝 0.5 或 0.8 方向调整一档重复一次。通常 2 到 3 轮PERCLOS 和连续闭眼两个最核心的阈值就能收敛到适合你采集环境的稳定值。本文还有配套的精品资源点击获取
返回列表