3D-ResNets-PyTorch动作识别实战:从原理到避坑指南

发布时间:2026/10/14 16:01:21
3D-ResNets-PyTorch动作识别实战:从原理到避坑指南 简介这份资源是面向计算机视觉与深度学习方向的开发者、研究生及算法工程师的3D ResNet动作识别实现基于PyTorch复现CVPR 2018论文可用于视频理解、行为识别等课题的模型训练与实验复现。压缩包共37个文件以34个Python脚本为核心涵盖训练、验证、推理、数据集加载、时空变换及多种网络结构定义另含LICENSE、README与gitignore等辅助文件整体约43KB结构清晰便于按模块查阅。项目已重构并支持新版PyTorch、分布式训练以及Moments、Kinetics-700等数据集的训练测试还新增R(21)D模型并上传了预训练权重读者可据此快速搭建训练流程、复现论文结果或迁移到自有视频数据上。目前已有2623人学习下载适合希望深入掌握3D卷积网络与动作识别工程实践的中高级读者参考。1. 3D-ResNets-PyTorch动作识别为什么绕不开这条技术路线视频动作识别这个方向很多人第一次跑公开数据集时都会卡在同一个地方2D 卷积把每一帧当成独立图片分类模型学到的全是背景和静态物体人物一挥手、一转身预测结果就跟着乱跳。3D-ResNets-PyTorch 解决的正是这个问题——它把卷积核从二维扩展到三维让网络在时间轴上也能“看”到连续帧之间的变化从而区分“挥手”和“站着不动”这类只在时序上才有差异的动作。这套实现对应 CVPR 2018 的 3D ResNets 工作核心是把 ResNet 的残差结构搬到视频输入上用 (C, T, H, W) 四维张量做前向配合 Kinetics、UCF101、HMDB51 这些动作数据集训练。适合谁做视频理解、行为分析、监控事件检测的工程师以及想复现经典动作识别基线、再往上叠时序模块的研究者。它不轻但它是绕不过去的基线。2. 从视频张量到 3D 残差块这套网络到底怎么搭起来的2.1 输入张量的维度顺序决定了后面所有坑3D 卷积和 2D 卷积最直观的区别在输入形状。2D 网络吃的是 (N, C, H, W)3D 网络吃的是 (N, C, T, H, W)多出来的 T 是时间维。这里第一个容易翻车的地方是维度顺序PyTorch 的 3D 卷积要求通道维在第二、时间维在第三而很多视频解码库吐出来的帧序列是 (T, H, W, C)。如果你直接把解码结果喂进去网络不会报错但学出来的东西完全是错的loss 会卡在一个很高的值下不去。常见做法是先用一个统一的采样器把视频裁成固定长度片段再转成 (C, T, H, W)。我一般会在 dataset 的__getitem__里就把顺序定死避免后面到处 transpose。下面是一个最小可用的视频片段采样逻辑import torch import numpy as np def sample_clip(frames, clip_len16, crop_size112): frames: list of HWC uint8 frames, 已按时间顺序排列 clip_len: 时间维长度3D ResNet 常用 16 或 32 crop_size: 空间裁剪尺寸常用 112 或 128 返回: (C, T, H, W) 的 float tensor total len(frames) if total clip_len: # 帧数不够就循环补齐避免 batch 里出现空片段 idx np.arange(clip_len) % total else: # 均匀采样保证覆盖整段视频而不是只取开头 idx np.linspace(0, total - 1, clip_len).astype(int) clip [frames[i] for i in idx] # 中心裁剪训练时通常换成随机裁剪 h, w, _ clip[0].shape top (h - crop_size) // 2 left (w - crop_size) // 2 clip [f[top:topcrop_size, left:leftcrop_size, :] for f in clip] arr np.stack(clip, axis0) # (T, H, W, C) arr arr.transpose(3, 0, 1, 2) # (C, T, H, W) return torch.from_numpy(arr).float() / 255.0这段代码里clip_len和crop_size是两个必须和网络配置对齐的参数。3D ResNet-18/34 在 Kinetics 上的标准配置是 16 帧、112 裁剪如果你显存够用 32 帧、128 裁剪通常能涨一两个点但显存占用接近翻倍。np.linspace做均匀采样而不是取前 16 帧是因为动作往往发生在视频中段只取开头会丢掉关键信息。2.2 3D 残差块和 2D 版本差在哪残差块的结构本身没变还是两条分支相加。变的是卷积核2D 是kernel_size(3,3)3D 是kernel_size(3,3,3)同时在时间、高度、宽度三个方向做卷积。步长也变成三维stride(1,2,2)表示时间维不下采样、空间维下采样这是 3D ResNet 里最常见的下采样方式——时间维下采样太早会丢时序信息。下面是一个简化版的 3D BasicBlock能看清参数怎么设import torch.nn as nn class BasicBlock3D(nn.Module): def __init__(self, in_planes, planes, stride(1, 2, 2)): super().__init__() # 第一个卷积负责下采样stride 三维 self.conv1 nn.Conv3d( in_planes, planes, kernel_size(3, 3, 3), stridestride, padding(1, 1, 1), biasFalse ) self.bn1 nn.BatchNorm3d(planes) self.relu nn.ReLU(inplaceTrue) # 第二个卷积保持尺寸stride 全 1 self.conv2 nn.Conv3d( planes, planes, kernel_size(3, 3, 3), stride(1, 1, 1), padding(1, 1, 1), biasFalse ) self.bn2 nn.BatchNorm3d(planes) # 捷径分支输入输出维度不一致时用 1x1x1 卷积对齐 self.downsample None if stride ! (1, 1, 1) or in_planes ! planes: self.downsample nn.Sequential( nn.Conv3d(in_planes, planes, kernel_size(1, 1, 1), stridestride, biasFalse), nn.BatchNorm3d(planes) ) 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) out identity return self.relu(out)关键参数是stride。第一个 block 用(1,2,2)后面几个 stage 也用(1,2,2)或(2,2,2)。时间维什么时候下采样直接影响模型能捕捉多长的动作。我一般在前两个 stage 保持时间维 stride 为 1到后面再压时间维这样短时动作不会被过早抹掉。padding(1,1,1)保证卷积后尺寸不变配合 stride 控制下采样节奏。2.3 把网络拼起来从 stem 到分类头完整的 3D ResNet 由 stem、四个 stage 和全局池化加全连接组成。stem 通常是一个7x7x7的大卷积加 maxpool但很多轻量配置会换成3x3x3堆叠。分类头这里有个细节3D 卷积输出是 (N, C, T, H, W)全局平均池化要在 T、H、W 三个维度上做得到 (N, C)再接nn.Linear(C, num_classes)。class ResNet3D(nn.Module): def __init__(self, block, layers, num_classes400): super().__init__() self.in_planes 64 # stem: 时间维不下采样空间维下采样 self.stem nn.Sequential( nn.Conv3d(3, 64, kernel_size(3, 7, 7), stride(1, 2, 2), padding(1, 3, 3), biasFalse), nn.BatchNorm3d(64), nn.ReLU(inplaceTrue), nn.MaxPool3d(kernel_size(1, 3, 3), stride(1, 2, 2), padding(0, 1, 1)) ) self.layer1 self._make_layer(block, 64, layers[0], stride(1, 1, 1)) self.layer2 self._make_layer(block, 128, layers[1], stride(1, 2, 2)) self.layer3 self._make_layer(block, 256, layers[2], stride(2, 2, 2)) self.layer4 self._make_layer(block, 512, layers[3], stride(2, 2, 2)) self.avgpool nn.AdaptiveAvgPool3d(1) self.fc nn.Linear(512, num_classes) def _make_layer(self, block, planes, num_blocks, stride): layers [] layers.append(block(self.in_planes, planes, stride)) self.in_planes planes for _ in range(1, num_blocks): layers.append(block(planes, planes, stride(1, 1, 1))) return nn.Sequential(*layers) def forward(self, x): x self.stem(x) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.layer4(x) x self.avgpool(x) # (N, 512, 1, 1, 1) x x.flatten(1) # (N, 512) return self.fc(x)AdaptiveAvgPool3d(1)把三个空间时间维都压成 1这是最省事的做法。有些实现会在时间维保留多个位置再做时序池化但对基线来说全局平均已经够用。num_classes要和你数据集的类别数一致Kinetics 是 400UCF101 是 101HMDB51 是 51改错这个数不会报错但训练永远不收敛。3. 训练配置怎么定学习率、batch 和预训练权重的取舍3.1 从零训练还是加载预训练动作识别数据集普遍偏小UCF101 也就一万三千条左右视频从零训 3D ResNet 几乎必然过拟合。常见做法是加载 Kinetics 预训练权重再把最后的全连接换成自己类别数。加载时要注意键名匹配预训练模型如果用了module.前缀多卡保存的常见情况直接load_state_dict会报一堆 missing keys。import torch def load_pretrained(model, ckpt_path, num_classes): ckpt torch.load(ckpt_path, map_locationcpu) state ckpt.get(state_dict, ckpt) # 去掉多卡训练留下的 module. 前缀 new_state {} for k, v in state.items(): if k.startswith(module.): k k[len(module.):] new_state[k] v # 分类头维度不匹配直接丢掉后面重新初始化 new_state {k: v for k, v in new_state.items() if not k.startswith(fc.)} missing, unexpected model.load_state_dict(new_state, strictFalse) print(missing:, missing) print(unexpected:, unexpected) # 重新初始化分类头 model.fc torch.nn.Linear(model.fc.in_features, num_classes) return modelstrictFalse是必须的因为分类头被我们主动丢了。打印 missing 和 unexpected 是为了确认除了fc之外没有别的层没对上——如果 missing 里出现layer3之类的骨干层说明权重文件本身有问题别硬训。3.2 学习率和 batch 的实操取值3D 网络显存占用大batch 通常上不去。单卡 16G 显存112 裁剪、16 帧、ResNet-18batch 大概能到 16 到 24。学习率用 SGD 时常见起点是 0.01 到 0.1配合 cosine 退火或 step 衰减。如果加载了预训练学习率要调小我一般用 0.001 到 0.01否则预训练权重会被大梯度冲掉。# 单卡训练示例参数按自己显存调整 python train.py \ --dataset ucf101 \ --clip_len 16 \ --crop_size 112 \ --batch_size 16 \ --lr 0.01 \ --epochs 50 \ --weight_decay 1e-4 \ --pretrain path/to/kinetics_ckpt.pthweight_decay用 1e-4 是 3D ResNet 的常规值太大欠拟合太小过拟合。epochs设 50 左右UCF101 上一般 30 到 40 轮就接近收敛。如果 loss 在前几轮就炸成 nan先查学习率是不是太大再查输入有没有归一化——3D 网络对输入尺度比 2D 更敏感。3.3 数据增强时间维和空间维要分开处理视频增强比图像多一个时间维。空间上常用随机裁剪、水平翻转时间上常用随机采样起点、随机抽帧。注意水平翻转会改变动作方向对“向左走”和“向右走”这类类别可能有害但大多数动作数据集里方向不是判别关键所以翻转仍然常用。import random def temporal_augment(frames, clip_len): total len(frames) if total clip_len: return frames # 随机起点保证片段落在视频内部 start random.randint(0, total - clip_len) return frames[start:start clip_len]这个随机起点增强能显著提升模型对动作发生时刻的鲁棒性。如果固定取中间片段模型会偏向只在视频中段出现的动作测试时遇到动作靠前或靠后的样本就翻车。4. 避坑与排查3D 动作识别里最容易翻车的几件事4.1 loss 不下降但也不报错现象训练跑起来loss 在 6 到 7 附近晃准确率等于随机猜。原因通常是输入维度顺序错了或者标签和输出类别数对不上。3D 卷积对 (N, C, T, H, W) 的顺序很敏感如果你传的是 (N, T, C, H, W)网络照样前向但学的是错的东西。解决在 dataset 里打印一个 batch 的 shape确认第二维是 3RGB 通道第三维是 clip_len。标签方面检查num_classes是否等于数据集实际类别数多一类少一类都会让交叉熵失去意义。4.2 显存溢出batch 降到 1 还是 OOM现象CUDA out of memory把 batch 降到 1 依然报错。原因多半是输入分辨率或帧数太大或者模型在某个 stage 没有下采样特征图一直保持高分辨率。3D 特征图的内存占用是 2D 的 T 倍16 帧、112 裁剪、ResNet-50 在 16G 卡上 batch 1 都可能吃紧。解决换 ResNet-18 或 34把 clip_len 降到 8 或 16crop_size 降到 112检查每个 stage 的 stride 是否真的在降分辨率。用torch.cuda.memory_summary()看是哪一层爆的。4.3 预训练权重加载后准确率反而更低现象加载 Kinetics 预训练后前几轮准确率比随机初始化还差。原因通常是学习率没调小大梯度把预训练特征冲毁了或者输入归一化方式和预训练时不一致。解决加载预训练后学习率降到 0.001 量级先冻结骨干只训分类头几轮再解冻全网络微调。归一化用 ImageNet 的均值和方差即 mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]别自己拍脑袋定。4.4 验证集准确率远高于测试集现象验证集上 80%换一批视频测试就掉到 50%。原因通常是验证集和训练集来自同一批视频的相邻片段时间上高度重叠等于变相泄漏。解决划分数据集时按视频划分不要按片段划分。同一个视频的不同片段必须全部落在同一个集合里否则模型记住的是视频背景而不是动作本身。4.5 多卡训练后单卡推理报键名错误现象用 DataParallel 或 DDP 训完保存的权重单卡加载时所有键都带module.前缀load_state_dict报 missing。原因就是多卡包装了一层。解决保存时用model.module.state_dict()或者加载时统一去掉module.前缀前面加载预训练的函数已经处理了这种情况。5. 把 3D ResNet 用到位时序池化和片段融合的两个进阶技巧5.1 不要只用最后一个时间位置的全局池化标准实现用AdaptiveAvgPool3d(1)把时间维也压成 1这等于假设动作在整段视频里均匀分布。实际视频里动作往往集中在几秒内全局平均会把大量静止帧的信息混进来。一个改动很小但收益稳定的做法是在时间维保留多个位置做时序平均或时序最大池化再拼接。class TemporalPoolingHead(nn.Module): def __init__(self, in_dim, num_classes): super().__init__() # 时间维保留空间维压成 1 self.spatial_pool nn.AdaptiveAvgPool3d((None, 1, 1)) self.fc nn.Linear(in_dim * 2, num_classes) def forward(self, x): # x: (N, C, T, 1, 1) x self.spatial_pool(x).squeeze(-1).squeeze(-1) # (N, C, T) avg x.mean(dim2) # 时序平均捕捉整体趋势 mx x.max(dim2)[0] # 时序最大捕捉瞬时峰值 return self.fc(torch.cat([avg, mx], dim1))AdaptiveAvgPool3d((None, 1, 1))保留时间维空间压成 1。然后对时间维分别做平均和最大拼起来送全连接。这个改动几乎不增加参数量但在动作峰值明显的类别上通常能涨一两个点。注意None在部分 PyTorch 版本里要写成具体数字或-1跑之前确认一下版本行为。5.2 多片段投票比单片段更稳推理时只取一个 16 帧片段结果受采样起点影响很大。常见做法是把一段视频切成多个有重叠的片段每个片段单独前向softmax 概率求平均。这样做的代价是推理时间线性增长但准确率更稳尤其对长视频。def multi_clip_inference(model, frames, clip_len16, num_clips10): model.eval() total len(frames) probs [] with torch.no_grad(): for i in range(num_clips): # 均匀分布采样起点覆盖整段视频 start int(i * (total - clip_len) / max(num_clips - 1, 1)) clip frames[start:start clip_len] clip sample_clip(clip, clip_len) # 复用前面的采样函数 clip clip.unsqueeze(0).cuda() logit model(clip) probs.append(torch.softmax(logit, dim1)) return torch.stack(probs).mean(dim0)num_clips取 10 是精度和速度的折中取 1 就是单片段取 25 以上收益递减。起点用i * (total - clip_len) / (num_clips - 1)均匀铺开保证首尾都覆盖到。如果视频特别长可以先做关键帧筛选再采样但那是另一个话题了。5.3 一个我踩过的坑测试时的归一化必须和训练一致有次复现训练时用了 ImageNet 归一化测试时图省事直接除以 255 就送进去结果准确率掉了十几个点。3D 网络对输入分布的偏移比 2D 更敏感因为时间维的统计特性会放大这种偏移。后来我把归一化写进 dataset 的__getitem__训练和测试走同一条路径再没出过这个问题。血泪经验就是凡是和输入数值有关的处理只写一处别在训练脚本和推理脚本里各写一遍。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询