简介:一份面向计算机视觉与深度学习研究者的学术论文PDF,聚焦视频中人体行为识别问题。文中提出深度残差网络(ResNet)与BN-Inception网络组合的空间时间网络:空间网络通过ResNet提取视频帧外观特征,时间网络通过BN-Inception提取光流运动特征,并以稀疏采样方式降低长时间视频密集采样带来的计算开销。作者在UCF-101和HMDB-51数据集上分别取得94.5%与70.1%的识别准确率,优于多种行为识别方法,可为视频监控、人机交互、无人驾驶等场景提供算法参考。资源为单份PDF文档,约1.09MB,内容涵盖网络结构设计、残差学习单元原理、实验设置与结果对比等,适合用于课程设计、毕业设计或算法调研。目前已有752人学习下载。
1. 为什么人体行为识别绕不开卷积神经网络
监控摄像头每天产生大量视频,但“看到人”和“知道人在做什么”是两回事。人体行为识别要解决的是给一段视频打上动作标签,比如跑步、握手或摔倒。这项任务离不开卷积神经网络,因为行为的判别性信息往往藏在局部纹理和空间结构里,而卷积核天生擅长提取局部特征。反直觉的是,在不少公开数据集上,简单的3D卷积基线就能打赢复杂的双流模型,原因是3D卷积把时间当成了第三个轴,隐式学习了帧间运动。这就是标题里“基于卷积神经网络”的底气。下面从建模方式、训练管线到推理调参,用PyTorch把这套方法落到可运行的层面。
2. 从图像到视频:卷积神经网络处理时序行为的三种建模方式
行为识别不能只靠单帧,时序信息必须进入网络。围绕这一点,业界已经形成了三条经典路线:双流网络、3D卷积网络和基于骨骼点的图卷积网络。这三条路线都属于“基于卷积神经网络的人体行为识别方法”的范畴,区别只在时间轴以什么形式进入卷积结构。很多同学是从李宏毅老师的CNN课程开始理解卷积核的局部感受野,这个认知完全可以迁移到视频里,只要多处理一个时间轴。
2.1 双流网络:空间流和时间流各干一件事
双流结构最早由Simonyan等人在2014年提出,思路很直观:一个分支输入RGB帧,负责判断“画面里有什么”;另一个分支输入光流,也就是相邻帧之间的像素运动向量,负责判断“东西在怎么动”。空间流可以使用标准2D卷积神经网络,比如ResNet50;时间流同样用2D CNN,但输入从单帧变成多张堆叠的光流图。
光流不是CNN学出来的,而是预先用算法计算好的,OpenCV里的Farneback光流是其中很常见的实现。这意味着双流网络需要额外的离线预处理,光流计算本身也耗时。这个代价换来的是动作边界清晰,在早期数据集上提升明显。一个双流网络的时间流输入通常保留10帧堆叠光流,空间流只取中间帧,最后把两个分支在softmax之前做加权平均。下面是一个简化版实现:
import torch import torch.nn as nn class TwoStreamFusion(nn.Module): def __init__(self, spatial_backbone, temporal_backbone, num_classes): super().__init__() self.spatial = spatial_backbone # 输入 RGB 单帧,输出 1024 维特征 self.temporal = temporal_backbone # 输入光流堆叠,输出 1024 维特征 self.classifier = nn.Linear(2048, num_classes) def forward(self, rgb, flow): f_s = self.spatial(rgb) f_t = self.temporal(flow) fused = torch.cat([f_s, f_t], dim=1) return self.classifier(fused)上面这个版本用torch.cat拼接特征,实际项目中有人改用元素级相加或加权和,因为拼接会引入更多分类层参数。两个backbone通常分别加载预训练权重,训练时先各自微调,再联合训练融合层。因此双流网络的实际训练管线比单模型重,但它的可解释性很好:你可以分别查看两个分支的激活图,判断模型到底是靠外观还是靠运动在做预测。
还有一个容易忽略的细节:时间流的输入光流通常会被量化为0到255的图像,并使用与ImageNet一致的均值和方差做归一化。如果直接使用浮点光流值训练,收敛速度会慢很多,因为这个分支的输入分布和ImageNet预训练权重严重不匹配。使用OpenCV计算光流时,横纵方向的光流图会被拆成两个通道,再加上一个笛卡尔坐标的x、y分量,最终常见的输入通道数是20,而不是通常意义上的3通道图。
2.2 3D卷积:把时间当成卷积核的第三个轴
从LeNet-5奠定卷积神经网络结构图以来,核心的机制一直是局部感受野和权值共享。到了视频领域,一个自然的演进就是把二维卷积变成三维卷积:2D卷积在H和W两个方向滑窗,3D卷积在H、W、T三个方向滑窗。输入张量也从(C, H, W)变成(C, D, H, W),这里的D就是时间维上的帧数。2015年的C3D网络用8层3D卷积在UCF101上验证了这条路可行,后来I3D把它扩展成膨胀卷积结构的Inception风格,成为现代视频理解的标准baseline。
3D卷积的参数量比2D大得多。一个3x3x3的卷积核有27个权重,而对应的3x3只有9个。为了控住计算量,常见做法是只在网络前几层使用3D卷积,或者用(3, 7, 7)这类非对称内核:时间维度取3帧,空间维度取7x7。3D卷积网络不需要光流,训练端到端,但显存占用高,数据增广也更敏感。下面是PyTorch中最小的3D卷积残差块:
import torch.nn as nn class BasicBlock3D(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 = nn.Conv3d(in_ch, out_ch, kernel_size=(3, 3, 3), padding=(1, 1, 1), bias=False) self.bn1 = nn.BatchNorm3d(out_ch) self.conv2 = nn.Conv3d(out_ch, out_ch, kernel_size=(3, 3, 3), padding=(1, 1, 1), bias=False) self.bn2 = nn.BatchNorm3d(out_ch) self.relu = nn.ReLU(inplace=True) self.shortcut = nn.Sequential() if in_ch != out_ch: self.shortcut = nn.Conv3d(in_ch, out_ch, kernel_size=1, bias=False) def forward(self, x): out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) return self.relu(out + self.shortcut(x))这个块与ResNet的BasicBlock几乎一样,只是所有卷积和BN都换成了3D版本。BatchNorm3d会对空间和时间维度的统计量一起归一化,训练时能加速收敛。但当batch里的clip帧数不一时,归一化统计量会剧烈振荡,所以生产代码里通常要求同一个batch内的clip帧数固定,推理时也不要临时改变帧数。
加载预训练权重时,常见做法是“膨胀”:把2D卷积核沿时间轴复制若干份并除以份数,这样能用ImageNet预训练权重初始化3D卷积。膨胀后的第一个epoch损失下降速度会明显快于完全随机初始化。这也是I3D能够从2D网络无缝迁移的关键操作。
2.3 图卷积与骨骼点:当CNN遇到非欧空间
第三类方法不是直接处理RGB视频,而是先做人体姿态估计,把每个关节点的x、y坐标提取出来。此时数据变成图结构:关节点是顶点,骨骼是边。图卷积神经网络(GCN)在这个结构上做特征传播,ST-GCN进一步沿时间轴展开邻接矩阵,形成时空图卷积。这类方法不需要背景纹理,对光照、服装和遮挡都不敏感,因为输入只保留关节坐标。
路线怎么选要看落地场景。如果监控视角里人体目标小、遮挡多,骨骼点可能漏检,用双流或3D卷积更稳。如果要做边缘设备上的实时识别,骨骼点和GCN的输入数据量小得多,更容易满足端到端的延迟要求。下面从输入、计算量和适用场景三个角度做一个横向对比。
| 建模方式 | 输入模态 | 相对计算量 | 典型适用场景 |
|---|---|---|---|
| 2D CNN + 双流 | RGB帧 + 光流 | 中,但光流预处理耗时 | 离线分析、精度优先 |
| 3D卷积网络 | 连续RGB帧 | 高,显存需求大 | 通用视频理解、大数据集 |
| 图卷积网络 | 骨骼关键点 | 低,实时性最好 | 边缘设备、不受背景干扰 |
三种方式并不是互斥的。生产级系统常常先用目标检测把人体裁剪出来,再把裁剪后的RGB片段送进3D卷积网络;如果检测置信度低,再退回双流或GCN做兜底。标题里的“卷积神经网络”其实覆盖了这些变体,重点在于如何把空间特征和时间建模绑在同一次前向里。
3. 搭建可复现的行为识别训练管线
模型结构定了,接下来要把视频变成张量,再喂给网络训练。这一章以3D卷积网络为例,给出一个单GPU可跑的PyTorch训练管道。为了快速验证,输入统一用8帧、112x112分辨率。
3.1 视频解码与帧采样:不要一次读完整个视频
视频文件用OpenCV或decord读取。我常用decord,因为它支持稀疏采样:不必把全部帧解码出来,而是直接跳到目标帧位置。对于几分钟的监控长片,训练时也能快速抽取16帧。关键是做等间隔采样:把视频时长均分成N段,从每一段随机抽一帧。这样能避免动作只出现在片头或片尾时采样不到。
import decord import numpy as np def sample_frames(video_path, num_frames=8, height=112, width=112): vr = decord.VideoReader(video_path, width=width, height=height) total = len(vr) indices = np.linspace(0, total - 1, num_frames).astype(int) frames = vr.get_batch(indices).asnumpy() return frames # shape: (8, 112, 112, 3)上面的np.linspace生成等距索引,训练时建议在每段范围内加随机偏移,推理时使用正中帧,这样能提高泛化能力。decord.VideoReader在初始化时已经完成宽高缩放,之后再统一短边缩放和中心裁剪,整个预处理就省去了逐帧resize的麻烦。
提示:训练时不要对时间维度做随机翻转。图片水平翻转很安全,但视频倒放会让“跑步”变成另一种动作,语义被破坏。
帧采样之后通常还要做短边缩放,一般缩放到128或160,再在训练时随机裁剪到112。这样网络能看到人体周边上下文,而不是只看到裁剪后的局部。sample_frames每次返回(N, H, W, 3),后续代码里需要转成模型输入的(C, N, H, W)格式。
3.2 模型定义:从C3D到轻量ResNet3D
直接用上一章的BasicBlock3D堆出一个小型ResNet3D。输入形状是(batch, 3, 8, 112, 112),对应通道、帧数、高、宽。下面的模型只用两层残差块,作为最小可运行验证。
import torch import torch.nn as nn class LightActionNet(nn.Module): def __init__(self, num_classes): super().__init__() self.stem = nn.Conv3d(3, 32, kernel_size=(3, 5, 5), stride=(1, 2, 2), padding=(1, 2, 2)) self.block1 = BasicBlock3D(32, 64) self.pool1 = nn.MaxPool3d(kernel_size=(2, 2, 2), stride=(2, 2, 2)) self.block2 = BasicBlock3D(64, 128) self.pool2 = nn.MaxPool3d(kernel_size=(2, 2, 2), stride=(2, 2, 2)) self.gap = nn.AdaptiveAvgPool3d((1, 1, 1)) self.fc = nn.Linear(128, num_classes) def forward(self, x): x = self.pool1(self.block1(self.stem(x))) x = self.pool2(self.block2(x)) x = self.gap(x) return self.fc(x.flatten(1)) model = LightActionNet(101) fake_input = torch.randn(2, 3, 8, 112, 112) print(model(fake_input).shape) # torch.Size([2, 101])AdaptiveAvgPool3d((1,1,1))把特征图在D/H/W三个轴上都池化成1x1x1,输出维度与输入帧数无关,推理时从8帧换成16帧不需要改网络。开始写训练代码之前,先用随机张量跑通前向,确认shape匹配,能省掉后面大量排错时间。
生产环境中,这个轻量模型通常会被替换成ResNet3D50或SlowFast。替换时要注意stem层的stride:stride=(1, 2, 2)意味着时间维不下采样,只有空间维减半。如果你把stride改成(2, 2, 2),那么8帧输入会很快变成2帧,动作细节丢失严重。设计网络时,一般只在空间维度做降采样,时间维度保留到全局平均池化前再压缩。
3.3 训练循环:标签平滑与多分类损失
行为识别是单标签多分类任务,标准损失是交叉熵。实际训练中,标签平滑能缓解视频数据普遍存在的过拟合。优化器我一般用AdamW,初始学习率1e-3,warmup 5个epoch后再进入cosine退火。训练循环需要每步做前向、反向,再周期性记录loss和top-1准确率。
from torch.utils.data import Dataset class ClipDataset(Dataset): def __init__(self, video_paths, labels): self.video_paths = video_paths self.labels = labels def __len__(self): return len(self.video_paths) def __getitem__(self, idx): frames = sample_frames(self.video_paths[idx]) frames = torch.from_numpy(frames).permute(3, 0, 1, 2).float() / 255.0 return frames, self.labels[idx] def train_one_epoch(model, loader, optimizer, criterion): model.train() for x, y in loader: optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() return loss.item()DataLoader里应该设置pin_memory=True、num_workers=4,视频解码本身是CPU密集操作,worker数太少会成为瓶颈。数据增强可以做随机裁剪、水平翻转和颜色抖动,但不要做时间维翻转。这里特别要注意的是,PyTorch的3D卷积网络输入顺序是(C, D, H, W),所以permute(3, 0, 1, 2)会把原始的(T, H, W, C)变成(C, T, H, W),如果写反,模型很可能不报错但准确率始终上不去。
如果视频文件放在机械硬盘上,每次__getitem__都重新打开视频容器会非常慢。一个实用做法是先把视频抽帧成JPEG目录,再按索引直接读帧;或者使用torchdata的缓存机制把解码结果放到内存。对于小规模验证,强依赖SSD问题不大,但完整数据集训练时,这一点常常是数据加载吞吐量的瓶颈。
4. 关键参数与常见坑:训练曲线、过拟合与推理效率
在行为识别里,收敛慢、显存爆、指标虚高是三个高频问题。它们都和参数设置有关,下面按数据、训练、推理的顺序来排查。
4.1 帧数、采样步长与Batch Size的取舍
3D卷积网络的输入叫做clip,clip包含的帧数和采样步长决定了模型看到的动作速度。帧数太少,模型看不到完整的动作周期;帧数太多,显存和训练时长成倍增加。对于常见人体动作,16帧、步长4通常比8帧、步长2更稳。下面是一组在112x112分辨率、单张8GB显存上的参考配置。
| 帧数 | 采样策略 | Batch Size | 显存占用 | 效果倾向 |
|---|---|---|---|---|
| 8 | 等间隔 | 32 | 约6GB | 快速验证,慢动作容易欠拟合 |
| 16 | 等间隔 | 16 | 约7GB | 兼顾精度与显存,推荐 |
| 32 | 等间隔 | 4 | 约7.5GB | 适合有长周期但训练慢 |
显存不足时,首选调整Batch Size,不要轻易减少帧数。Batch很小会让BatchNorm的统计量不稳定,Batch Size低于8时,建议把BN的momentum从默认0.1调到0.2,并加入warmup。另一个方案是使用梯度累积,用多个小batch凑成一个有效大batch:
# 梯度累积,有效batch = accumulation_step * micro_batch accumulation_step = 4 for i, (x, y) in enumerate(loader): loss = criterion(model(x), y) / accumulation_step loss.backward() if (i + 1) % accumulation_step == 0: optimizer.step() optimizer.zero_grad()注意,梯度累积不会让BatchNorm统计量变大,它只影响反向传播的梯度累加。所以如果你的模型对BN统计量敏感,还是要尽量提高单个batch的物理大小。
4.2 冻结BN、学习率Warmup与类别不均衡
从预训练权重微调时,最容易犯的错误是让所有层一起用同一个学习率训练。对于3D卷积网络,许多权重是从2D预训练权重膨胀来的,前几层依然是空间特征提取器,过大的学习率会破坏这些预训练知识。常见做法是给前两层设置0.1的学习率倍率,或者冻结BatchNorm的running stats。代码上,冻结BN就是把BN层设成eval模式。
def freeze_bn(model): for m in model.modules(): if isinstance(m, (nn.BatchNorm2d, nn.BatchNorm3d)): m.eval()这里的eval()不会影响模型中的Dropout,只是让BN使用当前running stats而不是当前batch统计量,微调阶段能显著提升稳定性。冻结BN后,权重仍然更新,只是归一化层不再更新数据分布的统计量。
类别不均衡在监控场景里很常见,例如“走路”占比很大,“摔倒”只有零星几条。最简单的处理是给损失函数加类别权重:nn.CrossEntropyLoss(weight=class_weight)。class_weight一般取每个类别样本数的倒数再做归一化,这样小类别每个样本的梯度贡献会被放大。如果想进一步提升难例关注度,可以换Focal Loss,但需要多调一个gamma参数。对大多数初版系统,类别权重已经够用。
4.3 推理阶段的时序聚合:滑动窗口与Softmax融合
测试时只抽一个clip,预测方差会很大。部署时常见做法是用滑动窗口以固定步长在完整视频上采集多个clip,对每个clip求softmax概率,再对所有clip取平均。这个操作能消除局部抖动,给长视频一个稳定的预测。
def predict_video(model, video_path, clip_len=16, stride=16, num_classes=101): vr = decord.VideoReader(video_path) total_frames = len(vr) probs = [] for start in range(0, total_frames - clip_len + 1, stride): indices = np.arange(start, start + clip_len) frames = vr.get_batch(indices).asnumpy() frames = torch.from_numpy(frames).permute(3, 0, 1, 2).float().unsqueeze(0) / 255.0 with torch.no_grad(): p = torch.softmax(model(frames), dim=1).cpu().numpy() probs.append(p) avg_prob = np.mean(probs, axis=0) return np.argmax(avg_prob, axis=1)[0]stride越大,推理越快,但可能漏掉动作边界。动作密集的视频里,stride建议不超过clip_len的一半。如果输入视频只有几秒,可以直接从start=0一次性取完所有帧,不需要滑动窗口。Softmax平均比投票更平滑,因为它保留了每个clip的置信度,相当于做了一次概率层面的集成。更复杂的做法是给每个窗口加时间位置编码,让模型知道当前动作发生在视频的哪个阶段,但这类方法在工程中很少用,优先把滑动窗口融合先跑通。
5. 用一套固定配置快速验证行为识别模型
如果你想只是“先跑起来确认链路是通的”,下面这套配置适合在单张RTX 3060上用UCF101的前10个类别做快速验证。它不追求榜单精度,目标是快速跑完训练并得到明显高于随机猜测的top-1。配置如下:
- 数据集:UCF101前10个类别,视频短边缩放至128x128,训练时中心裁剪到112x112。
- 帧采样:8帧等间隔,训练时每段做随机偏移。
- 模型:LightActionNet,第3章定义。
- 优化器:AdamW,学习率5e-4,batch size 16。
- 训练:20个epoch,前2个epoch warmup,标签平滑设为0.1。
- 推理:滑动窗口步长4,softmax平均。
把这套配置写成命令行,大致是这样:
python train_action.py \ --dataset ucf101_subset \ --model light_action \ --clip_len 8 \ --batch_size 16 \ --lr 5e-4 \ --epochs 20 \ --warmup_epochs 2 \ --log_dir runs/exp1跑完之后,第一步不是看loss,而是看验证集top-1是否超过随机水平。在10类任务里,20个epoch后通常能到40%上下,如果只有10%上下,说明模型没有学到任何有效特征。优先检查三条:视频张量通道顺序是不是(C, T, H, W),像素有没有除以255,标签是不是从0开始连续。这三类低级错误造成的现象都是loss降得很慢,但准确率始终停在随机猜测附近。
验证通过后,再把模型换到完整数据集继续调。后续优化我会按这个顺序做:先把帧数从8提升到16,然后把数据增强从随机裁剪换成多尺度裁剪,再加入时序滑动窗口融合。每一步只动一个变量,对照验证集top-1变化,避免同时改了三个参数之后不知道是谁带来的提升。
最后再提一个容易被忽略的细节:训练时用TensorBoard记录梯度范数,命令是tensorboard --logdir runs。如果模型前期的梯度范数大于5,学习率就要降,或者给优化器加上clip_grad_norm操作。视频网络的输入维度比图像分类高,对学习率更敏感,很多在图像上能跑的初始学习率转移到视频上会直接发散。看准梯度范数这一项,就能把你的调参周期从按天计变成按小时计。
本文还有配套的精品资源,点击获取