ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch实现3D CNN视频动作分类:从UCF101到完整训练流程

PyTorch实现3D CNN视频动作分类:从UCF101到完整训练流程 简介面向PyTorch学习者和计算机视觉入门者这份资源提供了基于CNN的视频动作分类完整项目配套真实数据集与可直接运行的Python代码。项目覆盖数据预处理、模型构建、损失函数与优化器选择、训练验证及评估测试全流程其中预处理部分包含帧提取、尺寸统一、像素归一化等环节模型部分兼顾2D卷积加LSTM与3D卷积两种时序建模思路便于对比理解。压缩包共2000个文件以jpg图像帧为主另含py训练测试脚本、db数据库和mat数据文件整体大小62.86MB既保留了视频帧序列又控制了包体体积适合本地复现。已有1947人学习使用代码按数据加载、模型定义、训练、评估分模块组织拿到后按说明即可运行出分类结果还可以在此基础上调整网络结构或换用自有数据集进一步扩展实验。1. 为什么用3D CNN做视频动作分类把视频分类当成“多帧图像分类”来做是很多入门者踩的第一个坑。单帧送入ResNet得到每帧的类别概率再对帧结果投票这种方案在镜头固定、动作简单的场景下勉强可用但一旦出现“拿起杯子”和“放下杯子”这类依赖时间顺序的动作2D CNN根本区分不了。视频动作分类的核心是同时建模空间外观和时间动态这正是3D CNN的用武之地。本篇文章围绕“有数据、有代码、可直接运行”这条主线以UCF101为基准数据集从视频抽帧、3D卷积网络搭建、训练配置到推理优化给出完整可复现的PyTorch实现路径。无论你是刚接触PyTorch的入门者还是有图像分类经验、想扩展到视频领域的工程师这篇文章会把每步的输入张量shape、参数含义和踩坑点都交代清楚。你不需要读懂全部论文按顺序执行就能跑出一个可用的动作分类训练流程。2. 数据准备从UCF101原始视频到可喂给CNN的帧序列2.1 环境配置与依赖安装视频分类项目对运行环境的要求比图像分类高一些核心依赖包括PyTorch、torchvision、OpenCV和NumPy。建议直接创建独立的conda环境避免污染现有的Python环境。conda create -n video_classify python3.8 conda activate video_classify # CPU版本 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # GPU版本按机器实际情况选择CUDA版本 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python numpy tqdm参数说明python3.8是为了兼容性PyTorch 2.x对3.8到3.11都支持选3.8更稳妥。CPU版本的安装速度比GPU版本快显存不足的机器一样能跑通训练流程只是batch size要调小。--index-url指定了PyTorch官方wheel源不会误装成源码包导致import失败。OpenCV用来抽帧tqdm用来显示训练进度条。安装完成后用下面命令验证GPU是否可用。这步不要跳过跑训练时才发现设备不对会非常浪费时间。import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else CPU)2.2 UCF101数据集结构与标签对齐UCF101是视频动作分类领域最常用的中等规模数据集包含101个动作类别、共13320个视频分辨率统一为320x240每个视频时长从几秒到几十秒不等。与ImageNet相比它的规模不大单卡显卡就能完成训练非常适合作为学习3D CNN的起点。数据集的原始目录结构是这样的UCF101/ ├── ApplyEyeMakeup/ │ ├── v_ApplyEyeMakeup_g01_c01.avi │ └── v_ApplyEyeMakeup_g01_c02.avi ├── ApplyLipstick/ │ └── v_ApplyLipstick_g01_c01.avi └── ...UCF101官方同时提供三份训练集/测试集划分文件每行是一段视频的相对路径例如ApplyEyeMakeup/v_ApplyEyeMakeup_g01_c01.avi 1最后这个数字是从0开始的动作类别编号。但是不保证每份划分文件的编号方案都一致所以不要直接用文件的顺序当作类别标签。更可靠的写法是枚举类别文件夹按文件夹名称排序后映射到索引值。import os DATA_ROOT UCF101 class_names sorted(os.listdir(DATA_ROOT)) class_to_idx {name: idx for idx, name in enumerate(class_names)}这段代码的逻辑是先用sorted对文件夹名排序保证在Windows和Linux上得到相同的类别顺序然后建立名称到索引的映射。101这个数字不要直接硬编码在代码里用len(class_names)获取防止数据集被替换成其他版本时出现越界。2.3 视频抽帧与片段采样策略视频不能整段直接送入CNN显存放不下而且连续帧之间的差异很小存在大量冗余。常见做法是从每个视频中均匀采样多个短视频片段每个片段包含固定数量的帧。UCF101的视频帧率是25fps一个5秒的视频有125帧训练时通常只取16或32帧作为一个clip。import cv2 import numpy as np def sample_clip(video_path, num_frames16, sample_rate2): cap cv2.VideoCapture(video_path) total_frames int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) cap.release() # 从视频中均匀选取片段起始位置 clip_len num_frames * sample_rate if total_frames clip_len: start 0 else: start np.random.randint(0, total_frames - clip_len) frames [] cap cv2.VideoCapture(video_path) cap.set(cv2.CAP_PROP_POS_FRAMES, start) for i in range(clip_len): ret, frame cap.read() if not ret: break if i % sample_rate 0: frame cv2.resize(frame, (224, 224)) frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frames.append(frame) cap.release() return np.stack(frames)参数说明num_frames16是每个clip的帧数这个值是速度与精度的折中16帧是3D CNN最常用的输入长度。sample_rate2表示每2帧取1帧这样16帧实际覆盖了视频中的32帧。np.random.randint保证了每个epoch采样到的起始位置不同等同于在时间轴上做了数据增强。cv2.CAP_PROP_POS_FRAMES是OpenCV跳帧读取的接口比逐帧读取再丢弃快得多。注意cap.read()返回的frame是BGR顺序转换成RGB是为了与torchvision预训练模型的输入约定对齐。np.stack(frames)得到的数组shape是(16, 224, 224, 3)训练时还需要转换成(C, T, H, W)格式。2.4 自定义Dataset类与DataLoader配置抽帧逻辑写好后需要封装进torch.utils.data.Dataset让DataLoader能够自动完成batch组装、多进程读取和shuffle。from torch.utils.data import Dataset import torch class VideoDataset(Dataset): def __init__(self, video_list, class_to_idx, num_frames16, sample_rate2): self.samples [] for line in video_list: rel_path, _ line.strip().split() label class_to_idx[rel_path.split(/)[0]] self.samples.append((os.path.join(DATA_ROOT, rel_path), label)) self.num_frames num_frames self.sample_rate sample_rate def __len__(self): return len(self.samples) def __getitem__(self, idx): video_path, label self.samples[idx] frames sample_clip(video_path, self.num_frames, self.sample_rate) # (T, H, W, C) - (C, T, H, W) frames torch.from_numpy(frames).permute(3, 0, 1, 2).float() frames frames / 255.0 return frames, label这里有一个容易出错的地方class_to_idx[rel_path.split(/)[0]]是根据视频路径中的第一级目录名找到类别索引而不是用划分文件里自带的数字。如果划分文件是Windows格式的路径分隔符需要先做一次替换。(T, H, W, C)转(C, T, H, W)这一步非常关键。很多新手直接沿用图像分类的(B, C, H, W)习惯把帧序列放在了channel维度后面。3D卷积的输入约定是(B, C, T, H, W)时间维必须夹在channel和height之间。frames / 255.0是归一化如果要使用torchvision预训练模型还要额外用ImageNet的mean/std做标准化。3. 模型搭建从ResNet2D扩展到3D CNN3.1 为什么普通CNN做不了视频分类2D CNN在图像分类上的统治力毋庸置疑但它不具备时间维度的建模能力。把视频帧逐张送入2D CNN得到的是每帧独立的特征图帧与帧之间的关联完全丢失。动作识别的关键恰恰在于时序上的变化模式挥拍这个动作重要的是球拍从下到上的运动轨迹而不是某一帧里球拍的静态位置。3D CNN的卷积核在空间维和时间维同时滑动。一个形状为(C_in, C_out, 3, 3, 3)的3D卷积核会在T维度上也做窗口滑动输出特征图的shape为(C_out, T_out, H_out, W_out)其中T_out由卷积核的时间深度和stride决定。这样网络从第一层开始就能捕捉到局部时序模式。当然3D CNN的代价是计算量和参数量大幅上升。同样是ResNet-18的结构换成3D卷积后FLOPs大约是2D版本的3到4倍。所以视频模型对显存的要求更高代价换来的是动作分类任务上显著的准确率提升。3.2 手写一个轻量级3D CNN不依赖任何预训练权重用PyTorch原生模块就能搭出一个可训练的3D CNN。下面这个结构是C3D风格的简化版适合先跑通流程再替换成更强的backbone。import torch.nn as nn class Simple3DCNN(nn.Module): def __init__(self, num_classes101, num_frames16): super().__init__() self.conv1 nn.Sequential( nn.Conv3d(3, 32, kernel_size(3, 3, 3), padding(1, 1, 1)), nn.BatchNorm3d(32), nn.ReLU(inplaceTrue), nn.MaxPool3d(kernel_size(1, 2, 2), stride(1, 2, 2)) ) self.conv2 nn.Sequential( nn.Conv3d(32, 64, kernel_size(3, 3, 3), padding(1, 1, 1)), nn.BatchNorm3d(64), nn.ReLU(inplaceTrue), nn.MaxPool3d(kernel_size(2, 2, 2), stride(2, 2, 2)) ) self.conv3 nn.Sequential( nn.Conv3d(64, 128, kernel_size(3, 3, 3), padding(1, 1, 1)), nn.BatchNorm3d(128), nn.ReLU(inplaceTrue), nn.MaxPool3d(kernel_size(2, 2, 2), stride(2, 2, 2)) ) self.avgpool nn.AdaptiveAvgPool3d((1, 1, 1)) self.fc nn.Linear(128, num_classes) def forward(self, x): # x: (B, 3, T, H, W) x self.conv1(x) # (B, 32, T, 112, 112) x self.conv2(x) # (B, 64, T/2, 56, 56) x self.conv3(x) # (B, 128, T/4, 28, 28) x self.avgpool(x) # (B, 128, 1, 1, 1) x x.view(x.size(0), -1) x self.fc(x) return x第一层卷积的输入是3通道RGB帧序列输出32个特征通道。padding(1, 1, 1)保证卷积不改变时间维和空间维的尺寸。第一层pooling的kernel是(1, 2, 2)时间维不压缩因为16帧的输入已经比较短了过早压缩时间维度会丢失运动信息。第二层和第三层的时间维pooling都设为216帧经过3次压缩后变成2帧最后由AdaptiveAvgPool3d((1, 1, 1))把时间、高度、宽度全部压缩为1。AdaptiveAvgPool3d是这里的关键设计。它不管输入视频实际有多少帧经过pooling后输出shape一定是(B, 128, 1, 1, 1)。这意味着模型不一定非要输入16帧训练时可以用16帧推理时换成32帧也不会报错。3.3 使用预训练模型与权重的加载策略手写的模型结构清晰但从零训练在UCF101上也就能达到55%到65%的准确率。要获得更好的效果常见做法是使用torchvision中提供的视频模型如R3D-18、MC3-18或R2Plus1D-18它们都在大规模视频数据集上预训练过。import torchvision.models.video as video_models model video_models.r3d_18(pretrainedTrue) model.fc nn.Linear(model.fc.in_features, 101)r3d_18是ResNet-18的3D版本把所有的2D卷积和2D pooling替换成了3D版本。pretrainedTrue会从模型仓库下载对应权重。替换model.fc这层是全连接分类头model.fc.in_features动态获取原来全连接的输入维度这样就不会出现维度写死的错误。MC3和R2Plus1D的区别在于卷积分解方式MC3是混合使用2D和3D卷积R2Plus1D把3D卷积拆成2D空间卷积加1D时间卷积。后两者的参数量比R3D小但精度不一定低建议直接选MC3-18作为入门首选。预训练模型的前处理要求与图像模型不同输入需满足特定的mean和stdfrom torchvision import transforms mean [0.43216, 0.394666, 0.37645] std [0.22803, 0.22145, 0.216989] transform transforms.Compose([ transforms.Normalize(meanmean, stdstd) ])这三个mean/std数值来自Kinetics数据集不是ImageNet的数值。很多人在这里直接用ImageNet的mean和std导致预训练模型效果大跌。UCF101的像素值经过2.3节的/255.0归一化到[0,1]区间后再用Kinetics的统计值做标准化。3.4 输入张量shape的统一从视频到Tensor的完整链路视频数据进入模型前的维度变化是最容易出错的环节这里把完整链路整理清楚。原始视频经过抽帧后得到(T, H, W, C)的numpy数组Dataset里转成(C, T, H, W)的tensorDataLoader把多个样本堆叠成(B, C, T, H, W)之后才是模型真正接收的输入。如果使用预训练模型还要注意torchvision视频模型内部有一个stochastic depth的参数默认是关闭的。另外有些版本的r3d_18在forward之前会对输入做一次permute所以喂进去的格式必须严格是(B, C, T, H, W)顺序反了会直接报错或者得到极差的精度。张量维度含义典型值Bbatch size8 / 16 / 32C通道数3T时间帧数16 / 32H高度112 / 224W宽度112 / 224batch size、帧数和分辨率共同决定显存占用。一个(8, 3, 16, 224, 224)的输入在R3D-18上大约需要10到12GB显存如果显存只有8GB可以把batch size降到4或者把分辨率改成(112, 112)torchvision的预训练模型在112x112分辨率下同样可用因为Kinetics预训练用的分辨率就是112。4. 训练与调参稳定收敛的配置和避坑4.1 训练主循环与进度显示训练循环的骨架和图像分类任务一致但有几处细节需要调整。视频模型参数量更大收敛速度比图像模型慢学习率的设置要更保守。下面给出一个可直接执行的训练主循环。from tqdm import tqdm import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) train_loader DataLoader(train_dataset, batch_size8, shuffleTrue, num_workers4, pin_memoryTrue) for epoch in range(30): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in tqdm(train_loader): inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) scheduler.step() epoch_loss running_loss / total epoch_acc correct / total print(fEpoch {epoch}: loss{epoch_loss:.4f}, acc{epoch_acc:.4f})optimizer.zero_grad()必须在loss.backward()之前调用否则梯度会累加。loss.item()取标量值用于打印不会打断计算图。torch.max(outputs, 1)返回每行最大值和对应的索引preds labels得到布尔张量用sum().item()统计正确个数。pin_memoryTrue只能在使用GPU时加速数据传输CPU环境下没有实际作用但也不会有副作用。4.2 学习率、权重衰减与batch size的搭配视频分类任务的超参数敏感性比图像分类更高。下面这张表格给出的是常用配置范围我个人的经验是先从表格里的“保守值”开始跑通后再逐项调整。超参数推荐值说明optimizerSGD / AdamWSGD更稳AdamW在3D CNN上收敛更快learning rate0.01SGD/ 0.001AdamW预训练模型用0.001起步即可momentum0.9SGD专用weight_decay1e-4视频模型过拟合风险高batch size816帧输入显存不足时优先降低分辨率num_workers4一般等于CPU核数的一半训练轮数30到60UCF101上30轮能看到明显收敛迹象预训练模型和从零训练的模型在learning rate上差异很大。预训练权重已经把底层特征学得比较好了fine-tune时如果lr设得太大比如0.1会把预训练权重破坏掉。常见的做法是backbone用较小的lr新的fc层用10倍大的lr。PyTorch中实现方法如下optimizer optim.SGD([ {params: [p for name, p in model.named_parameters() if fc not in name], lr: 0.001}, {params: model.fc.parameters(), lr: 0.01}, ], momentum0.9, weight_decay1e-4)named_parameters()遍历所有参数fc not in name筛掉分类头的参数backbone用0.001fc层用0.01。这种解耦设置能避免新初始化的fc层梯度太大拉偏backbone的预训练特征。4.3 验证集评估与最优模型保存训练过程中的模型不一定是验证集上表现最好的。视频模型训练曲线波动较大常规做法是每个epoch结束后在验证集上计算top-1和top-5准确率并且只保留最优的checkpoint。def evaluate(model, val_loader, device): model.eval() correct {1: 0, 5: 0} total 0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) _, preds outputs.topk(5, 1, True, True) for i in range(labels.size(0)): if labels[i].item() in preds[i][:1]: correct[1] 1 if labels[i].item() in preds[i][:5]: correct[5] 1 total 1 return correct[1] / total, correct[5] / total best_acc 0.0 val_acc1, val_acc5 evaluate(model, val_loader, device) if val_acc1 best_acc: best_acc val_acc1 torch.save(model.state_dict(), best_model.pth)model.eval()切换BatchNorm到评估模式。topk(5, 1, True, True)返回每行最大的5个值的索引shape为(B, 5)。这个评估函数里统计的是top-1命中即真实标签是否出现在预测的第一位。torch.no_grad()禁用梯度计算推理更快且省显存。state_dict只保存模型参数不包含优化器状态。如果想从断点恢复训练需要同时保存optimizer的state_dict和epoch数值但推荐的方式是最优模型单独保存参数这样在部署或继续推理时更方便不会加载到无关的优化器状态导致维度报错。4.4 训练崩溃案例NAN、OOM与加载错误视频训练中遇到NAN Loss的概率比图像训练高得多。最常见的原因是学习率过大特别是BatchNorm3d的初始gamma值权重没有设置正确。遇到NAN时优先把learning rate除以10如果问题依旧检查输入数据中是否存在NaN值。可以用torch.isnan(inputs).any()加在数据加载之后做一次判断。显存溢出OOM是视频训练的标配问题。报错信息CUDA out of memory出现时最直接的解法是减小batch size。但有时batch size已经为2还是OOM这时需要检查推理阶段是否也加载了训练时的模型没有释放显存。也可以用torch.cuda.empty_cache()清理被PyTorch缓存但不再使用的显存碎片。加载checkpoint时报size mismatch错误通常是模型结构定义变化导致的比如fc层的输出类别数改过。解决方案是使用strictFalse加载然后在控制台打印缺失的key。checkpoint torch.load(best_model.pth) model.load_state_dict(checkpoint, strictFalse)这样会把匹配的层全部加载不匹配的层通常是fc层保持随机初始化。输出层类别数发生变化时这是最合理的加载方式。5. 准确率提升多clip测试、数据增强与推理加速5.1 多clip测试验证阶段从1个clip改成多个clip取平均UCF101的官方评测标准是每个视频抽取25个clip进行预测再取平均作为最终结果。训练时从视频中随机抽1个clip验证时如果仍只抽1个方差大可能某次抽到了模糊的片段就判错。多做几次预测再取平均准确率能稳定提升1到2个百分点。def predict_video(model, video_path, num_clips10): model.eval() softmax nn.Softmax(dim1) clip_probs [] for _ in range(num_clips): clip sample_clip(video_path, num_frames16, sample_rate2) clip_tensor torch.from_numpy(clip).permute(3, 0, 1, 2).float() clip_tensor clip_tensor.unsqueeze(0).to(device) with torch.no_grad(): logits model(clip_tensor) probs softmax(logits) clip_probs.append(probs) avg_probs torch.mean(torch.cat(clip_probs), dim0) return torch.argmax(avg_probs).item()unsqueeze(0)给单个clip添加batch维度。torch.cat(clip_probs)得到(10, 101)的矩阵torch.mean(..., dim0)对clip维度求均值。多clip测试在推理阶段带来的计算量线性增加但效果是实打实的。注意这里的sample_clip每次都会重新随机抽取起始位置如果希望测试完全可复现可以把随机种子固定或者在采样函数中加一个start_frame参数。5.2 数据增强视频版的空间与时间增强组合图像分类常用的随机裁剪、水平翻转都能直接用于视频帧需要额外注意的是同一个视频的每一帧必须使用相同的空间变换参数。如果在Dataset里对每帧独立调用transforms.RandomCrop那相当于对每一帧做了不同的裁剪位置运动信息会全部错乱。PyTorch的transforms在视频上正确用法是先对clip整体做一次随机裁剪然后将裁剪好的同一区域应用到所有帧。实现方式有两种一是用torchvision.transforms.v2.RandomResizedCrop配合apply接口二是用一个简单的numpy实现def spatial_augment(clip_np, crop_size200): # clip_np: (T, H, W, C) T, H, W, C clip_np.shape if H crop_size or W crop_size: return cv2.resize(clip_np, (crop_size, crop_size)) top np.random.randint(0, H - crop_size) left np.random.randint(0, W - crop_size) aug_clip clip_np[:, top:topcrop_size, left:leftcrop_size, :] aug_clip cv2.resize(aug_clip, (224, 224)) if np.random.rand() 0.5: aug_clip aug_clip[:, :, ::-1, :] return aug_cliptop和left在视频所有帧之间只生成一次保证所有帧的裁剪区域完全一致。np.random.rand() 0.5决定是否水平翻转翻转的关键操作是[:, :, ::-1, :]即对H维度反转。时间维度的增强同样重要。训练时将片段随机加速或减速能模拟动作速度的变化这在体育动作识别中收益很大。PyTorch提供了一个好用的方法在抽帧时不只是固定sample_rate而是从一个范围内随机选取rate值。需要控制一下rate太大会导致动作变形通常在[1, 4]之间随机。5.3 用半精度混合精度训练压缩显存与加速16帧输入在R3D-18上batch size 8大约需要12GB显存很多人的显卡只有8GB。除了降低分辨率和batch size还有一条技术路线使用PyTorch的自动混合精度AMP。AMP用FP16存储梯度和中间激活值显存占用减少接近一半在Volta架构以后的GPU上还有Tensor Core加速。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for epoch in range(epochs): for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() with autocast(): outputs model(inputs) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()autocast()上下文内做的计算自动使用FP16模型权重保持FP32。GradScaler的作用是防止梯度值过小导致下溢为0它会对loss做缩放反向传播完成后在scaler.step(optimizer)内部还原。scaler.update()每个batch都要调用用于动态调整缩放因子。AMP在视频模型上收益非常明显实测训练速度提升约1.5到2倍。需要注意两点第一AMP模式下BatchNorm3d仍按FP32精度计算与前向推理结果存在少量差异可以在验证时关闭autocast避免精度分布不一致第二torch.cuda.amp接口在PyTorch 2.0以上建议使用torch.amp的新命名空间但旧接口代码依然可用不必刻意修改。模型保存后要一并保存类别列表推理时才能把输出索引还原为动作名称。最简单的做法是torch.save(class_names, class_names.pth)与模型权重放在同一目录下。这样换机器部署时不需要再去原始数据集文件夹里提取类别列表。本文还有配套的精品资源点击获取
返回列表