简介:面向计算机视觉与深度学习学习者,这份资源将UCF101视频动作识别数据集与CRNN模型结合,完整覆盖了视频帧处理、时空特征提取、动作分类和结果预测等实验环节。UCF101包含101类动作、约1.3万个视频,背景复杂且视角多变,CRNN则通过卷积与循环网络分别建模空间和时间特征,二者搭配适合作为动作识别入门及基线复现项目。包内共38个文件,以.ipynb实验笔记和.py脚本为主,辅以.npy保存的训练/测试损失与分数、.pkl输出的预测结果、.png可视化图片等,压缩包大小仅2.57MB,结构清晰且便于下载阅读。目前已有1146人学习使用,适合需要复现动作识别基线、分析CRNN时空建模能力的初学者和研究者。资源中的训练过程数据和预测结果可以帮助你观察模型在120轮迭代下的收敛趋势,结合错误预测样本来定位识别失败的动作类别,进而调整数据增强、正则化或网络结构,有效降低项目复现门槛。 视频动作分类这个领域,UCF101基本是跑不掉的“标准考卷”。不过我发现很多人拿到这套数据集,第一反应就是套个现成的3D卷积网络直接开训,结果不是精度卡在60%上下不去,就是训练过程各种报错。我自己用PyTorch搭CRNN模型在UCF101上做动作识别,从数据预处理到最终调参花了差不多三周,把整个过程踩过的坑、试错后的正确做法整理出来,这篇东西应该能让准备动手的朋友少走不少弯路。
这篇文章适合两类人:一是刚接触视频分类、想用UCF101做第一个完整项目的学生或初级算法工程师;二是已经在用纯CNN或者C3D跑UCF101、但觉得精度和训练效率都不够理想,想尝试CRNN路线的人。文章不会贴完整工程代码,但核心模块的写法逻辑和关键参数都会讲到,照着思路走完全能自己复现。
1. UCF101数据集:先搞懂这张“考卷”的规则再做题
1.1 数据集结构和容易被忽略的细节
UCF101一共包含101个动作类别、13320个视频片段,来源是YouTube上的真实场景视频。相比HMDB51那种小而杂的数据集,UCF101的类别划分更清晰,运动特征也更丰富,所以学术界约定俗成拿它当基准测试集。
第一次用这个数据集,大多数人会直接下载压缩包解压,然后开始写代码。但有几个细节我得先说明白,它们会影响你后面的数据管道设计:
第一,视频分辨率和帧率并不统一。虽然大部分视频是320×240左右,但实际编码格式、码率差别很大,有些视频甚至带有黑边。如果你不做统一处理,直接用OpenCV逐帧读,会遇到部分视频读不出来或读出来是花屏的情况。
第二,数据集的directory结构是按类别组织的,也就是UCF101/ApplyEyeMakeup/v_ApplyEyeMakeup_g01_c01.avi这种路径。文件名里的g01和c01代表分组和摄像头角度,这两个信息可以用来做更严格的数据划分,防止同一个视频的不同片段泄漏到训练集和验证集。
第三,UCF101官方提供了三个train/test split文件(split1、split2、split3),每个split大约有9500个训练视频和3700个测试视频。这三个split的划分方式是官方固定的,学术论文里报告结果也分别报告三个split的准确率再取平均。很多人图省事自己随机划分训练集,这样做出来的结果没法和其他人的工作横向对比,我不建议。
1.2 帧采样策略决定训练效率和模型上限
视频数据不能像图像一样整段扔进模型,核心原因是显存和时间成本。业界通用的做法是稀疏采样:从视频中均匀抽取N帧(常见是8、16、32帧),然后用这N帧代表整个视频。
这里有一个关键权衡:采样帧数越少,训练越快,但容易丢动作信息;采样帧数越多,准确率会上升,但训练时间几乎线性增长。我在实际项目中测试,UCF101上用CRNN模型,8帧的准确率大概比16帧低3到5个百分点,但训练时间节省一半以上。如果你算力有限,先用8帧跑通流程,再逐步加帧数,这是最稳妥的路线。
另一个经验是采样时刻的随机性。训练时不要每次都取固定位置的帧,而是从视频中随机选一个起始点,再均匀取N帧。这样相当于做了时间维度的数据增强,能明显提升模型的泛化能力。测试时再固定取均匀采样即可。
def sample_frames(video_path, num_frames=16, train=True): cap = cv2.VideoCapture(video_path) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if train: # 随机偏移 + 均匀采样 max_start = max(total - num_frames, 1) start = random.randint(0, max_start) indices = np.linspace(start, start + num_frames - 1, num_frames, dtype=int) else: indices = np.linspace(0, total - 1, num_frames, dtype=int) frames = [] for idx in indices: cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ret, frame = cap.read() if not ret: frame = np.zeros((240, 320, 3), dtype=np.uint8) frames.append(frame) cap.release() return frames这里有一个很隐蔽的坑:cap.set(cv2.CAP_PROP_POS_FRAMES, idx)在OpenCV的某些版本对部分AVI编码的视频定位不准确,会跳到错误帧。如果你的训练loss震荡异常,可以考虑用decord或PyAV替代OpenCV做视频读取,读取稳定性和性能都会有明显改善。
2. 为什么视频动作分类选了CRNN而不是纯CNN或C3D
2.1 纯CNN处理视频的根本缺陷
很多人第一次尝试视频分类,会想到把每一帧送到ResNet里提取特征,然后把所有帧的特征做平均池化,再过全连接层输出分类。这种做法其实是将视频当成一堆独立图片,完全没有建模帧与帧之间的时序关系。
问题在于,许多动作类别的判别信息恰恰在“运动的模式”中,而不是单帧的外观里。比如“挥拳”和“鼓掌”,单看某一帧可能非常相似,但放到时间轴上,一个是手臂大幅度、低频率运动,一个是手腕小幅度、高频率运动,时序特征才是区分它们的关键。
纯CNN平均池化相当于把所有帧的特征混在一起,时序结构全丢失了,准确率自然会受限。UCF101上用纯2D CNN + 平均池化的基线,通常只能到75%~80%左右,就有这个原因。
2.2 CRNN的思路:用CNN提空间特征,用RNN建模时序
CRNN的结构很直观:先用一个2D CNN(ResNet18/34,或轻量级的VGG11)逐帧提取空间特征,然后把一个视频片段所有帧的特征序列送给RNN,最后利用RNN的输出做分类。
其中CNN部分相当于眼睛,负责看懂每张画面里有什么;RNN部分相当于短时记忆,负责串联起这些画面之间发生了什么变化。两者分工明确,训练也相对稳定,不像C3D那样深层3D卷积对显存和算力的要求那么苛刻。
我选择的是ResNet18 + BiLSTM这种组合,完整结构如下:
- 输入:16帧连续或稀疏采样的图像,size为
[batch, 16, 112, 112, 3] - CNN特征提取:每帧单独经过ResNet18的卷积部分,输出feature map
- 空间池化:对每帧的feature map做全局平均池化,得到512维向量
- 时序建模:将这16个512维向量组成序列,输入单层BiLSTM(hidden size 256)
- 特征融合:BiLSTM最后时间步的前向隐状态 + 初始时间步的反向隐状态拼起来
- 分类层:Dropout后接全连接层,输出101个类别的得分
2.3 单向LSTM还是双向LSTM,这很关键
双向LSTM在UCF101上的效果几乎总是优于单向LSTM,原因在于动作的判别信息不一定只落在序列尾部。有些动作,比如“跳远”,关键判别信息可能在起跳的瞬间;有些动作,比如“拉小提琴”,运动模式则贯穿始终。双向结构让模型能同时看到当前时刻前后的信息,特征表达更完整。
代价是计算量翻倍,但LSTM部分在整个CRNN模型里的计算占比相对CNN要小得多,所以这波“加量”非常划算。我用双向LSTM后在split1上提升了大概3个百分点。
不过要注意,BiLSTM在训练时的batch_first设置以及初始隐状态的处理很容易写错。建议在定义时固定batch_first=True,并把LSTM的dropout层设为0.5(仅在层数大于1时生效,单层LSTM需要自己在输出后加Dropout),否则容易加了个寂寞。
3. PyTorch实现CRNN:数据装载、模型定义、训练循环的核心写法
3.1 Dataset和DataLoader:性能瓶颈往往在这里
视频数据的Dataset实现比图像复杂得多,核心问题是I/O。UCF101视频是AVI压缩格式,如果每次__getitem__都现读视频、逐帧解码,CPU会被大量占用,GPU经常处于饥饿等待状态。
我建议做离线抽帧缓存,训练前把每个视频的采样帧提取出来存成.npy或高效图片格式(JPEG质量压缩到90即可)。虽然UCF101全部抽帧会占不少磁盘空间,但换来的是训练速度成倍提升。128GB可用磁盘是底线,如果不够,就只缓存当前split的训练集和测试集,省着点用。
DataLoader的num_workers建议设为CPU核心数的一半,同时设置prefetch_factor=2。我用8核CPU时,num_workers=4时数据加载速度已经能满足大部分GPU的训练需求;盲目调大反而会因CPU频繁切换进程导致性能下降。
标签映射是新手常踩的坑:UCF101类别标签是从1到101,不是从0到100。直接读文件夹名排序并enumerate时,ApplyEyeMakeup会排在第0位,但官方split文件里的标签是从1开始的。你需要在加载split文件时,将标签列整体减一,或者建立从文件名到0-index标签的映射字典,否则验证集的准确率会突然掉到1%以下。
3.2 模型各模块之间的维度对齐
CRNN在代码实现上最麻烦的维度问题,在于CNN特征序列进入LSTM前的形状变换。很多人在这儿写崩过,我给出一个实测可用的小模型实现思路:
class CNNEncoder(nn.Module): def __init__(self, pretrained=True): super().__init__() resnet = models.resnet18(pretrained=pretrained) self.features = nn.Sequential(*list(resnet.children())[:-1]) # 去掉最后的全局池化和fc self.embed_dim = 512 def forward(self, x): # x: [B, T, C, H, W] -> [B*T, C, H, W] B, T = x.size(0), x.size(1) x = x.view(B * T, *x.size()[2:]) feat = self.features(x) # [B*T, 512, 1, 1] feat = feat.view(B, T, self.embed_dim) return feat # [B, T, 512] class CRNNClassifier(nn.Module): def __init__(self, num_classes=101): super().__init__() self.encoder = CNNEncoder() self.lstm = nn.LSTM( input_size=512, hidden_size=256, num_layers=1, batch_first=True, bidirectional=True ) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(512, num_classes) def forward(self, x): feat_seq = self.encoder(x) # [B, T, 512] lstm_out, _ = self.lstm(feat_seq) # [B, T, 512] final = torch.cat([lstm_out[:, -1, :256], lstm_out[:, 0, 256:]], dim=1) return self.fc(self.dropout(final))BiLSTM的lstm_out[:, -1, :256]取的是前向层最后一个时间步的隐状态,lstm_out[:, 0, 256:]取的是反向层第一个时间步(也就是序列末尾)的隐状态,两者拼接才能完整利用双向信息。直接用lstm_out[:, -1, :]会把反向层最后推演到的时间步拿进来,语义就乱了,这一点务必注意。
3.3 训练循环中的三个隐性要求
BCEWithLogitsLoss还是CrossEntropyLoss?UCF101是单标签多分类问题,用标准的nn.CrossEntropyLoss()就够了,不需要Idea多标签那套。唯一要留意的类别不平衡问题并不严重,UCF101绝大多数类别视频数量在100~150之间,极个别类别稍少一点,不经过加权也能正常收敛。
Batch Size方面,16帧输入、112×112分辨率,ResNet18作为backbone时,12GB显存可以放batch size 32。如果调整到224×224分辨率,batch size要降到8左右。先用小分辨率跑通逻辑,再升级大分辨率,是性价比最高的路径。
优化器选择AdamW,初始学习率设为1e-4到3e-4之间,权重衰减设1e-4。视频分类任务的数据复杂度远高于单帧图像分类,学习率设置太高很容易出现训练loss下降很快但验证集精度不涨的情况。
4. 提升UCF101准确率的实战调参经验
4.1 预训练权重怎么用才有效
使用在ImageNet上预训练的ResNet18作为CNN前端,能让模型起步准确率远高于随机初始化。但怎么用预训练权重有讲究。
我的做法是:全网络训练的前5个epoch,冻结ResNet的layer1和layer2,只训练layer3、layer4和后面的LSTM、全连接层。这相当于先用偏底层的通用视觉特征去适配UCF101,防止高层参数剧烈震荡。5个epoch后解冻所有层,用较小学习率微调全部参数。
这个策略在UCF101上约能带来2~3个百分点的提升,理由在于UCF101的视频帧内容与ImageNet的自然图像分布差距不大,底层特征迁移性很强,强行从零学反而容易过拟合到特定动作的外观上。
4.2 数据增强:空间增强很重要,时序增强别乱用
空间增强方面,训练时随机裁剪到112×112(或选择的输入分辨率)然后用RandomHorizontalFlip做水平翻转,这两个组合几乎是白拿的提升。测试时用中心裁剪。
有人问要不要做颜色抖动、随机灰度化,我在UCF101上的实测增益很小,反而可能让模型学到不够鲁棒的颜色特征。原因可能是UCF101背景相对复杂,颜色增强物带来的扰动被背景噪声淹没了。
时序增强方面,随机采样起始点就够了,不建议使用时间反序、时间裁剪这种操作。很多动作类别(比如“跳高”和“跳远”)对时间方向非常敏感,反向会制造错误的学习信号。
4.3 学习率调度和训练轮次的选择
我推荐使用OneCycleLR或CosineAnnealingLR。UCF101视频数量不算大,模型又比较重,过长的训练时间容易出现先涨后跌的现象。实测下来,30个epoch配合cosine退火,初始学习率3e-4,最小学习率降到1e-6,split1准确率大约能到85%上下。继续训练到50个epoch,提升可能只有1个百分点出头,要根据自己的时间预算决定。
还有一个很多人忽略的技巧:监控验证集loss,在验证集loss连续5个epoch不下降时保存当前权重作为最优模型,而不是单纯保存最后一个epoch的权重。你会发现验证集准确率的峰值往往出现在训练中段,而不是训练结束时。
4.4 混合精度训练与梯度累积
显存不够时,torch.cuda.amp混合精度训练是最直接的解法。开启AMP后,ResNet和LSTM的计算都会在FP16下进行,显存占用几乎减半,而精度损失在UCF101这个量级通常可以忽略。
LSTM在FP16下有个小坑:如果hidden size设置得比较大,且训练初期存在梯度值极小的时刻,可能触发数值下溢。建议对LSTM层的参数单独保持FP32,只对CNN部分开启AMP,代码上用torch.cuda.amp.autocast()配合model.parameters()区分处理。
梯度累积更适合batch size不敏感的场景,但CRNN内部的LSTM对梯度累积的延迟更新会有一定影响,表现为训练loss波动变大。如果显卡能放下batch size 16以上,就不用梯度累积了,收益不大还徒增调参负担。
5. 模型预测结果解读与排错记录
5.1 先看懂预测结果里“错的”比“对的”更有价值
训练完成后,不要只盯着准确率数字。把预测错误的样本单独抽出来看,是最能发现问题的方式。
我第一次训练时split1准确率到了84%,但看混淆矩阵发现,“HorseRiding”和“Biking”互相混淆严重。原因是这两类动作的帧图像里都有大量相似的自然背景和快速移动的前景主体,CNN提取的空间特征高度接近,时序维度上速度也差不多,模型很难区分。后来我试着把输入帧数从16加到32,这类混淆显著减少,因为更长时间跨度下“骑马”的上下颠簸规律和“骑行”的匀速前进规律更容易被LSTM捕捉。
预测置信度方面,可以用torch.topk(preds, k=2)输出前两个类别的概率。如果很多样本的top1和top2概率非常接近(差距小于0.05),说明模型对某些类别语义把握不够,可以考虑增大这部分类别的采样频率。
5.2 训练集精度高、验证集精度低的典型原因
这是避坑环节的重灾区。我总结过UCF101项目中最常见的过拟合信号:
第一,显存不够时直接在视频帧上做全局平均池化,模型只能靠背景等表面线索做判断。比如“HighJump”类别的很多训练样本都带横杆,模型把“横杆”当成了判类依据,测试集换场景就崩。解决方法是增加空间增强的比例,并用测试时中心裁剪之外的多种裁剪做平均预测。
第二,BiLSTM隐层数量过大。我一开始把hidden size设为512,参数量上涨很多,在UCF101上过拟合速度极快。降到256后,准确率不降反升。
第三,没有对BN层做正确的训练/评估模式切换。PyTorch的model.train()和model.eval()直接影响BN层统计量的使用方式。验证或测试时忘了切回eval()模式,BN会用当前batch的统计量推理,验证集准确率会无规律地上下波动。
5.3 视频解码与帧对齐的坑:三种库的取舍记录
OpenCV读不了某些视频怎么办?我遇到的情况是,某些从YouTube下载的AVI文件的编码是MPEG-4 Part 2,OpenCV的VideoCapture虽然能打开,但CAP_PROP_FRAME_COUNT返回的帧数可能比实际多或少,导致采样索引越界。
PyAV(FFmpeg的Python绑定)能稳定处理UCF101里几乎所有视频,解码速度也快,但安装和依赖管理稍有门槛。decord则是轻量级选择,对常见格式兼容性不错,DeepMind也在用。
我的最终方案是:用decord读帧,异常时回退到PyAV,两种库都失败才用OpenCV兜底。这样可以保证13320个视频全部成功读取。这个兜底逻辑看起来笨,但绝对是训练时最省心的做法。
5.4 验证集评估时还有几个“低级但致命”的坑
评估时不开torch.no_grad(),显存暴涨导致OOM;批次大小设置得太大,导致BN统计量在最后一批时异常;忘记对输入像素做归一化;忘了把训练时做的随机裁剪替换成测试时的中心裁剪。这些错误我都犯过,而且它们导致的直接后果是验证集准确率比正常值低10%到20%,非常误导人。
一个正确的测试评估流程应该是:
model.eval() correct = 0 total = 0 with torch.no_grad(): for frames, labels in test_loader: frames = frames.cuda() labels = labels.cuda() outputs = model(frames) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total print(f"Test accuracy: {acc:.4f}")这里还有一个细节:split1的测试集有3783个视频,不算多,但如果你把每个视频的多次随机裁剪全部跑一遍取平均,准确率可以再涨1个百分点左右。前提是时间充足,否则单次中心裁剪的结果也够用了。
最后分享一个我的习惯
每次训练跑完,我会把错误预测的视频片段连同预测得分、真实标签和前三名的预测类别一起存到一个CSV文件里,并且额外生成一段拼接了预测类别文字的“视频配图网格图”。这样后续分析错误模式时完全不用重跑代码,拿着CSV就能快速定位是数据问题,还是模型结构问题,还是类别本身的语义太接近。我建议你也把这一步固化到训练脚本里,省下的时间足够你再跑好几个调参实验了。
UCF101 + CRNN这条路,难的不是模型结构本身,而是数据读取、序列对齐、训练策略这些看起来不起眼但处处埋雷的环节。希望上面的经验能帮你少踩几个坑,早日把split1跑到85%以上。
本文还有配套的精品资源,点击获取