简介:这份源码面向计算机视觉与深度学习方向的研究人员和开发者,提供基于3D CNN与CNN+RNN两种架构的UCF101视频动作识别完整实现,帮助读者快速搭建可运行的动作分类系统并验证算法改进思路。资源包共82个文件,约19.35MB,包含12个py源码文件与12个ipynb交互式笔记,分别对应模型定义、训练与预测流程;16个npy与14个pkl文件保存训练损失、测试得分及预测结果,11个png图表用于展示损失曲线与模型结构,另有gif演示动作识别效果、md文档记录说明。项目按Conv3D、CRNN、ResNetCRNN三条技术路线分目录组织,覆盖从数据加载、帧数统计到预测校验的完整环节,便于对比不同时空特征提取方案。目前已有360人学习,适合希望深入理解视频动作识别工程实现、需要可复现实验代码的读者参考。
1. 从 UCF101 说起:为什么视频分类不能只靠单帧 CNN
UCF101 是视频动作识别领域最经典的基准数据集之一,101 个动作类别、13320 段真实 YouTube 视频,涵盖体育、乐器演奏、人物交互等五大类。很多人第一次做视频分类,习惯性地把每帧抽出来当成独立图片,用 CNN 逐帧分类再投票。这个思路在静态场景下勉强能用,但一碰到「投篮」和「运球」这种动作轨迹不同、单帧画面却高度相似的类别,准确率立刻掉到 60% 出头。原因很直接:单帧 CNN 丢掉了时间维度上的运动信息,而动作识别的核心恰恰是「怎么动」而不是「长什么样」。
这个标题里出现的两条技术路线——3D CNN 和 CNN+RNN——正是为了解决时序建模问题而生的两种主流方案。3D CNN 把卷积核从二维扩展到三维,在空间维度的基础上多出一个时间维度,直接在一段连续帧上做时空卷积;CNN+RNN 则先用二维 CNN 逐帧提取空间特征,再把特征序列喂给 LSTM 或 GRU 来建模时序依赖。两条路线各有适用场景,也各有各的坑。这篇笔记会从数据准备、模型搭建、训练调参到推理部署,把两条路线都跑通一遍,给出可复现的代码和参数配置,同时把我在实际项目中翻过车的地方标出来。适合已经掌握 PyTorch 基础、想做视频理解落地但还没找到切入点的工程师。
2. UCF101 数据准备:从原始视频到可训练张量
2.1 数据集结构解析与划分策略
UCF101 的原始目录结构是UCF-101/类别名/视频文件.avi,每个类别一个文件夹,视频帧率 25fps,分辨率 320×240,时长从 1 秒到 10 秒不等。官方提供了三个训练/测试划分文件(trainlist01.txt、testlist01.txt 等),做实验时务必用官方划分,否则和论文对比没有意义。
常见做法是先把视频解码成帧序列存成图片,或者直接存成帧数组的二进制文件。前者方便调试但占磁盘,后者读取快但不好肉眼检查。我一般会先抽帧存图,确认数据没问题后再转成打包格式。
import os import cv2 import numpy as np def extract_frames(video_path, save_dir, target_size=(224, 224), max_frames=64): """ 从视频中均匀抽取 max_frames 帧,resize 到 target_size,保存为 jpg """ os.makedirs(save_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total == 0: return 0 # 均匀采样索引,避免开头结尾冗余帧 indices = np.linspace(0, total - 1, max_frames, dtype=int) saved = 0 for i, idx in enumerate(indices): cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx)) ret, frame = cap.read() if not ret: continue frame = cv2.resize(frame, target_size) cv2.imwrite(os.path.join(save_dir, f"{i:04d}.jpg"), frame) saved += 1 cap.release() return saved这段代码的关键参数是max_frames和采样策略。UCF101 视频长度差异大,固定帧数采样比固定间隔采样更稳。target_size设成 224×224 是为了适配 ImageNet 预训练权重,如果你打算从头训练,可以改成 112×112 省显存。注意cv2.CAP_PROP_POS_FRAMES在部分编码格式下定位不准,如果发现抽出来的帧有重复,改用顺序读取加跳帧。
2.2 构建 PyTorch Dataset 与数据增强
抽完帧之后,需要写一个 Dataset 类把帧序列组织成模型输入。3D CNN 的输入形状是(C, T, H, W),CNN+RNN 的输入形状是(T, C, H, W),两者在维度顺序上不同,写 Dataset 时最好用参数控制返回格式。
import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image class UCF101Dataset(Dataset): def __init__(self, frame_root, split_file, num_frames=16, mode='3dcnn'): self.frame_root = frame_root self.num_frames = num_frames self.mode = mode self.samples = [] with open(split_file, 'r') as f: for line in f: name, label = line.strip().split() cls = name.split('/')[0] vid = name.split('/')[1].split('.')[0] self.samples.append((cls, vid, int(label) - 1)) # 训练用随机裁剪+翻转,验证用中心裁剪 if 'train' in split_file: self.transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) else: self.transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): cls, vid, label = self.samples[idx] frame_dir = os.path.join(self.frame_root, cls, vid) frames = sorted(os.listdir(frame_dir))[:self.num_frames] imgs = [self.transform(Image.open(os.path.join(frame_dir, f)).convert('RGB')) for f in frames] # 帧数不足时循环补齐 while len(imgs) < self.num_frames: imgs.append(imgs[-1]) tensor = torch.stack(imgs) # (T, C, H, W) if self.mode == '3dcnn': tensor = tensor.permute(1, 0, 2, 3) # (C, T, H, W) return tensor, label这里有几个容易翻车的点。第一,num_frames要和模型输入严格对齐,3D CNN 通常用 16 或 32 帧,RNN 可以用更多但显存吃紧。第二,数据增强对视频要谨慎,随机裁剪和翻转在训练集上没问题,但验证集必须用确定性变换,否则指标波动大。第三,Normalize的均值和方差用的是 ImageNet 统计量,如果你从头训练可以改成 UCF101 自己的统计量,但差异不大。
提示:抽帧阶段建议保留原始帧率信息,后续如果要做光流或者变速增强会用到。
3. 3D CNN 路线:C3D 与 R(2+1)D 的工程实现
3.1 3D 卷积核的时空建模原理
3D CNN 的核心思想是把二维卷积核(kH, kW)扩展成三维(kT, kH, kW),让卷积操作同时在时间和空间维度上滑动。以 C3D 为例,它的卷积核大小是 3×3×3,第一层在 16 帧输入上做时间卷积,感受野覆盖 3 帧,随着层数加深,时间感受野逐步扩大。这样做的好处是运动特征和外观特征在同一个网络里联合优化,不需要额外的时序模块。
但 3D 卷积的参数量的增长是立方级的。一个 3×3×3 卷积核比 3×3 多出 3 倍参数,如果通道数再翻倍,显存占用会非常夸张。R(2+1)D 的解法是把一个 3D 卷积分解成空间 2D 卷积加时间 1D 卷积,参数量降下来,非线性表达能力反而更强。实际项目中,如果显存有限,优先选 R(2+1)D 而不是 C3D。
import torch.nn as nn class R2Plus1DBlock(nn.Module): """R(2+1)D 基础残差块:空间 2D 卷积 + 时间 1D 卷积""" def __init__(self, in_ch, out_ch, stride=1): super().__init__() mid_ch = (in_ch * out_ch * 3 * 3 * 3) // (in_ch * 3 * 3 + out_ch * 3) self.conv1 = nn.Conv3d(in_ch, mid_ch, kernel_size=(1, 3, 3), stride=(1, stride, stride), padding=(0, 1, 1)) self.bn1 = nn.BatchNorm3d(mid_ch) self.conv2 = nn.Conv3d(mid_ch, out_ch, kernel_size=(3, 1, 1), stride=(stride, 1, 1), padding=(1, 0, 0)) self.bn2 = nn.BatchNorm3d(out_ch) self.relu = nn.ReLU(inplace=True) # 残差连接,维度不匹配时用 1x1x1 卷积对齐 self.downsample = None if stride != 1 or in_ch != out_ch: self.downsample = nn.Sequential( nn.Conv3d(in_ch, out_ch, kernel_size=1, stride=stride), nn.BatchNorm3d(out_ch) ) def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) if self.downsample is not None: identity = self.downsample(x) return self.relu(out + identity)mid_ch的计算公式来自 R(2+1)D 原论文,目的是让分解后的参数量和原始 3D 卷积大致持平。conv1的 kernel 是(1,3,3),只在空间维度卷积;conv2是(3,1,1),只在时间维度卷积。stride 的设置要注意,空间下采样和时间下采样分开控制,通常时间维度下采样放在 conv2 里。
3.2 训练配置与显存优化参数
3D CNN 训练最大的瓶颈是显存。以 R(2+1)D-18 为例,输入 16 帧 112×112,batch size 16 在 8GB 显存上勉强能跑,如果输入 224×224 直接 OOM。我一般会用梯度累积来模拟大 batch,同时开混合精度训练。
from torch.cuda.amp import autocast, GradScaler model = R2Plus1DNet(num_classes=101).cuda() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) scaler = GradScaler() accum_steps = 4 # 等效 batch size = 4 * 实际 batch for epoch in range(50): model.train() for step, (videos, labels) in enumerate(train_loader): videos, labels = videos.cuda(), labels.cuda() with autocast(): outputs = model(videos) loss = nn.CrossEntropyLoss()(outputs, labels) / accum_steps scaler.scale(loss).backward() if (step + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad() scheduler.step()学习率 0.01 配 SGD 是 3D CNN 的经典配置,如果 loss 震荡厉害降到 0.001。weight_decay设 1e-4 防止过拟合,UCF101 只有 13320 个视频,过拟合是常态。混合精度训练能把显存占用降低约 40%,但要注意autocast区域内的 loss 计算要放在外面做 scale。梯度累积的accum_steps根据显存调整,等效 batch size 建议不低于 32,否则 BN 统计量不准。
注意:3D CNN 的 BN 层对 batch size 敏感,如果单卡 batch 小于 8,考虑换成 GroupNorm。
4. CNN+RNN 路线:用 ResNet 提特征、LSTM 建时序
4.1 特征提取与序列建模的衔接方式
CNN+RNN 的思路很直观:用预训练的 ResNet 对每一帧提取 2048 维特征,得到一个长度为 T 的特征序列,再送进 LSTM 做时序建模,最后取 LSTM 最后一个时间步的输出分类。这条路线的好处是可以直接复用 ImageNet 预训练权重,收敛快,显存占用比 3D CNN 低不少。缺点是 CNN 部分和 RNN 部分分开训练,端到端微调时梯度回传路径长,容易梯度消失。
实际实现时有两种做法:一种是先用 CNN 把所有帧的特征离线提取好存成 npy 文件,训练时只跑 LSTM;另一种是 CNN 和 LSTM 拼成一个模型端到端训练。前者快但没法微调 CNN,后者慢但精度更高。我一般先用离线特征快速验证 LSTM 结构,确认有效后再端到端微调。
import torchvision.models as models class CNNRNNModel(nn.Module): def __init__(self, num_classes=101, hidden_dim=512, num_layers=2): super().__init__() resnet = models.resnet50(pretrained=True) # 去掉最后的全连接层,保留全局池化前的特征 self.cnn = nn.Sequential(*list(resnet.children())[:-1]) self.lstm = nn.LSTM(input_size=2048, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, dropout=0.5, bidirectional=False) self.fc = nn.Linear(hidden_dim, num_classes) def forward(self, x): # x: (B, T, C, H, W) B, T, C, H, W = x.shape x = x.view(B * T, C, H, W) feat = self.cnn(x) # (B*T, 2048, 1, 1) feat = feat.view(B, T, -1) # (B, T, 2048) out, (h, c) = self.lstm(feat) return self.fc(out[:, -1, :]) # 取最后时间步hidden_dim设 512 是经验值,再大容易过拟合。num_layers=2配合dropout=0.5在 UCF101 上比较稳。bidirectional=False是因为在线推理时没法看到未来帧,如果做离线分析可以开双向。取out[:, -1, :]是常见做法,也可以对所有时间步做平均池化,后者对长视频更鲁棒。
4.2 端到端微调的学习率分组策略
端到端训练 CNN+RNN 时,CNN 部分用的是预训练权重,学习率要小;LSTM 部分是随机初始化,学习率要大。如果统一用一个学习率,要么 CNN 被破坏,要么 LSTM 学不动。
cnn_params = list(model.cnn.parameters()) rnn_params = list(model.lstm.parameters()) + list(model.fc.parameters()) optimizer = torch.optim.SGD([ {'params': cnn_params, 'lr': 1e-4}, # 预训练部分小学习率 {'params': rnn_params, 'lr': 1e-3} # 随机初始化部分大学习率 ], momentum=0.9, weight_decay=1e-4)CNN 部分 1e-4,RNN 部分 1e-3,差一个数量级。训练 10 个 epoch 后如果验证集准确率不再提升,可以把 CNN 部分冻结,只调 LSTM。另外要注意,ResNet 的 BN 层在微调时建议设成 eval 模式,用预训练的 running mean 和 var,否则小 batch 下 BN 统计量会跑偏。
提示:离线特征提取时记得把 ResNet 设成 eval 模式并关闭梯度,能省一半显存。
5. 避坑与排查:两条路线各自的血泪经验
5.1 3D CNN 训练 loss 不下降的排查顺序
现象:训练 5 个 epoch 后 loss 还在 4.6 附近震荡,准确率等于随机猜。
原因:最常见的是学习率太大导致梯度爆炸,其次是数据归一化没做对,还有可能是标签没减 1 导致越界。
解决:先把学习率降到 1e-4 跑两个 epoch 看 loss 是否下降;检查Normalize的均值和方差是否和输入匹配;打印几个 batch 的 label 确认范围在[0, 100]。如果都没问题,检查 3D 卷积的 padding 设置,时间维度 padding 为 0 时输出长度会逐层缩短,最后可能变成 1 帧,LSTM 或全连接层输入维度对不上。
5.2 CNN+RNN 验证集准确率远低于训练集
现象:训练集准确率 95%,验证集只有 50%,差距巨大。
原因:过拟合,UCF101 数据量对 ResNet+LSTM 来说偏少;另外数据增强在验证集上没关掉也会导致指标异常。
解决:加大 dropout 到 0.7,加 L2 正则,早停。如果还不行,冻结 CNN 只训练 LSTM,或者用更小的 hidden_dim。检查验证集的 transform 是否误用了 RandomResizedCrop,这个错误很隐蔽,指标会莫名其妙低 20 个点。
5.3 显存溢出但 batch size 已经调到 1
现象:batch size 设为 1 仍然 OOM,报错指向 3D 卷积层。
原因:输入分辨率或帧数太大,3D 卷积的中间激活值占用远超预期。
解决:把输入从 224 降到 112,帧数从 32 降到 16。如果还不够,用torch.utils.checkpoint做梯度检查点,用时间换显存。另外确认没有在训练循环里累积计算图,optimizer.zero_grad()要放在 backward 之前或之后但必须每步调用。
5.4 推理速度慢到无法接受
现象:单条视频推理耗时超过 2 秒,达不到实时要求。
原因:3D CNN 的浮点运算量本来就大,如果没做模型剪枝和量化,CPU 推理基本不可用。
解决:导出 ONNX 后用 TensorRT 或 OpenVINO 加速,FP16 量化能提速 2 到 3 倍。如果对精度要求不高,把 3D CNN 换成 CNN+RNN 加轻量 backbone(如 MobileNetV3),推理速度能到 200ms 以内。
5.5 类别不平衡导致少数类全错
现象:整体准确率 70%,但某些类别(如「吹笛子」)准确率接近 0。
原因:UCF101 各类别样本数在 100 到 200 之间,不算严重不平衡,但如果训练时采样没做分层,少数类会被淹没。
解决:用WeightedRandomSampler按类别频率倒数采样,或者在 loss 里给少数类更高权重。更简单的做法是确保每个 batch 里每个类别至少出现一次,用DataLoader的sampler参数控制。
6. 从 70% 到 85%:两个提点技巧和验证方法
第一个技巧是多片段采样推理。训练时每个视频只抽 16 帧,推理时把视频均匀分成 4 段,每段抽 16 帧,分别过模型后对 logits 取平均。这个做法几乎不增加训练成本,但推理阶段能稳定提升 3 到 5 个点。代码上只需要在推理循环里加一层片段循环,把outputs累加后除以片段数。
def multi_clip_inference(model, video_tensor, num_clips=4): """video_tensor: (T_total, C, H, W),均匀切分后分别推理""" model.eval() T = video_tensor.shape[0] clip_len = T // num_clips logits_sum = 0 with torch.no_grad(): for i in range(num_clips): clip = video_tensor[i*clip_len:(i+1)*clip_len] clip = clip.permute(1, 0, 2, 3).unsqueeze(0).cuda() logits_sum += model(clip) return (logits_sum / num_clips).argmax(dim=1)第二个技巧是用光流分支做双流融合。3D CNN 和 CNN+RNN 本质上都在学 RGB 表观特征,对运动剧烈但外观相似的类别区分力有限。加一个光流分支,用 TV-L1 算法预计算光流图,单独训一个 3D CNN,推理时两个分支的 softmax 输出加权平均,权重 0.6 给 RGB、0.4 给光流。这个方案在 UCF101 上能到 88% 左右,代价是预处理时间翻倍。
验证方法上,不要只看 top-1 准确率。用混淆矩阵看哪些类别容易混,UCF101 里「篮球投篮」和「篮球运球」经常互错,这时候针对性加这两类的训练样本比调参有效。另外用 t-SNE 可视化倒数第二层的特征分布,如果同类特征散得很开,说明模型还没学好,继续训;如果类间边界清晰但准确率低,可能是分类头的问题。
我自己踩过最深的一个坑是:花了三天调 3D CNN 的学习率,最后发现是抽帧脚本里cv2.CAP_PROP_POS_FRAMES定位不准,导致很多视频抽出来的帧全是黑的。所以每次换数据集或换解码库,先肉眼检查 20 个样本的帧图,比看 loss 曲线管用。希望帮到你。
本文还有配套的精品资源,点击获取