简介:面向深度学习与计算机视觉方向的毕业设计、课程设计开发者,提供一套完整的驾驶员分心驾驶行为识别方案。资源整合了基于Keras框架的VGG16、VGG19、ResNet50、InceptionV3、Xception等经典卷积神经网络的微调与可视化代码,覆盖数据预处理、瓶颈特征提取、模型训练、结果评估及可视化环节,所有关键步骤均配有注释清晰的Python脚本与Jupyter Notebook,新手也能快速上手。包内还包含项目说明文档、毕业论文成稿(docx与pdf)以及演示动图,可直接参考撰写论文和制作答辩PPT。压缩包共31个文件,以ipynb、py、html为主,辅以pdf、docx、gif、md等格式,整体大小约65.36MB,目录结构清晰,部署方便。目前已有739人下载学习,适合需要从零搭建系统、完成高完成度项目的学生与研究者。
1. 驾驶员分心行为识别:一个看着简单、坑却不少的深度学习落地方案
我复现过不少视觉分类项目,驾驶员分心驾驶行为识别是其中“看起来简单、实际坑最多”的一个。它本质上是一个图像分类任务:判断司机正在安全驾驶、打电话、发短信、喝水、化妆还是操作中控面板。但类间相似度高、光照变化大、姿态多样,模型很容易在训练集上刷出高分,一到真实场景就露馅。这套基于深度学习的驾驶员分心驾驶行为识别项目,包含完整源码、标注数据集、训练好的模型权重以及配套毕业设计论文,正好覆盖从数据准备到部署推理的全部环节,适合正在选毕设题的同学,也适合想系统性掌握图像分类工程的开发者。下面我按复现流程逐层拆解,把每步能抄作业的参数和值得警惕的坑一并说清。
2. 数据集与预处理:分心样本怎么选、怎么切分才不踩坑
2.1 数据集选型:公开样本与自采数据的取舍
驾驶员分心行为识别的主流做法,是把它建成一个细粒度图像分类任务。公开数据集方面,最常被引用的是 State Farm 分心驾驶员检测数据集,它在模拟驾驶舱环境中拍摄,覆盖 10 类行为。下面的表是我在复现时整理的标签含义,也直接对应源码里的类别目录:
| 类别标签 | 行为 | 识别的关键部位 |
|---|---|---|
| c0 | 安全驾驶 | 双手在方向盘上,视线朝前 |
| c1 | 右手发短信 | 右手不在方向盘,手机在下半区 |
| c2 | 左手发短信 | 左手不在方向盘,手机在下半区 |
| c3 | 右手打电话 | 手机靠近右耳,右臂抬起 |
| c4 | 左手打电话 | 手机靠近左耳,左臂抬起 |
| c5 | 操作收音机 | 手伸向中控台区域 |
| c6 | 喝饮料 | 手持杯体或瓶体,嘴部靠近 |
| c7 | 化妆 | 手持镜子或化妆工具,面部朝向镜头 |
| c8 | 与乘客交谈 | 头部转向副驾驶方向 |
| c9 | 从后座拿东西 | 身体向后方扭转 |
这类公开数据的优点很直接:类别体系是现成的,标注也比较规整,跑基线非常快。但它有个明显边界——拍摄背景固定在模拟驾驶舱里,真实行车环境的光照、抖动、视角变化一旦叠加上来,模型的泛化能力会明显下降。自采数据能缓解泛化问题,但成本不低,标注 10 类行为、每类至少 500 张有效样本,一个人需要一周左右的时间。我一般建议的折中方案是:用公开数据集做预训练和基准测试,再补充 100 到 200 张自己拍摄的行车视频抽帧,做最后的微调。这样既保留了可复现性,又让模型带上真实场景的特征。
2.2 预处理流水线:RGB 顺序、裁剪比例与按人划分
预处理环节看起来不复杂,但每一处都藏着影响精度的细节。我的处理顺序是:先用 OpenCV 读图并把 BGR 转成 RGB,这个顺序很容易被忽略,却是最常见的翻车点;然后按中心裁剪,裁掉左右两侧的副驾座椅和车窗背景,裁剪比例设在 0.8 到 0.9;再统一缩放到 224×224,匹配 ImageNet 预训练模型的输入尺寸;最后做归一化,像素值除以 255,再按 ImageNet 的 mean=[0.485, 0.456, 0.406] 和 std=[0.229, 0.224, 0.225] 做标准化。
比预处理更关键的是数据划分方式。State Farm 数据集中同一个驾驶员会出现在连续多张图片里,姿势和背景极其相似。如果直接随机划分训练集和验证集,同一个人的图片被拆到两侧,验证集分数会明显虚高,训练过程看起来一切正常,实际泛化能力却被高估。我采用的常见做法是“按人划分”而不是“按图划分”:
import os import random import shutil def split_by_subject(data_root, output_root, train_ratio=0.8): """ 按驾驶员 ID 划分数据集,保证同一个人不会同时出现在训练集和验证集。 data_root 结构要求:data_root/<subject_id>/<class_id>/*.jpg """ subjects = [d for d in os.listdir(data_root) if os.path.isdir(os.path.join(data_root, d))] random.shuffle(subjects) cut = int(len(subjects) * train_ratio) train_subjects = set(subjects[:cut]) for split_name, condition in [("train", lambda s: s in train_subjects), ("val", lambda s: s not in train_subjects)]: out_dir = os.path.join(output_root, split_name) os.makedirs(out_dir, exist_ok=True) for subject in subjects: if condition(subject): src = os.path.join(data_root, subject) dst = os.path.join(out_dir, subject) if not os.path.exists(dst): shutil.copytree(src, dst) split_by_subject("raw_data", "split_data", train_ratio=0.8)这个脚本的逻辑是:先扫描出全部驾驶员 ID,做一次随机打乱,然后按比例切成训练组和验证组,再按 ID 复制整个目录。注意这里必须用copytree复制整个驾驶员目录,而不是复制单张图片,否则类别目录结构就乱了。train_ratio=0.8在样本量充足时是合理取值,如果总人数少于 20,建议提到 0.9,因为验证集过小会导致评估波动明显。
如果手里的原始数据不是按人分目录的,就需要从文件路径或元数据中解析出驾驶员 ID,再按 ID 分组。解析时特别要留意文件名分隔符的差异,不同数据集的命名规范不一样,正则写错了,同一个人就被切散到两侧,数据泄漏问题会再次出现。
2.3 类别不平衡与数据增强
公开数据集里 10 类样本基本均衡,但一旦掺入自采数据,类别数量就会明显拉开。我的经验是先把各目录的样本数统计出来打印一遍:
from collections import Counter counter = Counter() for root, dirs, files in os.walk("split_data/train"): class_id = os.path.basename(root) counter[class_id] += len(files) print(counter)如果类别之间数量差距超过 1 倍,就需要处理。常见做法是随机过采样小类别,再把小类别的样本重复读入训练循环;也有人用加权采样器,效果相近。数据增强层面,翻转和旋转对分心驾驶这个任务特别有效,因为驾驶员的左右手动作本来就有对称性,但翻转有一个标签陷阱,下面这份数据增强配置和对应的说明会讲清楚:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.3), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.25, contrast=0.25), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])RandomHorizontalFlip对左右手类别的影响需要特别说明:水平翻转后,“右手打电话”图像上的手部位置会镜像到左侧,视觉特征接近“左手打电话”。如果训练时不做标签联动修改,模型会在这两类上产生混乱。所以更稳妥的做法是对左右手成对类别单独处理,或者把翻转概率降到 0.3 以下并用类别映射修正标签。我在复现时用的是成对映射方案:训练循环里读取图片时,如果发生了翻转,就把 c1 与 c2 互换、c3 与 c4 互换。单纯压缩翻转概率也能缓解,但不如映射方案彻底。
3. 模型选型与训练:ResNet50 与轻量化网络的取舍
3.1 骨干网络选型:从 ResNet50 到 MobileNet 的边界
分心驾驶识别最稳定的基线,是基于 ImageNet 预训练权重的迁移学习。骨干网络的选择,本质上是在精度、参数量、推理延迟三者之间做权衡。下面是我在复现时整理的三个常见选择:
| 骨干网络 | 参数量 | 推理相对耗时 | 适用场景 |
|---|---|---|---|
| ResNet50 | 25.6M | 1x(基准) | 实验室验证、精度优先 |
| MobileNetV3-Large | 5.4M | 约0.3x | 边缘设备、实时推理 |
| EfficientNet-B2 | 9.2M | 约0.5x | 精度与速度的折中 |
ResNet50 是最稳妥的起点。它的残差结构让十几层卷积之后梯度依然稳定,训练曲线容易收敛,调参空间也相对宽松。MobileNetV3 的优势在部署侧,深度可分离卷积把参数量压得很低,在 CPU 上也能跑到可用帧率,但它的训练对学习率更敏感,收敛后精度通常比 ResNet50 低 2 到 4 个百分点。EfficientNet-B2 是折中选择,复现时需要额外安装 timm 库,对刚接触深度学习代码的同学来说多一个依赖就多一个变量,我建议先把 ResNet50 跑通再换。
替换分类头是迁移学习的关键步骤。预训练模型在 ImageNet 上输出 1000 类,分心识别只需要 10 类,所以要重建最后的全连接层:
import torchvision.models as models import torch.nn as nn def build_model(num_classes=10, backbone="resnet50", pretrained=True): if backbone == "resnet50": model = models.resnet50( weights=models.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None ) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) elif backbone == "mobilenet_v3": model = models.mobilenet_v3_large( weights=models.MobileNet_V3_Large_Weights.IMAGENET1K_V1 if pretrained else None ) in_features = model.classifier[-1].in_features model.classifier[-1] = nn.Linear(in_features, num_classes) return model这段代码的逻辑是保留预训练模型的特征提取部分,只重建最后的任务头。ResNet50 的model.fc.in_features是 2048,MobileNetV3 最后一个线性层的输入是 1280,这些值不需要手写,直接从原模型里取。分类头里加一个 Dropout 是为了抑制小样本类别的过拟合,概率设 0.3 已经足够,再大反而会拖慢收敛。
3.2 训练配置:冻结层、学习率与收敛节奏
迁移学习里一个常被忽略的点是“冻结”策略。我的做法是:前 5 个 epoch 冻结骨干网络的所有参数,只训练新加的分类头,让随机初始化的分类头先学会适配预训练特征;第 6 个 epoch 开始解冻骨干网络的后半段,用更小的学习率对整个网络做微调。
def set_freeze_by_epoch(model, epoch, backbone_name="resnet50"): for name, param in model.named_parameters(): param.requires_grad = True if epoch < 5: for name, param in model.named_parameters(): if "fc" not in name and "classifier" not in name: param.requires_grad = False这段代码的行为是:epoch < 5时,除分类头之外的所有参数都不可训练。解冻时要注意,不能所有层同时放开,否则分类头学习率偏大,骨干网络的预训练权重会被快速破坏,训练曲线容易出现先跌后涨的过山车现象。我见过不少训练日志里验证集 loss 在第 6 个 epoch 突然抬升,就是解冻策略没控制好。
学习率方面,分类头用 1e-3,骨干网络解冻后用 3e-5,整体采用余弦退火。对应的优化器与调度器配置如下:
from torch.optim.lr_scheduler import CosineAnnealingLR import torch.optim as optim optimizer = optim.SGD(model.parameters(), lr=1e-3, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-5)SGD配合momentum=0.9在图像分类任务上是稳定的默认选择,weight_decay=5e-4对全连接层有效,对卷积层影响不大。T_max=30表示 30 个 epoch 完成一个完整的余弦周期,学习率从初始值缓慢降到eta_min=1e-5。有人用 Adam 也能跑,但我在这类任务上遇到 Adam 的验证精度波动偏大,换成 SGD 后反而稳定很多。AdamW 也可以试,但初始学习率要降到 1e-4 附近,否则前几个 epoch 就会发散。
3.3 评估指标:不要只看整体准确率
分心驾驶任务最容易出现的情况,是整体准确率很高但某个类别完全崩掉。比如“与乘客交谈”和“安全驾驶”之间,头部姿态稍微偏移一点就被误判;再比如“右手发短信”和“左手发短信”,模型对左右手的区分本身就敏感。所以评估时要打印混淆矩阵和每个类别的召回率:
from sklearn.metrics import confusion_matrix, classification_report y_true, y_pred = [], [] model.eval() with torch.no_grad(): for imgs, labels in val_loader: imgs = imgs.cuda() logits = model(imgs) preds = logits.argmax(dim=1).cpu().numpy() y_true.extend(labels.numpy()) y_pred.extend(preds) print(classification_report(y_true, y_pred, digits=3)) print(confusion_matrix(y_true, y_pred))classification_report输出的 precision、recall、f1-score 是按类别分开统计的,如果某个类别的 recall 低于 0.85,就需要检查是样本太少还是类间特征太接近。混淆矩阵里的高频错误对会告诉你具体是哪两类在打架,比如 c3 和 c4 互相误判,通常意味着左右手的特征没有学好。还有一个容易被忽略的点:安全驾驶类的误报统计要单独看,漏报一次危险行为比多报一次误警的后果严重得多,模型选择的标准也应该向这个方向倾斜。
4. 从单帧分类到行为判定:推理脚本与连续帧决策
4.1 模型加载与单帧推理
训练完成之后,核心工作转向加载模型并对视频流做实时推理。加载模型有两个常见做法:一是用torch.save(model.state_dict())保存权重,推理时先重建模型结构再 load;二是保存完整的 TorchScript 脚本模型。前一种兼容性好,后一种适合部署,不依赖 Python 环境。下面是我常用的推理脚本框架:
import torch import torchvision.models as models import torch.nn as nn from PIL import Image from torchvision import transforms def load_model(weight_path, num_classes=10, device="cuda"): model = models.resnet50(weights=None) model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes) ) state = torch.load(weight_path, map_location=device) model.load_state_dict(state["model_state_dict"] if "model_state_dict" in state else state) model.to(device).eval() return model val_transform = transforms.Compose([ transforms.CenterCrop(224), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def predict_image(model, pil_image, device="cuda"): img = val_transform(pil_image).unsqueeze(0).to(device) with torch.no_grad(): logits = model(img) prob = torch.softmax(logits, dim=1) conf, idx = torch.max(prob, dim=1) return idx.item(), conf.item()推理端的代码没有训练那么复杂,关键点是model.eval()一定要调用,它能关闭 Dropout 和 BatchNorm 的训练状态。CenterCrop(224)和Resize((224, 224))同时出现容易被误认为重复,实际上 Crop 是先把图像中心裁成方形,Resize 再统一尺寸,两个操作合起来能减少图像拉伸导致的形变失真。torch.no_grad()会关闭梯度计算,推理显存占用和单帧耗时都会下降。
4.2 从单帧到连续判定:滑动窗口投票与报警逻辑
单帧分类结果不稳定。同一段 1 秒视频里,某几帧把“安全驾驶”判成了“操作收音机”,下一帧又跳回“安全驾驶”,这种抖动在实际系统中不能直接使用。处理抖动的常见做法是滑动窗口投票:
from collections import deque import numpy as np class FrameVoter: def __init__(self, window_size=15, alarm_class_ids=None): self.window = deque(maxlen=window_size) self.alarm_class_ids = alarm_class_ids or {1, 2, 3, 4, 5, 6, 7, 9} def update(self, class_id, prob): self.window.append((class_id, prob)) if len(self.window) < self.window.maxlen: return None, 0.0 ids = [c for c, _ in self.window] counter = np.bincount(ids, minlength=10) vote_class = int(counter.argmax()) vote_rate = counter[vote_class] / len(ids) return vote_class, vote_rate这个FrameVoter的做法是维护一个长度固定的双端队列,每来一帧就往队尾放入一个分类结果,队首自动弹出。只有窗口满 15 帧时才输出判定结果,输出的是窗口内出现次数最多的类别。vote_rate表示该类别在窗口内所占比例,比例越高,判定越可信。alarm_class_ids用来标记哪些类别属于需要报警的危险行为,默认集合里排除的 c0 和 c8 是安全驾驶与交谈,实际部署时可以根据业务需求调整。
真实系统里很少直接播放单帧预测结果。我一般会在投票结果仍是危险类别、且vote_rate超过 0.7 的情况下才触发一次报警,并给报警加一个 3 秒的冷却时间,防止同一次行为触发多次提示。这样做的本质是把模型输出的单帧概率转换成一个短时间尺度上的决策,比直接拿 softmax 结果做阈值要稳得多。窗口长度 15 帧对应 1 秒决策粒度,在 15fps 的抽帧策略下刚好匹配人的反应节奏。
5. 避坑指南:数据、训练与推理三个阶段的常见问题
5.1 数据阶段的坑:验证集虚高与左右手翻转混乱
复现这套项目最容易翻车的位置,大多不在模型结构,而在数据切分和增强这两个环节。我按现象、原因、解决三个层次记录了三条最典型的踩坑记录,读者可以直接对照检查自己的流程。
问题一:验证集准确率接近 98%,真实场景掉到 80% 以下
- 现象:训练日志里验证集精度很高,把模型接到行车视频上测试,准确率明显下降,甚至稳定把安全驾驶判成操作收音机。
- 原因:训练集和验证集按图片随机划分,同一个驾驶员的连续帧同时出现在两侧。分心驾驶数据集中同一人的图像背景、光照、姿势高度相似,模型记住的是“这个人”的特征,而不是“这个行为”的特征。
- 解决:按驾驶员 ID 分组切分数据,用
split_by_subject这种脚本保证同一人的图片只落在训练集或验证集一侧。切分完成后要抽查验证集目录,确认每个驾驶员 ID 没有重复出现。
问题二:c1/c2、c3/c4 左右手类别的召回率始终偏低
- 现象:混淆矩阵里 c1 和 c2 互相误判比较频繁,c3 和 c4 也有类似情况,整体准确率被拉低。
- 原因:数据增强里加了
RandomHorizontalFlip,水平翻转后图像里的手部位置镜像变换,但标签没有同步修改,模型在左右手类别上收到了矛盾信号。 - 解决:去掉水平翻转,或者用成对标签映射。更稳定的做法是只保留旋转 10 度以内的增强,因为分心驾驶数据里左右手的物理方向对识别有明确意义,不需要靠翻转来扩充样本。
问题三:自采数据加入后训练 loss 下降缓慢
- 现象:在公开数据集上训练正常,加入自采数据后 loss 明显偏高,收敛速度变慢。
- 原因:自采数据的图像分辨率、光照、相机位置与公开集不一致,预处理参数没有统一,归一化以后的数据分布出现偏移。
- 解决:把所有来源的图像统一走同一条预处理流水线,先缩放再裁剪,裁剪比例保持一致。自采数据如果分辨率分布跨度大,可以在预处理里加一个
Resize的中间步骤,让输入尺寸先对齐到统一大小再裁剪。
5.2 训练阶段的坑:验证曲线震荡与 Loss 发散
问题四:验证准确率在第 6 个 epoch 突然下跌,之后恢复缓慢
- 现象:前 5 个 epoch 验证集稳定上升,进入第 6 个 epoch 后验证准确率突然跌了几个点,之后需要很长时间才爬回来。
- 原因:骨干网络从冻结切换为解冻时,所有参数同时开始更新,预训练权重被较大的梯度扰动,特征提取层出现短期退化。
- 解决:解冻时按层分组,先解冻最后一个残差块,再逐步解冻更早的层。更简单的做法是把解冻后的学习率压到 1e-5 以下,让梯度扰动变小。
问题五:训练过程中 loss 变成 NaN
- 现象:训练日志打印的 loss 在某一步突然变成
nan,之后所有指标全部失效,只能中断训练。 - 原因:数据管道里混入损坏图片或空标注文件,读入的图像张量出现异常值;也可能是初始学习率偏大,梯度爆炸把权重数值推到溢出范围。
- 解决:检查数据加载日志,确认每个 batch 的 tensor 统计量里没有
inf或nan;把分类头初始学习率降到 1e-4 再试;在优化器之后加一层梯度裁剪,torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0),能兜住绝大多数发散场景。
5.3 推理阶段的坑:颜色通道错位与模型状态错误
问题六:训练准确率高,推理脚本预测结果一塌糊涂
- 现象:同一个验证集图片,在训练脚本里评估准确率 95%,换到推理脚本里预测结果几乎随机。
- 原因:推理脚本用 OpenCV 读图,OpenCV 默认返回 BGR 顺序,而 PyTorch 预训练模型期望的是 RGB,颜色通道错位后,模型看到的图像色调完全不对。
- 解决:读图后立即做
cv2.cvtColor(img, cv2.COLOR_BGR2RGB),或者在预处理函数里用 PIL 的Image.open替代 OpenCV。
问题七:单张推理正常,连续跑视频时显存缓慢增长
- 现象:固定输入分辨率,推理单张图片显存占用稳定,连续处理视频帧时显存不断攀升,最后 OOM。
- 原因:每一帧都调用
.cuda()或者torch.from_numpy创建新张量,旧的张量没有被及时释放,PyTorch 的缓存分配器没有把显存还回来。 - 解决:推理循环外面统一创建输入张量,每帧用
copy_把数据填进去;或者每处理 100 帧调用一次torch.cuda.empty_cache()。更彻底的做法是把推理函数改成接收预处理后的张量,而不是接收 PIL 图像,这样张量的生命周期由调用方控制。
6. 最后一步:滑窗投票的细节调参与行为链判定
前面第 4 章给出的FrameVoter是基础版本,实际部署时还有两个参数值得深调:窗口长度和报警阈值。
窗口长度 N 是第一个关键参数。N=30 表示 30 帧,按 15fps 的抽帧速度相当于 2 秒决策窗口;N=15 是 1 秒。对报警任务来说,窗口太长会延迟危险行为的响应,窗口太短又无法滤掉单帧误判。我建议从 N=15 开始,优先保证报警及时性。如果现场误报率太高,再把窗口加到 25 或 30,但要注意误报率的下降与报警延迟是线性相关的。
第二个参数是危险行为的触发条件。基础版取窗口内出现次数最多的类别作为结果,但更实用的做法是记录窗口内的危险帧占比,只有连续出现且占比超过阈值才触发。我在复现时用的是双重条件:危险类别连续出现 3 帧以上,且窗口内危险帧比例超过 0.7。连续 3 帧能过滤掉单帧噪声,比例阈值能防止窗口内类别频繁跳动时误触发。
class BehaviorTrigger: def __init__(self, danger_threshold=0.7, consecutive_frames=3, cooldown=90): self.danger_threshold = danger_threshold self.consecutive_frames = consecutive_frames self.cooldown = cooldown self._danger_streak = 0 self._cooldown_left = 0 def step(self, class_id, window_danger_ratio): if self._cooldown_left > 0: self._cooldown_left -= 1 return False if window_danger_ratio >= self.danger_threshold: self._danger_streak += 1 else: self._danger_streak = 0 if self._danger_streak >= self.consecutive_frames: self._cooldown_left = self.cooldown self._danger_streak = 0 return True return FalseBehaviorTrigger把报警决策拆成两个阶段:先看窗口内的危险占比,再做连续帧累计。cooldown=90表示触发一次报警后 90 帧内不再重复触发,在 15fps 下恰好是 6 秒,足够司机完成一次抬头看路的动作。这个逻辑看起来简单,但很有效,它把单帧分类结果变成了一个有时间维度的行为链判断。
从那以后我每次部署分心驾驶识别模型,都会强制走一遍这套流程:先按人切分数据验证是否有泄漏,再打印混淆矩阵检查左右手类别,最后用滑动窗口加报警冷却时间跑一段模拟视频。三个环节都过了,模型才有底气接到真实场景里。希望帮到你。
本文还有配套的精品资源,点击获取