简介:一份基于卷积神经网络CNN的疲劳驾驶识别检测完整毕业设计项目,面向计算机相关专业正在准备毕业设计、课程设计或期末大作业的学生,也适合需要项目实战练习的开发者。项目经导师指导并获评98分,覆盖从模型训练、评估到摄像头/视频实时检测的完整流程,包含SSD网络结构、损失函数、数据增强、配置与工具模块等典型代码模块。压缩包共37个文件,以Python源码(16个py)、预训练权重与模型文件(3个pth)、数据集(zip)、测试图片(jpg)、说明文档(txt)及训练日志(log)为主,整体约500.41MB,结构清晰便于二次开发。目前已有128人学习下载,可作为毕业设计答辩、算法复现或项目实战的参考蓝本,帮助快速理解卷积神经网络在疲劳驾驶检测场景中的落地实现。
1. 基于CNN的疲劳驾驶识别:从跑通模型到系统闭环,这条路有多长?
做疲劳驾驶识别检测系统的毕业设计时,最容易出现的问题是:找一个预训练模型,跑通闭眼分类,就以为任务完成。但基于卷积神经网络CNN的疲劳驾驶识别检测系统,真正卡住人的从来不是那几层卷积,而是数据是否干净、疲劳判定阈值是否合理、系统接到摄像头之后能不能在连续视频流里给出可靠报警。这篇文章从我实际完成这类项目的角度出发,把数据集划分、模型训练、疲劳判定到常见故障排查的整条链路拆开讲。适合正在做Python毕设、准备把算法从笔记本迁到实时摄像头的同学;读完后你能照着复现,也能在答辩时讲清楚每个参数背后的取舍。
2. 数据集准备与预处理:先让样本会说话,再让 CNN 学疲劳
2.1 疲劳样本从哪来:公开数据集与自采补全的取舍
拿到完整毕设项目之后,第一件事不是运行train.py,而是把 dataset 目录拆开看一眼。绝大多数疲劳驾驶识别项目的数据集都会按场景或状态分目录,常见的有睁眼、闭眼、打哈欠三类,有的还会加入正常驾驶、低头、打电话等干扰类别。先检查每个类别的样本数、图片尺寸和文件格式,把模糊帧、重复帧、标签错位的图片清掉,这件事比调参更影响最终效果。
公开数据集最大的问题是拍摄场景单一。如果整套数据都来自同一位实验者在同一个座位上录制,CNN 很容易学到“这个人的脸长什么样”,而不是“眼睛是否闭上”。我在实际项目里见过这样的情况:训练集准确率超过 97%,拿自己手机摄像头一测,频繁误报,因为肤色、光照、头部角度全部变了。所以建议在已有公开数据集的基础上,至少用手机录制十分钟自己的面部状态,每隔两帧抽一张,补充到对应目录里。自采样本不需要很多,每个类别增加 200 到 300 张就能让跨摄像头表现明显改善。
类别设计要克制。疲劳驾驶识别最常用的三类是 awake、closed_eye、yawn,目录结构如下:
| 类别标签 | 目录名 | 典型场景 | | awake | open_eye | 正常睁眼、驾驶 | | closed_eye | closed_eye | 闭眼或半闭眼 | | yawn | yawn | 打哈欠、张嘴 |
也可以改成四类,比如加入“低头看手机”,但类别越碎,需要的样本量越大。毕设答辩时,三类刚好对应三个可解释语义:清醒、瞌睡、哈欠。闭眼类别的可靠性优先级最高,因为后面计算 PERCLOS 指标依赖的是闭眼帧占比,闭眼漏标会直接让疲劳判定失真。
2.2 训练集划分:两种脚本与数据泄露的边界
很多开源项目的训练脚本简单地把所有图片打乱后按比例切分。如果数据集本身就是一堆独立图片,这种切法没问题;但如果图片是从连续视频帧里抽出来的,同一段视频的相邻帧极其相似,随机切分会让验证集里出现训练集的“近亲”,导致验证指标虚高。换到新视频上,准确率立刻下跌。这种问题叫数据泄露,它的典型表现是训练和验证准确率都在 97% 以上,真实场景却一塌糊涂。
先看一个按单帧随机切分的常见脚本,理解它的问题在哪:
import os, random, shutil from glob import glob def split_by_frame(source_root, target_root, train_ratio=0.7, val_ratio=0.15, seed=42): random.seed(seed) for label in os.listdir(source_root): label_path = os.path.join(source_root, label) if not os.path.isdir(label_path): continue images = glob(os.path.join(label_path, "*.jpg")) random.shuffle(images) train_end = int(len(images) * train_ratio) val_end = int(len(images) * (train_ratio + val_ratio)) for split_name, imgs in zip(["train", "val", "test"], [images[:train_end], images[train_end:val_end], images[val_end:]]): out_dir = os.path.join(target_root, split_name, label) os.makedirs(out_dir, exist_ok=True) for img in imgs: shutil.copy(img, os.path.join(out_dir, os.path.basename(img)))逻辑说明:先固定seed让打乱顺序可复现,然后按train_ratio和val_ratio切出训练、验证、测试三份。参数0.7/0.15/0.15是常用比例,验证集太少时,闭眼类别的少量样本可能没有被覆盖到,调阈值时看到的曲线会比较毛糙。
这个脚本的缺陷在于images列表来自某个目录下的所有图片,没有记录视频来源。如果你只有单帧图片目录,它能用;如果源数据来自视频,请换用按视频片段划分的方式。更可靠的做法是用GroupShuffleSplit,让同一次连续拍摄的帧全部留在同一边:
from sklearn.model_selection import GroupShuffleSplit import os all_images = [] clip_ids = [] for root, _, files in os.walk(source_root): for f in files: if f.endswith(".jpg"): all_images.append(os.path.join(root, f)) clip_ids.append(os.path.basename(os.path.dirname(root))) gss = GroupShuffleSplit(n_splits=1, train_size=0.7, random_state=42) train_idx, val_idx = next(gss.split(all_images, groups=clip_ids))逻辑说明:clip_ids里存的是每个样本所属的视频片段或拍摄批次 ID,GroupShuffleSplit按组划分,保证同一组的帧不会同时出现在训练集和验证集里。答辩时如果能主动说出“我避免了数据泄露”,作用比列一堆模型指标更直接,因为这体现的是工程思维。
2.3 预处理与数据增强:分辨率、归一化与闭眼样本过采样
CNN 输入尺寸直接决定训练速度和实时推理帧率。疲劳驾驶识别是细粒度图像分类,不需要特别大的分辨率。我一般把输入统一到 112x112,如果电脑性能一般,降到 64x64 也能跑,只是闭眼细节的区分度会下降。不要盲目使用 ImageNet 的 224x224,除非你有 GPU 且不在乎实时性。
预处理代码用 PyTorch 的transforms写:
from torchvision import transforms PREPROCESS = transforms.Compose([ transforms.Resize((112, 112)), transforms.Grayscale(num_output_channels=3), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])逻辑说明:Resize把任意尺寸的摄像头帧压成统一尺寸;Grayscale(num_output_channels=3)对灰度摄像头友好,同时保留三通道结构,兼容预训练模型;ToTensor把 HWC 排列的像素数组转成 CHW 张量并归一化到 0 到 1;最后的Normalize使用 ImageNet 的统计量。
参数说明:如果模型是从零训练,mean 和 std 可以改成自己数据集上算出的像素均值,迁移学习则继续沿用 ImageNet 统计值。
数据增强不宜过度。疲劳检测里闭眼是一种局部细节状态,翻转和旋转可以增强姿态鲁棒性,但旋转角度过大会让模型学习到本不存在的倒立人脸。亮度扰动用来模拟逆光、隧道光照,但也可能把闭眼的阴影抹掉。我常驻的三项增强如下:
AUGMENT = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=8), transforms.ColorJitter(brightness=0.2, contrast=0.2), ])逻辑说明:水平翻转对驾驶场景合理,因为左右眼状态语义一致;8 度旋动能覆盖头部轻微倾斜,不会制造异常姿态;亮度 0.2 的扰动足够模拟不同光线,又不至于让闭眼特征消失。
如果闭眼类样本明显偏少,除了在Loss上加权重,也可以在增强时对闭眼样本多执行一轮随机模糊和随机噪声,让网络看到更多闭眼的变体。复制粘贴闭眼帧没有新增信息,这条不算增强。
3. CNN 模型搭建与训练:用 PyTorch 把分类器做扎实
3.1 自建小 CNN 还是迁移学习:毕设场景的选型逻辑
每次看到“基于 CNN”的课题,第一反应是自己写卷积层。但疲劳驾驶识别的样本量普遍在几千到几万张,从零训练一个深层网络容易欠拟合,训练时间却一点不短。在一线做这个方向,最常用的路数是迁移学习:加载 ImageNet 预训练权重,只替换最后的全连接层。预训练网络已经学会了边缘、纹理、形状这些通用特征,迁移到眼睛开合、嘴部状态识别时收敛快,精度也更有保障。
如果导师明确要求你展示自行搭建 CNN 的能力,那至少要做成一个三卷积块以上的结构。这里给出一个轻量级网络,能跑但不要指望它超过预训练 ResNet:
import torch.nn as nn class DrowsyCNN(nn.Module): def __init__(self, num_classes=3): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 14 * 14, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))逻辑说明:输入 112x112,三次池化后空间尺寸变成 14x14,所以全连接层输入维度是128 * 14 * 14。每层卷积后接 BatchNorm2d 和 ReLU,BatchNorm 能让训练中各层输入的分布更平稳,Dropout 则控制过拟合。
如果是毕设主线,更推荐直接用 ResNet18。替换最后一层分类头:
from torchvision import models import torch.nn as nn model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, 3)逻辑说明:预训练模型的model.fc原本输出 1000 类,in_features是最后一个全连接层的输入维度 512,替换成输出 3 类的线性层即可。训练时前面的特征层负责提取通用特征,fc分类头负责当前任务的类别决策。
参数说明:ResNet18 在普通 CPU 笔记本上做实时推理已经偏重,如果预算有限可以换 MobileNetV2。MobileNetV2 也是 ImageNet 预训练模型,结构里大量使用深度可分离卷积,参数量只有 ResNet18 的约三分之一,摄像头推理帧率更高。
3.2 训练脚本与超参数:让 loss 稳步下降
训练循环不要一次写复杂。先跑通最简版本,再逐步加验证、加权重。优化器我一般用 AdamW,它相比 SGD 对学习率不那么敏感,在分类任务上通常几分钟就能让 loss 走到合理区间。学习率分配是迁移学习的关键,分类头用 1e-3,backbone 特征层用 1e-4,防止预训练权重被大幅破坏。
训练循环最小可行版本如下:
import torch from torch.optim import AdamW device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) optimizer = AdamW([ {"params": model.layer4.parameters(), "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3}, ], weight_decay=1e-4) criterion = nn.CrossEntropyLoss() for epoch in range(30): model.train() 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() # 每个 epoch 结束后的验证逻辑 model.eval() total_correct = 0 total_num = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) pred = torch.argmax(outputs, dim=1) total_correct += (pred == labels).sum().item() total_num += labels.size(0) print(f"epoch {epoch+1} val acc: {total_correct / total_num:.4f}")逻辑说明:optimizer里给不同参数组配不同学习率,layer4是 ResNet18 最后一个残差层,保留更多高层特征,学习率压低;fc是全新分类头,学习率放大。weight_decay=1e-4是 L2 正则,能减轻过拟合。
参数说明:train_loader需要设置shuffle=True,否则每个 epoch 的数据顺序完全一样,优化过程会来回震荡。验证阶段必须调用model.eval()关闭 Dropout 和 BatchNorm 的统计更新,并包在torch.no_grad()里避免累积梯度。
训练过程中观察 val acc 的曲线:如果训练 loss 持续下降但 val acc 不涨,说明过拟合,回到数据增强和 Dropout 上找问题;如果训练和验证 acc 都不涨,先检查数据预处理和标签顺序是否匹配,再怀疑学习率过高或过低。
3.3 类别不平衡:CrossEntropyLoss 的 weight 参数
疲劳驾驶数据集中,睁眼帧往往远多于闭眼帧。正常开车时眼睛大多数时间睁开,采集到的视频帧自然也以睁眼为主。如果睁眼:闭眼 = 8:1,模型只要全输出“清醒”就能拿到 88% 准确率,但实际一点用都没有。这时候需要用类别权重修正损失函数。
计算权重推荐直接用sklearn:
from sklearn.utils.class_weight import compute_class_weight import numpy as np import torch labels = np.array([sample["label"] for sample in train_samples]) classes = np.array([0, 1, 2]) weights = compute_class_weight(class_weight="balanced", classes=classes, y=labels) class_weights = torch.tensor(weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)逻辑说明:compute_class_weight("balanced", ...)返回的权重遵循反比逻辑:样本多的类别权重低,样本少的类别权重高。把权重传给CrossEntropyLoss之后,闭眼类的误判会产生更大 loss,模型被迫更重视少数类。
参数说明:如果闭眼样本特别极端,比如只有睁眼的十分之一,单一权重可能过于激进导致训练初期不稳。可以把权重做开方平滑:sqrt(class_weights),既保留补偿,又不会让模型对闭眼过拟合。在实现疲劳判定时,还可以把闭眼判定阈值轻微放低,宁可多报一次疲劳,也不要让一次真正闭眼被漏过去。
4. 疲劳判定逻辑与系统集成:从单帧概率变成报警动作
4.1 PERCLOS 指标:标准化的疲劳阈值从哪里来
CNN 分类器输出的是某一帧图像属于闭眼的概率,但人本来就会正常眨眼,偶尔一帧闭眼不能判定疲劳。工程化做法是把连续若干帧的结果汇总成一个时间窗口指标,其中最经典的是 PERCLOS,也就是眼睛闭合时间占比。它的计算公式是:
PERCLOS = 闭眼帧数 / 窗口总帧数 × 100%
常见的阈值设置在 0.4 左右,意思是 30 秒窗口内,闭眼帧占比超过 40% 就触发报警。这个值不是死参数。高速公路场景注意力要求高,可以调到 0.35;城市低速场景正常眨眼更频繁,阈值可以放宽到 0.5。关键是阈值要留出调整接口,而不是写死在代码里,方便你在不同测试视频上做对比。
窗口长度直接影响报警延迟和误判率。窗口太短,一次几秒的眨眼会被当成疲劳;窗口太长,真疲劳了要过很久才报警。推荐初始值window_size=90,配合 30 FPS 摄像头正好三秒窗口,后续按实际视频帧率调整。
4.2 摄像头实时检测:把模型接进 cv2 循环
系统集成阶段最核心的代码是一个带滑动窗口状态的检测循环:
import cv2 import torch import numpy as np from collections import deque from PIL import Image from torchvision import transforms def run_detector(model, device, video_source=0, window_size=90, alarm_threshold=0.4, input_size=112): preprocess = transforms.Compose([ transforms.Resize((input_size, input_size)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) cap = cv2.VideoCapture(video_source) recent = deque(maxlen=window_size) model = model.to(device).eval() with torch.no_grad(): while True: ret, frame = cap.read() if not ret: break rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) pil = Image.fromarray(rgb) tensor = preprocess(pil).unsqueeze(0).to(device) prob = torch.softmax(model(tensor), dim=1) label = int(torch.argmax(prob, dim=1)) # 假设类别 0 是闭眼,把闭眼帧记作疲劳信号 recent.append(1 if label == 0 else 0) perclos = float(np.mean(recent)) if perclos > alarm_threshold: cv2.putText(frame, "DROWSY PERCLOS=%.2f" % perclos, (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imshow("drowsy detection", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()逻辑说明:deque(maxlen=window_size)是固定长度队列,新帧进来时最旧一帧自动弹出,便于计算滑动窗口内的闭眼占比。torch.softmax把网络输出转成概率,argmax得到当前帧的类别编号。闭眼帧用1填入队列,开眼帧用0,队列均值就是 PERCLOS。
参数说明:video_source=0表示默认摄像头;如果测试视频文件,传视频路径即可。input_size要和训练预处理保持一致,否则模型看到的图和训练时差异很大。alarm_threshold建议通过一组真实测试视频录制后标定,不要在理想环境下随便写一个数。
当前代码直接对整帧做分类,没有做人脸检测。实际场景中,如果摄像头在方向盘上方,画面中包含前挡风玻璃、中控台等大量背景,整帧分类会受影响。更可靠的做法是先用 OpenCV 的CascadeClassifier或mediapipe截取人脸区域,把裁剪后的 face ROI 送到 CNN 分类器,这样背景干扰会小很多。这个改动对精度提升非常明显。
4.3 用 EAR 做自检:CNN 概率与几何特征双重验证
如果希望系统具备可解释性,可以在 CNN 之外再加一路几何特征,最常见的是眼睑纵横比 EAR。EAR 通过人脸关键点计算眼睛的垂直距离与水平距离比值,睁眼时大约在 0.25 到 0.33 之间,闭眼时接近 0.1。
EAR 的计算公式是:
EAR = (||P2-P6|| + ||P3-P5||) / (2 * ||P1-P4||)
其中 P1 到 P6 是单只眼睛从外眼角到内眼角按顺序排列的六个关键点。EAR 和 CNN 概率可以做成并联判定,只有两个通道都判定闭眼时,才把当前帧计为闭眼帧。这样能有效规避阴影遮挡造成的误判,也能减少把打哈欠时眯眼当成闭眼的情况。
对于毕设,EAR 不是必需的,但它能帮助你在答辩时讲清楚“为什么不是单模型走天下”。常见做法是把 EAR 作为验证工具,在离线数据集上统计 CNN 判断和 EAR 判断的不一致率,找出哪些视频帧让两个通道产生分歧,再针对性地补充训练样本。这种分析过程本身也是论文里很容易写的一节。
5. 排查疲劳识别系统最常见的五个坑:现象、原因与解决
5.1 现象:准确率高但摄像头预览卡成 PPT
训练集准确率很高,一打开摄像头实时预览,画面肉眼可见地卡顿,推理延迟超过 300 毫秒。
原因:绝大多数出问题项目都用了 224x224 输入加上 ResNet34 或更大网络,还在每一帧上都完整推理,没有跳帧。笔记本电脑 CPU 推理一张 224x224 图片耗时往往超过 100 毫秒,加上画面显示开销,帧率直接掉到 5 FPS 以下。
解决:把输入尺寸降到 112x112,模型换成 ResNet18 或 MobileNetV2。依然卡就做跳帧,每两帧只推理一次,另一帧沿用上一次结果。人眼对 15 FPS 以上的画面感知已经接近连续,跳帧不会影响最终报警体验。
5.2 现象:验证集准确率高,换一个人就失效
在自己录制的视频上表现很好,换一个同学坐到摄像头前,系统频繁误报。不是模型坏了,而是模型过拟合到了特定受试者的外貌特征。
原因:数据集采集自单一受试者,模型学的不是“闭眼”这个语义,而是“这个人的眼睛颜色、脸型、眼镜框”等信息。肤色较深或戴眼镜的人一出现,闭眼与不闭眼的分界线就错乱。
解决:按受试者划分训练与测试集,确保同一位受试者的所有帧只出现在训练或测试单侧。其次扩充自采样本,覆盖不同肤色、不同眼镜佩戴情况。如果没有条件采多人数据,就在增强中加入随机遮挡和局部模糊,模拟墨镜、口罩对局部特征的遮盖。
5.3 现象:打哈欠总是被误判成闭眼
一段视频里,人物打哈欠时眼睛明明是睁开的,系统却持续报警疲劳。
原因:哈欠类样本太少,CNN 把嘴部大幅张开、面部肌肉收缩的纹理错误关联到了闭眼类;也可能是哈欠帧里本来就伴随半闭眼,标签把这类帧归到了 yawn,但模型没学到区分两者。
解决:打开样本目录,检查 yawn 和 closed_eye 两类图片是否互相串了。把嘴角变形但眼睛睁开的图片从 closed_eye 里清掉。如果只有两类,需要为哈欠单独立类。模型层面可以用 EAR 做校验,CNN 判定闭眼时检查 EAR 是否也低于阈值,双通道一致才计入疲劳帧。
5.4 现象:loss 正常但验证指标震荡,最后全输出一类
训练过程中 loss 每个 epoch 都在下降,但验证准确率忽高忽低,某次验证后突然绝大部分预测都是同一个类别,模型变成“复读机”。
原因:排查时先看 DataLoader 是不是忘了shuffle=True,数据顺序固定会让优化过程周期性循环。另一个常见原因是学习率过大,AdamW 下学习率超过 3e-3 就会在后期震荡,权重被推出最优点。
解决:固定随机种子,给训练加载器补上shuffle=True;学习率降到 1e-4 到 1e-3 的合理区间。还要确认验证时调用model.eval(),否则 BatchNorm 使用 batch 统计量,预测结果会不稳定。
5.5 现象:无 GPU 电脑部署时模型加载报错
项目在自己的 GPU 机器上正常,拷到一台没有 NVIDIA 显卡的电脑上加载权重时报错,提示 CUDA 张量无法加载到 CPU。
原因:训练时保存的state_dict里张量绑定在cuda:0设备上,部署机没有 GPU,直接torch.load就崩了。
解决:加载权重时强制映射到 CPU:
state_dict = torch.load("model.pth", map_location="cpu") model.load_state_dict(state_dict)如果你想转成 ONNX 在 OpenVINO 或 TensorRT 上部署,需要固定输入尺寸,并提前把model.eval()设好再导出。给答辩演示准备一个 CPU 可跑版本,永远比临时找 GPU 要安全。
6. 最后一步:用混淆矩阵和跨受试者验证,证明你的系统真的可用
6.1 混淆矩阵:优先看漏报而不是只看准确率
疲劳驾驶系统的可用性,不能只看总体准确率。闭眼是少数类,总体准确率很容易被大量清醒帧撑高。我最关心的指标是混淆矩阵里的 FN,也就是把闭眼判成清醒的帧数。漏掉一次真闭眼,意味着报警信号完全缺失,这对驾驶安全系统是不可接受的。
用 sklearn 输出混淆矩阵:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt y_true = [] # 真实标签 y_pred = [] # 模型预测标签 cm = confusion_matrix(y_true, y_pred) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=["awake", "closed_eye", "yawn"]) disp.plot() plt.show()逻辑说明:混淆矩阵的行是真实类别,列是预测类别。对角线是正确判断,非对角线是错误。看 closed_eye 这一行落在 awake 列的数量,就是漏报;看 awake 行落在 closed_eye 列的数量,就是误报。疲劳检测系统可以先想办法把漏报压到最低,再通过阈值调回可接受的误报范围。
6.2 跨受试者验证的简单协议
如果项目里有多位受试者的录制数据,做一次跨受试者验证会让结论更有说服力。协议很简单:训练集中不包含受试者 A 的任何帧,用剩余受试者数据训练,测试集是受试者 A 的全部视频帧;之后轮换,让每位受试者都做一次测试对象。最后统计每位受试者的准确率和漏报率,如果某位受试者的表现与训练集中其他人差异很大,说明模型存在明显的身份泄露。
这个协议不需要新写模型,只是改一改数据划分逻辑。很多毕设论文只给一个全局准确率,答辩老师一问“测试集里有没有训练数据中的人”就接不上了。能把跨受试者验证讲清楚,是让项目从“跑通”走向“可信”的分水岭。
6.3 一个固定下来的习惯
我在做这类项目时有一个固定习惯:每次实验前固定随机种子,并把数据划分结果存成索引文件,记录是第几次划分。曾经吃过一次亏,调了两天损失函数,精度没变化,最后发现是数据增强顺序在每次运行时不一样,模型看到的样本分布一直在变。从那以后,所有预处理、增强、划分操作都做成可复现流程,任何一次实验结果都能回溯到当时的参数组合。做疲劳驾驶识别这项毕设,真正的价值不只是“用 CNN 分出了闭眼”,而是你愿意把每一个阈值、每一个样本、每一处报错都当成可解释的工程问题去处理。希望这几段经验能帮你少走一些弯路。
本文还有配套的精品资源,点击获取