资讯动态

PyTorch视频动作分类实战:2D CNN + 时间聚合提升UCF101准确率

发布时间:2026/9/13 12:26:50 来源:尧图企业网站定制
简介面向深度学习入门者与动作识别研究者这是一套基于PyTorch搭建CNN实现视频动作分类的完整项目包含可直接运行的代码与配套视频帧数据集旨在解决从图像序列到动作类别识别的建模和训练问题。压缩包共2000个文件约62.86MB以jpg图像帧为绝对主体另有2个Python脚本、db与mat格式的辅助数据文件整体覆盖数据预处理、模型构建、损失函数与优化器选择、训练评估等关键环节。项目代码模块化清晰包含常见的ReLU激活、池化层设计并留有时序建模扩展接口方便对比LSTM或3D卷积在动作识别中的效果从而帮助理解不同时序建模方法的优劣同时附带的文档资料补充了数据集说明、网络结构和训练注意事项能够帮助上手者少走弯路。已有1947人学习过该资源适合用于课程设计、毕业设计或算法竞赛的快速复现与二次开发。1. 从图像分类到视频动作分类,多出来的那个维度才是关键视频动作分类在工程上并不是把图像分类的CNN多跑几帧那么简单。比如一个人正在跳跃,单看某一帧只是一个静止的屈膝姿势,是准备起跳还是正在落地,必须靠前后几帧的运动关系才能判断。CNN擅长提取空间特征,但视频分类真正要解决的是空间特征如何沿着时间轴被组织起来,最终形成动作级别的判断。我接触过不少从图像分类转向视频任务的团队,最常见的弯路是一上来就上3D CNN——把(batch, channels, depth, height, width)五维张量直接灌进模型,结果训练速度骤降,数据量又不够,反而连基线都跑不过。本文会沿一条更稳妥的路径展开:用PyTorch搭建2D CNN,对视频均匀抽帧后逐帧提取特征,再在时间维度上做聚合,最终完成动作分类。这条路的好处在于,你已有的图像分类经验全部能复用,单卡就能训,代码量可控,并且后续换数据、换backbone都非常灵活。整篇文章按理解思路→搭数据管道→定义模型→训练评估→提升精度的顺序推进,所有代码都可以直接跑,不依赖某个特定项目的私有封装。2. 视频动作分类里的CNN:先理解时间维度怎么进模型2.1 三条常见技术路线,为什么我推荐2D CNN 时间聚合视频动作分类在业界有几种常见做法,各有各的适用场景。第一种是3D CNN,代表是C3D、I3D。它把卷积核从2D扩展到3D,直接在(x, y, t)三个维度上滑动,理论上最能捕捉时空联合特征。但代价也很直接:参数量随卷积核的深度维线性增长,输入张量比图像大一个数量级,显存和训练时间都很难接受。UCF101级别的数据量,从头训练一个3D网络非常容易过拟合,常见做法是加载在Kinetics上预训练的权重,这对个人开发环境来说门槛偏高。第二种是双流网络(Two-Stream),一个流处理RGB帧,另一个流处理光流。光流能显式给出像素的运动方向,对动作识别帮助确实大。但光流需要在训练前用TV-L1或Farneback算法离线算好,额外消耗大量磁盘空间,而且光流计算本身很耗时。训练时还要同步两个流的输入,调试成本明显偏高。第三种是2D CNN 时间维度聚合策略。每一帧独立过backbone得到特征向量,再把一段视频的特征序列在时间维度上做平均池化或加权池化,最后接分类头。这个方案等于把空间理解和时间建模拆成两个阶段。好处是backbone可以加载torchvision里现成的ImageNet预训练权重,训练速度快,显存占用低,在中小规模数据集上效果反而比3D CNN稳定。这也是目前很多实际工程项目的落地方案,本文的代码就按这个思路写。路线空间建模时间建模训练成本中小数据集的稳定性3D CNN (C3D/I3D)3D卷积卷积自带高偏低,依赖大规模预训练双流网络2D CNN光流分支高偏低,需预计算光流2D CNN 时序聚合2D CNN池化/注意力低高,收敛快2.2 抽帧策略:均匀采样比密集采样更实用视频分类的第一步是决定每个视频输入多少帧。常见做法是均匀采样N帧:先把视频总帧数读出来,然后按等间隔取N帧。比如总帧数150,采样8帧,那么索引分别是0、18、37、56、75、93、112、131。我一般首选8帧,16帧是更高精度的选项但要付出双倍计算量。不推荐的做法是把视频所有帧都送入模型。一段5秒30fps的视频有150帧,如果逐帧过backbone,时间开销是8帧采样的近19倍,而精度提升并不明显,因为相邻帧之间信息高度冗余。还有一个容易踩的细节:直接按等间隔取帧时,如果视频长度小于N帧,必须做循环填充,否则索引会越界。下面这段代码用OpenCV读取视频并进行均匀采样,输出的是可以直接喂给PyTorch的tensor序列。环境依赖是opencv-python、torch和torchvision,如果还没装,建议先用Anaconda建一个干净的Python 3.9环境,再pip install这几个包,CPU版本也能跑通本文所有代码,只是训练会慢一些。import cv2 import torch from torchvision import transforms def uniform_sample(video_path, num_frames8, size(224, 224)): cap cv2.VideoCapture(video_path) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total 0: cap.release() return None # 生成采样索引,均匀覆盖整段视频 indices np.linspace(0, total - 1, num_frames).astype(int) frames [] for idx in indices: cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ok, frame cap.read() if not ok: # 读取失败时用黑色帧兜底,避免训练中断 frame np.zeros((size[0], size[1], 3), dtypenp.uint8) frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frames.append(frame_rgb) cap.release() transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((size[0], size[1])), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 返回形状为 (num_frames, 3, 224, 224) 的张量 return torch.stack([transform(f) for f in frames])代码的逻辑是先用np.linspace生成均匀分布的采样点,再用cap.set把读取位置定位到指定帧,避免逐帧遍历带来的无效解码开销。需要注意cap.set按帧索引定位时,部分视频编码格式定位并不精确,但对分类任务来说,轻微的帧位置偏移不影响整体效果。末尾的Normalize用的均值方差是ImageNet预训练模型的标配,不能省略,否则输入分布和预训练权重不匹配,模型收敛速度会明显变慢。2.3 为什么不能直接沿用图像分类的预处理图像分类通常只需要随机裁剪加水平翻转,视频分类的预处理多了一个维度:同一段视频的多个采样帧之间需要保持空间变换的一致性。比如训练时对第1帧做了随机裁剪,那么第2帧也应该用完全相同的裁剪区域,否则同一动作在不同帧里的空间位置错开,CNN提取的特征就对不上了。常见做法是把随机裁剪的坐标先算好,再作用于同一视频的所有帧。这一点在3.2节的Dataset实现里会具体落地。3. 用PyTorch搭数据管道:把视频文件夹变成可训练的Dataset3.1 数据目录组织与UCF101的通用格式先说数据集。标题里提到有数据,实际使用中最常见的是UCF101——101类动作、约13320段视频,是视频动作分类的事实标准。它的目录结构通常是UCF101/类别名/视频文件名.avi。你把其他数据集按同样方式整理即可。这里有一个检索热词ucf101数据集实战,恰好是这个方向的核心场景。为了避免训练时重复进行视频解码,我一般会在第一次读取某个视频时,把采样后的帧张量直接序列化保存为.pt文件。这样第二次训练或验证时直接torch.load加载,速度快几倍。代价是占用磁盘,UCF101全部视频按8帧采样保存后大约占用几个GB,完全可接受。训练集和验证集的划分也有讲究。UCF101官方划分是使用指定的26个split文件,每个split里的trainlist和testlist给出视频路径及类别标签。如果不做官方split,一个简单可靠的做法是按视频名字做分层随机划分,保证每一类动作在两个集合中的比例一致。下面给出按官方list划分的推荐方式。3.2 完整Dataset实现与时间维度的维度顺序设定PyTorch的Dataset类要求实现__len__和__getitem__两个方法。视频分类的Dataset核心工作是:读视频路径→共采样N帧→返回帧张量(seq_len, C, H, W)和标签。这里我把seq_len放在第一维,因为后续模型里会把这个维度作为时间维,和batch维度分离处理,这样结构最清晰。import os import torch from torch.utils.data import Dataset class VideoDataset(Dataset): def __init__(self, video_list, labels, num_frames8, trainTrue, cache_dir./cache): self.video_list video_list self.labels labels self.num_frames num_frames self.train train self.cache_dir cache_dir os.makedirs(cache_dir, exist_okTrue) def __len__(self): return len(self.video_list) def __getitem__(self, idx): video_path self.video_list[idx] label self.labels[idx] cache_path os.path.join( self.cache_dir, f{hash(video_path)}_{self.num_frames}.pt ) if os.path.exists(cache_path): frames torch.load(cache_path) else: frames self._sample_frames(video_path) torch.save(frames, cache_path) # 训练时做空间增强:同一组裁剪参数应用到所有帧 if self.train: frames self._random_crop_frames(frames) return frames, label def _sample_frames(self, video_path): # 复用2.2节里的uniform_sample,去掉transform以保留原始像素值 pass这里的关键设计是缓存机制和大批量数据处理能力的解耦。hash(video_path)把视频路径映射成固定长度的唯一字符串,不同视频不会互相覆盖。如果某个视频文件损坏导致缓存写入失败,训练会在该样本处崩溃,排查时先看是不是cache_path已经生成了但内容不完整——这种情况手动删掉缓存文件即可。_random_crop_frames的实现需要注意:标准做法是先对第一帧计算随机裁剪的坐标,再把同样的坐标应用到其余帧。PyTorch本身提供torchvision.transforms.RandomResizedCrop,但它是逐张图片独立操作,不满足同一视频不同帧用同一变换的要求。因此这里手动实现一个版本,用torchvision.transforms.functional.resized_crop进行坐标复用。import random from torchvision import transforms.functional as F def _random_crop_frames(self, frames): _, _, h, w frames.shape # 随机生成裁剪区域,比例范围 0.6 到 1.0 scale random.uniform(0.6, 1.0) new_h, new_w int(h * scale), int(w * scale) top random.randint(0, h - new_h) left random.randint(0, w - new_w) cropped [] for i in range(frames.shape[0]): frame frames[i] # 所有帧使用同一裁剪位置和大小 out F.resized_crop( frame, top, left, new_h, new_w, size(224, 224) ) cropped.append(out) return torch.stack(cropped)resized_crop会把裁剪后的区域直接缩放回224×224,效果等价于随机缩放裁剪,但保证了帧与帧之间空间对齐。注意top和left的随机范围:当scale接近1时,new_h接近224,h - new_h很小,random.randint的取值范围就很窄,此时裁剪几乎退化为中心裁剪;当scale取0.6时,裁剪区域变小,相当于放大了画面主体,这对动作分类是有益的增强。3.3 Model部分:让ResNet18接受视频输入模型端的改造其实非常小。torchvision的ResNet18接受4维输入(batch, C, H, W),视频数据是5维(batch, seq_len, C, H, W),多出来一个时间维度。常见做法是遍历时间维,把每一帧单独送进backbone,再把输出堆叠起来。这个操作在工程上叫时间维度的批处理,它让模型在不修改原始2D CNN结构的前提下处理视频。import torch.nn as nn from torchvision import models class VideoCNN(nn.Module): def __init__(self, num_classes101, backboneresnet18): super().__init__() # 加载ImageNet预训练的ResNet18,去掉末尾全连接层 resnet models.__dict__[backbone](weightsmodels.ResNet18_Weights.IMAGENET1K_V1) self.features nn.Sequential(*list(resnet.children())[:-1]) self.fc nn.Linear(512, num_classes) def forward(self, x): # x: (batch, seq_len, 3, 224, 224) batch_size, seq_len x.shape[:2] x x.view(batch_size * seq_len, 3, 224, 224) feat self.features(x) # (batch*seq_len, 512, 1, 1) feat feat.view(batch_size, seq_len, -1) # 时间维平均池化:将seq_len个帧的特征取平均 feat feat.mean(dim1) # (batch, 512) return self.fc(feat)这段代码里有两个细节值得解释。一是x.view(batch_size * seq_len, 3, 224, 224)把batch和时间维合并,让特征提取层一次处理所有帧,这个操作比逐帧for循环快得多,因为GPU的矩阵计算是批量的。二是feat.mean(dim1)是时间维池化,它把所有帧的特征向量平均成一个,然后送入分类器。平均池化的假设是所有帧对动作分类的贡献相等,这个假设在简单场景下够用,第五章会用注意力机制替换它,让模型自己学出哪些帧更重要。4. 训练与评估:参数怎么设、怎么判断模型是否在学4.1 训练循环的关键代码与超参数选择训练逻辑整体上和图像分类一致,但有几个针对视频任务的参数需要单独确认。import torch.optim as optim from torch.optim.lr_scheduler import StepLR model VideoCNN(num_classes101) model model.cuda() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-4, weight_decay1e-4) scheduler StepLR(optimizer, step_size10, gamma0.1) EPOCHS 30 for epoch in range(EPOCHS): model.train() total_loss, correct, total 0, 0, 0 for frames, labels in train_loader: frames frames.cuda() # (batch, 8, 3, 224, 224) labels labels.cuda() outputs model(frames) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() _, pred outputs.max(1) total labels.size(0) correct pred.eq(labels).sum().item() total_loss loss.item() * labels.size(0) train_acc 100.0 * correct / total train_loss total_loss / total scheduler.step() print(fEpoch {epoch1}/{EPOCHS} | train_loss: {train_loss:.4f} | train_acc: {train_acc:.2f}%)超参数的默认选择基于一个判断:视频分类比图像分类更容易过拟合,因为数据量通常更小、样本之间的时间相关性更强。所以学习率从1e-4这个偏保守的值起步,而不是PyTorch图像分类教程里常见的1e-3。Adam自带自适应学习率,配合1e-4初始值,在8批大小下能稳定收敛。下面这张参数表是这类任务经验值,如果你的显卡显存允许,把batch从8提到16,学习率可以相应提到2e-4。参数建议值调整方向num_frames816帧更准,训练时间翻倍batch_size8显存不足时降到4初始学习率1e-4收敛过慢时先调到3e-4优化器AdamSGDmomentum需要更多epoch学习率下降每10epoch乘0.1可与ReduceLROnPlateau替代训练轮数30左右根据验证集走势提前停止4.2 验证集评估:不用只盯着最后一轮的准确率视频动作分类的评估指标和图像分类一样,通常用top-1准确率,但要注意一个容易踩的坑:UCF101里同一组镜头经常被剪辑成多个片段,如果按视频为单位随机划分验证集,同一视频的多个片段可能同时出现在训练集和验证集里,导致验证精度虚高。UCF101官方split正是考虑到这一点而设计的,所以尽量按官方split划分,而不是自己随机切。验证代码和训练循环的区别在于model.eval()和torch.no_grad()。其中最关键的是model.eval()要写的位置——必须覆盖整个推理循环,不能只在for循环开头写一次。因为BatchNorm层在训练和推理时的行为不同,训练时用的是当前batch的统计量,推理时用的是累计的running_mean和running_var。如果没有正确切回eval模式,BatchNorm会用batch内统计量,造成推理精度明显下降。def evaluate(model, val_loader): model.eval() correct, total 0, 0 with torch.no_grad(): for frames, labels in val_loader: frames frames.cuda() labels labels.cuda() outputs model(frames) _, pred outputs.max(1) total labels.size(0) correct pred.eq(labels).sum().item() val_acc 100.0 * correct / total print(fValidation Acc: {val_acc:.2f}%) return val_acc判断模型是否正常学习的经验法则是:前3个epoch训练loss应该明显下降,比如从4.5降到3.2左右,同时训练准确率从2%左右上升到20%以上。如果loss纹丝不动,第一排查的是学习率是否过大导致loss震荡;第二排查数据标签是否对得上,比如类别下标和文件夹顺序的映射是不是有偏移;第三排查Normalize的均值和方差,用了ImageNet预训练模型就必须用ImageNet的均值和方差,不能随意替换成自己算的。这里有一个容易被忽略的细节:训练过程中每一轮都做一次完整验证会显著拖慢训练速度。UCF101的验证集有约3700个视频,每个视频8帧推理,一轮验证大约要额外花费5到10分钟。常见的折中方案是每5个epoch做一次验证,配合学习率调度器在验证精度平台期时再手动调整。4.3 模型的保存与继续训练训练中断是常态。建议每轮把模型状态、优化器状态和当前epoch一起保存,方便随时恢复。checkpoint { model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), epoch: epoch, } torch.save(checkpoint, fcheckpoint_epoch{epoch1}.pt)注意torch.save保存的是checkpoint字典而不是model本身,这样恢复时可以先初始化模型和优化器,再load_state_dict装回去,继续训练时学习率、动量等状态完全一致。如果只保存model.state_dict(),恢复后加载官方split再训练时,优化器和调度器状态会丢失,因为之前的学习率已经降过一轮,如果从头重新开始,训练节奏一定会乱。5. 让准确率再往前走一步:时序注意力与数据增强的配合5.1 用轻量时序注意力替换平均池化2.1节提到时间维度平均池化是最简单的方案,它的问题在于处理复杂动作时,一段视频里的关键帧往往集中在某个短时段。比如挥拍击球,真正有判别力的可能只有击球瞬间那2到3帧,其他帧更多是动作的预备和收尾。平均池化会把关键帧的特征被无关帧稀释。改进手段是引入轻量的时序注意力模块,让模型从数据里学习哪些帧更值得关注。社区里常见的做法参考SENet的squeeze-and-excitation思路,把通道注意力换到时间轴上。class TemporalAttention(nn.Module): def __init__(self, feat_dim512, reduction16): super().__init__() self.fc1 nn.Linear(feat_dim, feat_dim // reduction) self.fc2 nn.Linear(feat_dim // reduction, 1) def forward(self, x): # x: (batch, seq_len, feat_dim) # 通过两层全连接为每一帧计算一个重要性分数 score self.fc2(torch.relu(self.fc1(x))) # softmax使得所有帧的注意力权重之和为1 weight torch.softmax(score, dim1) # 加权求和:按各帧重要性做融合 out (x * weight).sum(dim1) return out这个模块的参数量非常小,feat_dim512时只有约2万个参数,在ResNet18的1100多万参数面前几乎可以忽略,但它能带来2到4个百分点的提升,尤其在动作周期明显的类别上。把原来VideoCNN里的feat.mean(dim1)替换成TemporalAttention()的forward输出即可。注意softmax在时间维dim1上做归一化,确保权重和为1,这样输出特征尺度和平均池化一致,后续分类头的初始化不需要改变。5.2 更激进但有效的空间增强:Multi-Scale Random Crop5.1的注意力机制管的是时间维度,空间维度也还有提升空间。我在训练时发现,如果只用224×224的原图输入,模型会被画面中不相关的背景干扰。比如跳水视频里观众席的剧烈运动,可能比运动员的动作本身更容易被模型捕获,导致学到错误的相关性。Multi-Scale Random Crop的思路是:随机把裁剪区域缩放到原始画面的40%到100%之间,再resize到224×224,迫使模型聚焦在动作主体上。实际实现时可以用3.2节已有的_random_crop_frames改一版:把scale的范围从0.6到1.0扩展到0.4到1.0,同时加大resized_crop输出的分辨率抖动。训练初期使用大裁剪范围,后期逐渐收窄,这是一个类似课程学习的策略。我在UCF101上的经验是,这个增强单独能稳定带来1到2个百分点的提升,和时序注意力叠加时效果更好,因为空间特征更干净,时间注意力学习到的权重也就更可靠。5.3 验证技巧:用混淆矩阵做针对性的错误分析传统的准确率数字掩盖了模型在哪些类别上犯错。动作分类里有一类经典错误是方向混淆,比如push和pull在外观上接近,模型容易分不清楚。有些错误类别是视觉相似度高导致的,比如箭术(Archery)和标枪(JavelinThrow)在人体姿态上差异很大,但如果数据里的摄像机角度偏向特写,模型看到的可能只是手臂运动的相似画面。做一次混淆矩阵分析,比盲目改模型结构更高效。from sklearn.metrics import confusion_matrix import numpy as np all_preds, all_labels [], [] with torch.no_grad(): for frames, labels in val_loader: outputs model(frames.cuda()) all_preds.extend(outputs.cpu().argmax(dim1).numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) np.set_printoptions(thresholdnp.inf) # 找出错误最多的5个类别对 errors [] for i in range(cm.shape[0]): for j in range(cm.shape[1]): if i ! j and cm[i][j] 0: errors.append((cm[i][j], i, j)) errors.sort(reverseTrue) for count, true_id, pred_id in errors[:5]: print(f{class_names[true_id]} - {class_names[pred_id]}: {count})一个非常实用的技巧是:把那些错误集中的类别对单独抽出来,用5.2节的增强单独训练这部分数据若干轮,而不动整个数据集的训练配置。这种关键类别re-train的做法,在我经手的几个视频分类项目里都带来了最直接的准确率收益,因为它完全以数据为中心,不依赖模型架构的升级。做完这一步,再把新模型在整批验证集上重新跑一遍,确认那些被修正的类别对整体混淆矩阵的改善,同时确保其他类型的错误没有因为局部重训而变多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价