简介基于卷积神经网络的人脸识别驾驶员疲劳检测与预警系统毕业设计项目面向计算机相关专业准备毕设的学生及需要实战练习的学习者可直接用作课程设计或期末大作业。资源共十九个文件压缩包约78.33MB包含十一个Python源码文件覆盖数据集加载与预处理、人脸区域提取、CNN模型构建与训练、疲劳状态评估、检测报警及可视化交互界面等完整开发链路同时配有人脸检测XML、预训练HDF5模型权重、依赖清单、运行说明及配套数据集并提供可执行exe程序无需额外配置即可体验。目前已有四百九十人学习下载。项目经导师指导并严格调试运行流程完整读者可对照源码、模型与说明文档复现实验也可在tkinter界面基础上调整检测阈值、替换模型参数或扩展功能作为毕业设计与工程实战的落地参考。1. 拿到源码包先别急着解压先看这条链路缺了哪一环你下载过不少「基于卷积神经网络人脸识别驾驶员疲劳检测与预警系统」这类 Python 毕业设计源码包压缩包里通常塞着几百个 Python 文件、一堆图片和一个 README。但真正打开后最常见的状态是不知道先跑哪个文件不知道数据集怎么放更不知道报警阈值设多少才不会一分钟响八次。这套系统的核心链路其实很固定——摄像头取帧、人脸检测框出人脸、把眼睛和嘴巴区域交给卷积神经网络做状态分类再按 PERCLOS 和连续闭眼帧数触发预警。无论压缩包是谁整理的骨架都长这样。这篇笔记就按这条链路把环境、数据集、训练、实时检测和预警逐个拆开讲适合想在两周内跑通并理解全部代码的毕业生也适合想快速复用一个疲劳检测模块的开发者。2. 疲劳检测的三种信号与 CNN 介入的理由从 EAR 阈值到状态分类2.1 疲劳在视觉上呈现为三种可测量信号驾驶疲劳最直观的视觉信号有三个眨眼变慢且闭眼时间变长、频繁打哈欠、头部不自觉下垂。工程上分别对应眼睛开合程度、嘴巴张合程度和头部姿态角。传统方案里最常用的是 EAREye Aspect Ratio眼睛纵横比它通过六个关键点算出一个比值垂直方向两个距离的平均值除以水平方向两个距离的平均值。眼睛正常睁开时 EAR 在 0.2 到 0.3 之间闭眼时会掉到 0.1 以下。嘴巴对应的是 MAR原理一模一样嘴巴张大时比值明显升高。这套几何指标的问题在于它完全依赖关键点定位的稳定性。驾驶员戴墨镜、侧脸、逆光、低头都会让关键点抖动甚至丢失EAR 一帧高一帧低阈值怎么调都别扭。很多人第一次跑通代码后会发现明明没有疲劳系统隔几秒就报警一次这就是纯阈值方案的高频误报。CNN 介入的价值在于它直接对眼睛和嘴巴的小图像做分类输出「正常 / 闭眼 / 打哈欠」这样的离散标签比连续几何值抗干扰能力强得多。CNN 在这一环的角色不是取代关键点而是把关键点给出的 ROI 区域再做一次语义判断。2.2 为什么选 CNN个体差异、光照与遮挡如果只用 EAR 阈值还有一个无法绕开的个体差异问题单眼皮、细长眼、贴双眼皮贴的人睁眼 EAR 本来就低按通用阈值会被一直判成闭眼。传统图像处理里也有其他招比如肤色分割、虹膜检测但这些方法对光照极其敏感车里的光线从晴天到隧道、从白天到夜间变化范围非常大手写特征根本覆盖不过来。CNN 是从大量标注样本里自动学特征的同一批数据里如果有各种眼型、各种光照条件下的样本它能把「看着像闭眼但其实是细长眼」这类情况学进去泛化能力比手工阈值高一个量级。选型上也别一上来就堆大网络。疲劳分类任务本质上是两三类的图像分类输入是几十像素的小图ResNet 级别的深度在这里属于浪费。我一般会优先用 MobileNetV2 或者自己搭一个四层小卷积网络参数少、推理快CPU 上单帧分类能在几毫秒内完成。需要说明的是标题里的「人脸识别」在实际毕设实现里通常被拆成两个部分先用一个人脸检测模型框出驾驶员人脸再用 CNN 对眼睛嘴巴区域做疲劳状态分类。部分完成度高的系统还会加一个身份确认前置步骤用 CNN 提取人脸特征向量确认是注册过的司机本人后才启动疲劳监控避免副驾驶的人脸干扰判定。2.3 整体架构检测、分类、统计、报警四个模块下面的表是这类系统最常见的组件划分不管源码包里文件命名有多乱最终落地结构基本都能归到这几类。模块作用常见选型运行频率人脸检测从摄像头帧中框出人脸位置OpenCV DNN、MTCNN、dlib HOG每帧关键点定位定位眼睛和嘴巴的 68 个关键点dlib 68 landmarks、PFLD每帧CNN 状态分类判断眼睛闭合状态和嘴巴哈欠状态MobileNetV2、小型自定义 CNN每帧时序统计在滑动时间窗口内统计闭眼比例和哈欠频率PERCLOS、连续帧计数每秒汇总预警声音报警、画面边框提示、截图留证playsound、OpenCV 绘图触发时我见过太多人拿到源码后直接跳到最后一步运行主程序结果模型加载报错、数据集路径对不上、摄像头打不开然后回头一句「代码有问题」。实际上每个模块各有一条相对独立的调试路径按顺序跑通才是省时间的做法。下一章就从环境配置开始把这套链路里最耗时间的部分先讲透。3. 从零跑通训练链路环境、数据集与第一个分类模型3.1 Python 版本与依赖库安装的稳妥选择这类毕设项目对 Python 版本比较挑剔最常见的问题是 dlib 装不上。dlib 在 Windows 上需要先有 CMake 和 Visual Studio C 生成工具很多人卡在这一步就直接放弃。我的建议是如果你不需要自己训练关键点模型跳过 dlib用 OpenCV 自带的 LBF 关键点模型代替或者直接换用基于 OpenCV DNN 的人脸检测加形状预测。依赖清单控制在下面这个范围兼容性最好python -m venv .venv # Windows 下激活虚拟环境.venv\Scripts\activate # Linux / macOS 下激活source .venv/bin/activate pip install --upgrade pip pip install opencv-python opencv-contrib-python pip install numpy pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 需要关键点定位时再装 dlib否则跳过 pip install cmake pip install dlib # UI 与报警 pip install imutils playsound这里刻意把 PyTorch 装成 CPU 版本原因很实际疲劳检测的数据量不大CPU 上训练一个 MobileNetV2 做二三类分类一两个小时内也能收敛而 GPU 版的 CUDA 配置会消耗大量时间。至少先把整条链路跑通确认没有路径和逻辑错误再考虑换 GPU 版本提速。Python 版本建议用 3.8 到 3.10太新的版本偶尔会遇到 torchvision 和 dlib 轮子不全的问题。3.2 数据集准备采集、标注与一次性划分脚本数据集是这类项目的命门也是最容易翻车的地方。常见做法是二八开混合一部分用公开的疲劳驾驶数据集一部分自己拿摄像头录几段视频再抽帧标注。录视频时模拟正常驾驶、频繁眨眼、打哈欠三种状态每段别太长二十秒到一分钟足够。抽帧后按眼睛开闭和嘴巴张合分别标目录最终的目录结构大致长这样dataset/ train/ eye_open/ eye_closed/ mouth_normal/ mouth_yawn/ val/ eye_open/ eye_closed/ mouth_normal/ mouth_yawn/标注口径必须统一这个我会在第五章的避坑清单里细讲。拿到一批图片后不要手动分目录写一个脚本按比例随机划分最稳妥import os import random import shutil def split_dataset(source_root, target_root, val_ratio0.15, seed42): 把源目录下的每个类别随机划分为 train 和 val。 参数说明 source_root: 原始标注图片根目录每个子目录是一个类别 target_root: 目标数据集根目录会自动创建 train/val 结构 val_ratio : 验证集比例0.15 表示每类取 15% 作为验证集 seed : 随机种子固定后每次划分结果一致方便复现实验 random.seed(seed) for class_name in os.listdir(source_root): class_dir os.path.join(source_root, class_name) if not os.path.isdir(class_dir): continue images [f for f in os.listdir(class_dir) if f.lower().endswith((.jpg, .jpeg, .png))] random.shuffle(images) val_count int(len(images) * val_ratio) for split_name in (train, val): os.makedirs(os.path.join(target_root, split_name, class_name), exist_okTrue) for i, img_name in enumerate(images): split_name val if i val_count else train src os.path.join(class_dir, img_name) dst os.path.join(target_root, split_name, class_name, img_name) shutil.copyfile(src, dst) print(f{class_name}: total{len(images)} train{len(images)-val_count} val{val_count}) if __name__ __main__: # 按需改成自己的路径 split_dataset(dataset_raw, dataset)这段脚本做的事情是按类别把图片随机打散每类固定取 15% 作为验证集其余进训练集。有两个细节值得注意一是随机种子固定为 42这样多次运行划分结果一致训练时对比实验才有意义二是用 copyfile 而不是 move保留原始标注目录后面发现标注错了还能追溯。图片放进训练目录之前最好统一尺寸比如全部缩放到 48×48 或 64×64太小丢细节太大浪费算力。如果懒于自采数据也可以找公开数据集来补充。你拿到的压缩包里如果自带数据集先看它的目录分类是不是也是按 eye_open、eye_closed 这类方式组织的如果不是可以先跑一遍转换脚本把它归拢到上面这种结构再执行划分脚本。模型对数据分布很敏感训练集的样本构成直接影响实时检测时的表现这一步值得多花时间。3.3 训练一个最小可用的 CNN 状态分类器数据集就绪后训练脚本是下一个关键节点。下面这段代码用 PyTorch 写模型结构刻意保持简单方便在笔记本上复现也方便后续按需加层import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from torchvision import transforms, models from PIL import Image import os # 1. 自定义 Dataset从目录读图 class FatigueDataset(Dataset): def __init__(self, root, transformNone): self.samples [] self.labels [] self.class_names sorted(os.listdir(root)) for label, name in enumerate(self.class_names): class_dir os.path.join(root, name) for img_name in os.listdir(class_dir): if img_name.lower().endswith((.jpg, .jpeg, .png)): self.samples.append(os.path.join(class_dir, img_name)) self.labels.append(label) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): image Image.open(self.samples[idx]).convert(RGB) if self.transform: image self.transform(image) return image, self.labels[idx] # 2. 数据增强训练集轻度扰动提升泛化能力 train_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomRotation(5), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) # 3. 加载数据 train_ds FatigueDataset(dataset/train, train_transform) val_ds FatigueDataset(dataset/val, val_transform) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers0) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers0) # 4. 迁移学习用 MobileNetV2 预训练权重替换最后一层分类头 model models.mobilenet_v2(weightsmodels.MobileNet_V2_Weights.DEFAULT) num_classes len(train_ds.class_names) model.classifier[1] nn.Linear(model.last_channel, num_classes) # 5. 训练配置 device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1) # 6. 训练循环每一轮同时评估验证集准确率保存最优权重 best_acc 0.0 epochs 30 for epoch in range(epochs): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() acc correct / total # 保存验证集准确率最高的权重作为上线模型 if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch1}: saved best model, val_acc{acc:.4f}) print(fEpoch {epoch1}/{epochs}, loss{running_loss/len(train_ds):.4f}, val_acc{acc:.4f}) scheduler.step()这段代码有几个参数值得细说。学习率用 0.001 而不是更大因为迁移学习的预训练卷积层本身就接近收敛学习率太大会把学好的特征冲掉。StepLR 每 10 轮把学习率降 10 倍让后期收敛更稳。batch_size 设 32 是兼顾显存和收敛速度的选择显存紧张就降到 16。epochs 设 30 是经验值多数情况下第 15 到 20 轮验证集准确率就进入平台期早停可以帮你省时间。数据增强里加了 5 度的随机旋转和亮度对比度扰动这是为了让模型适应摄像头角度和车内光照变化。判断训练是否正常看两个信号训练 loss 是否平稳下降验证集准确率是否在 90% 以上。如果你的任务是四分类睁眼、闭眼、正常嘴、哈欠嘴验证集四分类准确率到 90% 就已经够用。如果只有 70%别急着调结构先回数据集看看标注是不是混入了大量模糊样本。训练结束后best_model.pth就是后面接入实时检测的模型。4. 实时疲劳检测与预警系统三个阈值参数决定误报率4.1 检测主循环人脸框、关键点与 CNN 分类的协作方式模型训练好后下一步是把离线分类搬进实时视频流。整个过程是一个每帧重复的流水线读取摄像头画面、缩小尺寸、人脸检测器框出人脸、关键点定位提取眼睛和嘴巴 ROI、把 ROI 缩放后交给 CNN 分类、把分类结果推进一个滑动窗口统计。核心循环用 OpenCV 实现代码骨架如下import cv2 import torch import numpy as np from torchvision import transforms from collections import deque # 这里的 detector 用 OpenCV DNN 加载人脸检测模型加载方式随权文件格式略有差异 # 常见做法cv2.dnn.readNetFromCaffe(proto, caffemodel) 或 readNetFromONNX face_detector cv2.dnn.readNetFromCaffe(deploy.prototxt, res10_300x300_ssd_iter_140000.caffemodel) # 加载上一章训练好的 CNN 状态分类器 model models.mobilenet_v2(weightsNone) model.classifier[1] nn.Linear(model.last_channel, 4) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) # 滑动窗口保存最近 150 帧的状态序列用于时序统计 eye_state_buffer deque(maxlen150) mouth_state_buffer deque(maxlen150) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break # 人脸检测输入缩放提升速度输出人脸框坐标 h, w frame.shape[:2] blob cv2.dnn.blobFromImage(frame, 1.0, (300, 300), (104.0, 177.0, 123.0)) face_detector.setInput(blob) detections face_detector.forward() face_box None for i in range(detections.shape[2]): confidence detections[0, 0, i, 2] if confidence 0.6: box detections[0, 0, i, 3:7] * np.array([w, h, w, h]) x1, y1, x2, y2 box.astype(int) x1, y1 max(0, x1), max(0, y1) x2, y2 min(w, x2), min(h, y2) face_box (x1, y1, x2, y2) break if face_box is None: # 没有检测到人脸时不更新状态缓冲等待下一帧 cv2.imshow(Driver Fatigue Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break continue x1, y1, x2, y2 face_box face_img frame[y1:y2, x1:x2] # 在实际项目中嘴巴和眼睛 ROI 由关键点坐标裁剪 # 这里用近似位置演示取人脸下半部分为嘴巴区域上半部分为眼睛区域 eye_roi face_img[0:int((y2-y1)*0.4), :] mouth_roi face_img[int((y2-y1)*0.55):int((y2-y1)*0.9), :] # 分别送入 CNN 做分类得到 0睁眼/1闭眼0正常嘴/1哈欠 with torch.no_grad(): eye_input transform(eye_roi).unsqueeze(0) mouth_input transform(mouth_roi).unsqueeze(0) eye_pred torch.argmax(model(eye_input)).item() mouth_pred torch.argmax(model(mouth_input)).item() eye_state_buffer.append(eye_pred) mouth_state_buffer.append(mouth_pred) # 到这里每帧的状态已经更新下一小节接入阈值判断与报警 cv2.imshow(Driver Fatigue Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码把两个循环写清楚了外层循环读取每一帧画面内层循环对同一帧的多个候选框做置信度筛选。几个参数需要特别说明置信度阈值 0.6 是一个比较平衡的值低于它容易把背景误检成人脸高于它可能漏掉侧脸deque(maxlen150)决定了滑动窗口容量150 帧在 30fps 下正好对应 5 秒这也是计算 PERCLOS 的时间窗口。4.2 三个核心预警参数PERCLOS、连续闭眼帧数与哈欠频率模型输出的单帧状态本身没有预警意义真正决定系统准不准的是时序统计参数。这是整套系统里最值得反复调整的部分也是毕设答辩时老师最爱追问的地方。下表列出我常用的默认参数参数含义默认值调整方向PERCLOS 阈值60 秒内闭眼帧占比0.2即 20%误报多就调高到 0.25漏报多就调低连续闭眼帧数不间歇闭眼持续帧数40 帧约 1.3 秒帧率高的摄像头按秒数换算别用固定帧数哈欠频率1 分钟内打哈欠次数5 次换季过敏期人群适当调高到 6-7 次预警冷却时间两次报警的最小间隔30 秒防止报警后状态未恢复造成重复轰炸PERCLOS 的全称是 Percentage of Eyelid Closure Over the Pupil疲劳研究里公认的指标。计算方式很简单统计一个时间窗口内闭眼帧数占总帧数的比例超过阈值就判定为疲劳。上面代码里的deque(maxlen150)保存了最近 5 秒的闭眼状态主循环每帧只要算sum(eye_state_buffer) / len(eye_state_buffer)就能得到实时 PERCLOS。连续闭眼帧数处理的是另一种场景司机在高速上瞬间睡着闭眼可能持续几秒这时候 PERCLOS 还没累计到阈值但连续闭眼已经非常危险必须立即报警。哈欠频率的判断依赖嘴部状态缓冲实现上和 PERCLOS 类似。三个参数联动时我一般这样组织判定优先级连续闭眼高于 PERCLOSPERCLOS 高于哈欠频率。也就是说连续闭眼超过帧数阈值立即进入强报警状态同时静默 30 秒冷却PERCLOS 超过阈值进入普通报警并记录截图哈欠频率作为辅助信号单独存在时降低报警等级只做文字提示。这样设计是为了避免单一几何指标误报导致司机对报警产生麻木。4.3 预警落地声音报警、画面高亮与报警截图预警模块在毕设里是最容易出彩也最容易做土的部分。常见做法是 OpenCV 画红色边框加文字配一段提示音。声音播放用 playsound 最省事但 playsound 在 Windows 上偶尔会阻塞主线程导致画面卡顿。我的替代方案是用pygame.mixer预加载音频非阻塞播放代码如下import pygame import datetime pygame.mixer.init() # 先加载一段提示音路径根据自己的资源调整加载失败时静默降级为画面提示 try: pygame.mixer.music.load(alarm.wav) sound_ready True except Exception: sound_ready False last_alert_time 0 ALERT_COOLDOWN 30 # 与参数表里的冷却时间保持一致 def trigger_alert(frame, reason, current_time): 触发报警画红框、播声音、保存截图。 参数说明 frame : 当前摄像头帧用于绘制和截图 reason : 报警原因字符串写入截图文件名方便复盘 current_time: 当前时间戳用于冷却判断 返回 True 表示这条报警已执行False 表示仍在冷却期 global last_alert_time if current_time - last_alert_time ALERT_COOLDOWN: return False # 画面提示红色边框 原因文字 cv2.rectangle(frame, (0, 0), (frame.shape[1]-1, frame.shape[0]-1), (0, 0, 255), 6) cv2.putText(frame, fFATIGUE: {reason}, (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3) # 声音提示非阻塞播放报警声持续一小段 if sound_ready: pygame.mixer.music.play() # 截图留证文件名带上时间和原因便于事后核对误报场景 ts datetime.datetime.now().strftime(%Y%m%d_%H%M%S) cv2.imwrite(falert_{ts}_{reason}.jpg, frame) last_alert_time current_time return True # 在主循环里按判定顺序调用例如 # if close_eye_frames 40: # trigger_alert(frame, close_eye, time.time())报警冷却时间是我强烈建议保留的参数。没有冷却的报警系统在真实场景里是灾难司机打个哈欠系统连续 10 秒报 10 次正常人会直接关掉程序。冷却时间设为 30 秒之后两次报警之间留出缓冲同时配合截图留证事后回查误报原因时非常有价值。截图命名带时间戳和报警原因复盘时一眼就能看出哪次报警对应什么场景这个细节在毕业答辩展示时也很加分。5. 疲劳检测项目避坑指南五个高频翻车点与修复5.1 数据与标注的坑类别不均衡、标注口径不一致现象训练时 loss 降得很快验证集准确率也很高但一到实时检测就全线崩溃——摄像头里明明睁着眼系统却持续判断为闭眼。原因这是数据层面的典型翻车。训练集里闭眼样本大多是刻意闭眼的摆拍眼睛周围肌肉状态和真实驾驶时的自然闭眼完全不一样。更常见的是标注口径前后不一致前 500 张图把「半闭眼」标成睁眼后 500 张把同样的状态标成闭眼。CNN 学到的决策边界被这些矛盾样本拉扯结果就是实时画面里的中等开合度状态被随机分类。解决重新梳理标注规则明确给出「睁眼」和「闭眼」的量化标准。我一般用 EAR 做初筛EAR 大于 0.25 标睁眼小于 0.15 标闭眼中间区域人工复核。标注完成后做一次类别分布检查确保每类样本数不要相差超过两倍类别不均衡会让模型倾向于把模糊样本全判成多数类。另外训练集里一定要保留一部分带眼镜的样本和夜间低照度样本否则这些场景必然翻车。5.2 训练层面的坑损失不降、过拟合、验证集准确率虚高现象训练了二十轮验证集准确率稳定在 95% 以上但部署到摄像头后模型对没见过的角度和光照条件表现得像随机猜测。原因这是典型的过拟合加数据分布单一。验证集和训练集来自同一次抽帧画面背景、人脸角度、光照高度一致模型学到的是「这批数据下的特征」而不是「疲劳状态的本质特征」。另一个隐藏问题是迁移学习里分类头被随机初始化后学习率过高前几轮就把预训练特征破坏掉表现为 loss 先降后升。解决验证集不要和训练集同源至少让验证集来自另一段录制视频或者换一个时间点补拍。训练策略上把分类头学习率降到 0.0001卷积层学习率降到 0.00001用分层学习率训练可以保住预训练特征。早停也很关键验证集准确率连续 5 轮不提升就停止训练避免后期过拟合。最后检查一下模型在完全没见过的视频上的表现而不是只看训练时的验证集指标。5.3 实时检测的坑人脸框抖动导致 ROI 错位现象静止的画面里人脸框上下左右轻微跳动导致裁剪出的眼睛和嘴巴区域不稳定CNN 分类结果在正常和疲劳之间反复横跳。原因人脸检测器对每一帧独立输出单帧的置信度和框位置有随机波动尤其是 OpenCV DNN 的 SSD 模型对小幅度的头部移动很敏感。ROI 错位后眼睛区域可能混入额头纹理嘴巴区域可能混入下巴分类自然不稳定。解决对人脸框做时间平滑。最简单的做法是维护一个最近 10 帧人脸框坐标的滑动平均用平滑后的坐标去裁剪 ROI。代码上只需要在检测到人脸后追加一步# 假设 x1, y1, x2, y2 是当前帧的人脸框 # box_history 是一个 dequemaxlen10每帧追加一次 box_history.append([x1, y1, x2, y2]) smooth_box np.mean(box_history, axis0).astype(int) sm_x1, sm_y1, sm_x2, sm_y2 smooth_box平滑代价是丢失一点实时性人突然转头时框会慢半拍但对于疲劳检测这种秒级判定场景完全够用。另一个有效手段是只在置信度连续 3 帧都高于阈值时才更新人脸框否则沿用上一帧的框这样单帧的误检和漏检都不会打断 ROI 稳定性。5.4 戴眼镜与墨镜闭眼识别被镜片反光干扰现象戴眼镜的受试者闭眼时镜片反光让眼睛区域出现高光纹理CNN 把闭眼状态判成睁眼戴墨镜时则相反镜片透出的暗影让睁眼被判成闭眼。原因训练集里缺少带眼镜样本时模型没有见过「镜片反光下的闭眼」和「墨镜遮挡下的睁眼」这两种视觉模式。CNN 本质上是在匹配训练时见过的纹理分布没见过的纹理只能靠猜。解决采集数据时必须有意识加入眼镜样本至少占到 20%。如果项目时间紧也可以做一个预处理对眼睛 ROI 做直方图均衡化压低镜片反光的高光区域和墨镜的暗部对比度。均衡化后再送进 CNN分类稳定性会明显提升。另外可以把 EAR 作为兜底信号当 CNN 输出与 EAR 矛盾时以更保守的结果为准——也就是只要 CNN 判闭眼或 EAR 低于闭眼阈值就按闭眼计入统计降低漏报优先。5.5 摄像头权限、路径与环境的坑主程序跑不起来现象双击运行主程序报错信息是cv2.VideoCapture(0) 返回 False或者模型权重文件路径找不到或者虚拟环境里ModuleNotFoundError。原因毕业后代码交接最常见的问题环境没激活就运行、绝对路径写的是作者的电脑路径、摄像头索引不是 0笔记本自带摄像头可能是 1外接 USB 摄像头才是 0、OpenCV 和 PyTorch 装进了不同环境。解决路径一律改成脚本所在目录的相对路径用os.path.join(os.path.dirname(__file__), models, best_model.pth)拼接。摄像头索引先写一个探针脚本循环尝试 0、1、2 三个索引打印能打开的第一个。依赖列表锁进requirements.txt并在 README 里写明先激活虚拟环境再安装依赖。这类问题九成能靠这三步解决别急着怀疑代码逻辑。6. 进阶模型导出加速与端到端真实验证6.1 用 ONNX 导出模型摆脱 PyTorch 运行时依赖训练好的 PyTorch 模型推理时依赖完整 torch 库启动慢、占用内存高。实际部署时我一般会把模型导出成 ONNX 格式再用 OpenVINO 或 ONNX Runtime 做 CPU 推理。导出脚本很短import torch from torchvision import models model models.mobilenet_v2(weightsNone) model.classifier[1] torch.nn.Linear(model.last_channel, 4) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() # dummy_input 的尺寸必须与训练时的输入尺寸一致这里对应 64x64 的 RGB 图 dummy_input torch.randn(1, 3, 64, 64) torch.onnx.export( model, dummy_input, fatigue_model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version11 )导出操作本身没有玄学唯一值得强调的是dynamic_axes和批次维度。如果只做单帧推理可以直接去掉 dynamic_axes固定 batch 为 1。导出后拿 ONNX Runtime 跑一次确认输出结果和 PyTorch 的差距在可忽略范围内再把推理函数替换掉。OpenVINO 在 Intel CPU 上通常能比原始 PyTorch 快 2 到 3 倍单帧检测加分类整体控制在 20 毫秒以内不是难事。这一步做完整个系统的启动速度、CPU 占用和发热都会有能感知的提升。6.2 用带标签的真实驾驶视频做端到端验证毕业答辩时最硬核的展示不是现场接摄像头而是一段离线视频加上逐帧标注的对比结果。做法是录一段三到五分钟的真实驾驶模拟视频人工逐帧标注疲劳状态然后让系统离线跑一遍统计每帧判定结果与人工标注的差异算出准确率、召回率和误报次数。每一条报警都翻出对应截图人工复核确认是真疲劳还是误报把原因归类。这个验证过程能让你在答辩前就暴露大部分问题也让你对系统行为边界有清晰认知。我自己的习惯是永远保留报警截图和日志文件因为疲劳检测这种系统单看准确率没有意义真正要命的是误报率。误报一次司机还能忍误报十次司机就会关掉系统这是比漏报更严重的工程失败。每次调完参数先跑一遍旧日志回放确认没有引入新的误报来源再上真机。这个生产线式的验证流程帮我避开了很多临时翻车希望对看到这里的你也有用。本文还有配套的精品资源点击获取
