视频理解这几年在计算机视觉里的热度一直没降过,动作识别更是其中最贴近实际落地的一支。不管是智慧安防里的异常行为检测、体育动作分析、人机交互中的手势控制,还是短视频平台给视频自动打标签,背后都离不开动作识别这个底层能力。今天我就从动作识别这个方向入手,把“视频理解到底要干什么”“动作为什么这么难认”“工业界和学术界现在都怎么玩”这套东西掰开揉碎讲清楚,最后再带大家完整跑通一个小型动作识别项目。
1. 动作识别到底在解决什么问题
1.1 问题定义:从“找到人”到“看懂人在做什么”
动作识别(Action Recognition)要回答的问题非常朴素:给定一段视频,判断其中的人正在做什么动作。比如这个人是在跑步、挥手、跳高,还是摔倒。听起来像目标检测的延伸,但它在任务本质上完全不同。
图像里的目标检测回答的是“什么东西在哪”,输入是一张静态图,输出是物体类别和位置框。动作识别面对的是视频,输入是一串按时间排序的帧序列,输出的是动作类别标签。也就是说,算法不但要理解每一帧画面里有什么,还必须在时间维度上搞清楚这些画面之间的变化规律。
拿“跑步”和“走路”来说,单看某一帧,两个动作下的人的姿态可能就是一条腿在前一条腿在后,静止画面里很难区分。只有把连续多帧放在一起,让网络看明白腿的摆动节奏、身体重心的起伏,才能做出正确判断。这就是动作识别和静态图像理解最本质的区别:空间上要认识物体,时间上要理解变化。
1.2 难点分析:空间与时间的双线任务
动作识别难,主要难在四个层面。
第一,时间尺度的不确定性。一个动作可能持续0.5秒(眨眼、闪身),也可能持续几分钟(做一套操、打一场篮球)。算法既要捕捉瞬间的肢体变动,又要跨足够长的时序来建模整体动作模式,这给网络设计带来了很大麻烦——时间窗口定短了会丢信息,定长了会引入大量无关帧。
第二,动作边界的模糊性。一段视频里连续发生好几个动作,比如先走路、然后停住、再挥手。每个动作从哪里开始到哪里结束并没有清晰标注。实际应用中往往要同时解决“什么时候发生”和“发生了什么”两个问题,这就从纯分类问题上升到了时序分割与检测问题。
第三,多模态信息的复杂性。动作的发生伴随着外观变化(人穿的衣服、手中的道具)、姿态变化(肢体的空间构型)、运动变化(肢体移动的速度方向),还有可能伴随物体交互(比如“倒水”这个动作需要识别杯子和水的互动)。网络要把这些信息都融合进来才能形成准确判断。
第四,数据获取的成本极高。图像任务可以靠搜索引擎和人工标注快速积累百万张图,视频动作数据集的标注要远比这复杂——标注员看一段几秒的视频才能标出一个动作类别,要覆盖动作变化和视角变化,成本翻好几倍。
正因为这些难点,动作识别在很长一段时间里都是计算机视觉研究里公认的硬骨头,直到深度学习引入视频建模之后才逐步走上实用化道路。
2. 技术路线演进:从手工特征到视频大模型
2.1 传统手工特征时代的思路
在深度学习普及之前,动作识别的主流做法是“手工特征 + 分类器”两段式。研究人员手工设计特征描述子,从每段视频中提取出能够表达运动模式的向量,然后交给SVM等分类器去判断。
最有代表性的是改进的密集轨迹算法(IDT)。它的核心思路是在视频的多个空间尺度上密集采样特征点,然后用光流场跟踪这些特征点的运动轨迹,在轨迹周围提取梯度直方图(HOG)、光流直方图(HOF)和运动边界直方图(MBH)等特征,最终汇总成视频级别的描述子。
这整套流程逻辑完整,在当年的HMDB51和UCF101数据集上能达到不错的准确率,但问题也很明显:手工特征的设计依赖大量专家经验,而且计算密集度高,一条几秒的视频跑特征提取可能要花几十倍于视频时长的时间。
后来双流卷积网络在2014年横空出世,用ConvNet同时处理RGB帧和光流堆叠帧,才算正式把深度学习引入动作识别,也把准确率拉到了一个新台阶。
2.2 双流网络:用两路信息解耦空间与时间
双流网络的思想非常直接:既然动作识别既需要空间信息(物体长什么样、人处于什么姿态),又需要时间信息(物体怎么运动),那我就设计两个分支,一个吃RGB图像,负责空间建模,另一个吃多帧光流堆叠图,负责时间运动建模,最后把两支的特征拼接或取均值得到最终分类。
这种方法之所以有效,是因为它很好地贴合了人类视觉系统的两条通路——腹侧通路处理物体识别,背侧通路处理运动感知。两个分支各司其职,网络训练起来也更容易收敛。
但双流网络有个实操痛点:光流计算非常耗时,而且需要预先做光流提取并存储到磁盘,数据集一大,存储开销成倍增长。所以在工业落地场景里,很多团队更倾向于直接用端到端的3D卷积网络。
2.3 3D卷积与工业化路线:C3D、I3D、SlowFast
3D卷积网络的出现,把动作识别从“手工特征+光流”的耦合模式中解放了出来。C3D是这一路线的奠基之作,把2D卷积核在时间维度上扩展了一维,让网络直接从原始视频帧序列里学习时空特征,不需要显式计算光流。
C3D的卷积核是像3×3×3这样的三维张量,输出特征图也是三维的(宽×高×时间)。它端到端可训练,部署起来比双流方案简单得多,但C3D在训练效率和精度上还有不小提升空间。
此后I3D(Inflated 3D ConvNet)做了一个很巧妙的操作:把在ImageNet上预训练好的2D网络权重——包括卷积核和池化层——在时间维度上“膨胀”成3D网络,然后在视频数据集上微调。由于用到了大规模图像预训练,I3D收敛快,精度高,很长一段时间里都是各类视频任务的首选骨干网络。
再到SlowFast,这个设计更有意思:它刻意让两个分支以不同的帧率工作——低速路径用较低帧率捕捉空间语义信息,高速路径用较高帧率捕捉快速运动变化,两路最终融合。通过这种频率差异,网络对大动作和细微动作都有感知能力,计算量也比单纯6倍速率的3D卷积省不少。目前SlowFast仍然是很多工业方案的基线选择。
2.4 视频Transformer与预训练大模型
Transformer最初成名于自然语言处理,后来Vision Transformer(ViT)把它带到了图像领域,视频方向也不甘落后。TimeSformer、ViViT这类视频Transformer的核心做法,是把视频切分成三维时空块,再将块展开成token送入多头注意力机制。通过自注意力机制,网络能够直接建模任意两帧任意两个位置之间的长距离依赖,这比固定核大小的卷积在时序建模能力上更强。
不过视频Transformer真正起飞,还得归功于大规模视频预训练的兴起。像VideoMAE、InternVideo这类模型在大规模未标注视频上做掩码自编码预训练,学到的特征迁移到下游动作识别任务后,精度大幅超过从零训练的卷积网络。
但必须提醒大家的是:视频Transformer在精度上的优势,一部分是用巨量计算换来的。几十卡训练几十个epoch是常态,普通实验室和中小企业很少能负担这种成本,所以工程落地时往往还是会回到SlowFast和I3D这些成熟方案上来。
2.5 选型建议:不同场景下怎么挑模型
| 应用场景 | 推荐方案 | 理由 |
|---|---|---|
| 小规模数据集科研验证 | I3D(ImageNet预训练) | 收敛快,精度稳,显存要求适中 |
| 工业落地、边缘设备 | SlowFast(轻量版本)或TSN | 推理效率高,能拆分支路 |
| 长视频、复杂时序建模 | 视频Transformer(TimeSformer) | 长程依赖建模能力强 |
| 实时交互、手势识别 | 2D CNN+时序模块(LSTM/GRU) | 延迟低,算力消耗小 |
多提一句,任何模型选型都以你的实际算力和延迟要求为准绳。一味追新模型,很多时候只会把项目拖入算力泥潭。
3. 实操:用3D CNN训练一个人体动作识别模型
说了这么多理论,不如直接动手跑一个完整的动作识别项目。我带大家用PyTorch训练一个基于3D CNN的动作识别模型,数据集选用UCF101的一个小子集,让你在家用单卡也能跑完整个流程。
3.1 数据准备:从UCF101划分子集
UCF101是动作识别领域最经典的数据集之一,包含101个动作类别,总共13320段视频,分辨率基本是320×240,时长从几秒到几十秒不等。类别涵盖日常运动、人机交互、体育竞技等场景,非常适合入门学习。
完整跑101类需要的基础算力比较高,入门阶段可以从中挑选5个差异较大的动作类别:如游泳(Swimming)、射箭(Archery)、打高尔夫(GolfSwing)、骑车(Cycling)和弹吉他(Guitar)。每个类别选100段训练视频、30段测试视频,规模小,训练时间控制在合理范围内。
从UCF101官网下载好数据集后,目录结构一般是每个类别一个文件夹,里面放着对应视频文件。我们需要写一个脚本,将视频文件路径和标签整理成CSV或TXT列表,方便后续DataLoader读取。
import os import csv dataset_root = "data/UCF101" selected_classes = ["Swing", "Archery", "GolfSwing", "Cycling", "Guitar"] # 为每个类别分配数字标签 class_to_idx = {cls: i for i, cls in enumerate(selected_classes)} print("类别映射:", class_to_idx) records = [] for cls in selected_classes: class_dir = os.path.join(dataset_root, cls) for video_name in os.listdir(class_dir): if not video_name.endswith(".avi"): continue video_path = os.path.join(class_dir, video_name) # 简单按文件名区分训练集/测试集,也可以用官方split role = "train" if int(video_name.split("_")[-1].split(".")[0]) % 2 == 0 else "test" records.append([video_path, class_to_idx[cls], role]) with open("data/ucf101_subset.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["path", "label", "role"]) writer.writerows(records) print("共生成记录数:", len(records))这里有一个关键点:UCF101官方提供了三份train/test划分文件,我上面的代码只是用视频编号的奇偶做了一个临时划分。如果是正式实验,建议直接用官方指定的split文件,保证结果可复现。
3.2 视频预处理与采样策略
视频和图片最大的区别是带时间维度。训练时我们不能把整段视频全部塞进网络,一是显存放不下,二是计算量爆炸。实际操作中,一般对每段视频按等间隔采样固定数量的帧,这个帧数被称为clip长度,通常是8帧、16帧或32帧。
采样策略直接决定训练效果。过早采样(比如只取前几帧)可能看不到完整动作;过晚采样可能漏掉起始姿态;等间隔全覆盖是相对稳妥的默认做法。
import cv2 import numpy as np import torch from torch.utils.data import Dataset class VideoDataset(Dataset): def __init__(self, records, num_frames=16, frame_size=(112, 112)): self.records = records self.num_frames = num_frames self.frame_size = frame_size def __len__(self): return len(self.records) def __getitem__(self, idx): video_path, label, _ = self.records[idx] cap = cv2.VideoCapture(video_path) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total_frames == 0: cap.release() raise ValueError(f"视频读取失败:{video_path}") # 等间隔采样帧索引 frame_indices = np.linspace(0, total_frames - 1, self.num_frames).astype(int) frames = [] for i in range(total_frames): ret, frame = cap.read() if not ret: break if i in frame_indices: frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame = cv2.resize(frame, self.frame_size) frames.append(frame) cap.release() # 如果帧数不足,用最后一帧补齐 while len(frames) < self.num_frames: frames.append(frames[-1]) # 转成Tensor并归一化: [T, H, W, C] -> [C, T, H, W] frames = np.stack(frames, axis=0).astype(np.float32) / 255.0 frames = torch.from_numpy(frames).permute(3, 0, 1, 2).contiguous() return frames, label3.3 模型结构搭建:从零实现一个轻量3D CNN
这里不直接调torchvision里现成的模型,我们从零写一个精简的3D CNN,把每个模块的作用讲明白。网络结构参考C3D思路:连续多层三维卷积提取时空特征,中间穿插三维池化做空间和时间下采样,最后接全连接分类头,并在时间维度上做全局平均池化。
import torch import torch.nn as nn class Simple3DCNN(nn.Module): def __init__(self, num_classes=5): super(Simple3DCNN, self).__init__() self.conv1 = nn.Conv3d(3, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm3d(32) self.pool1 = nn.MaxPool3d(kernel_size=(1, 2, 2)) self.conv2 = nn.Conv3d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm3d(64) self.pool2 = nn.MaxPool3d(kernel_size=(2, 2, 2)) self.conv3 = nn.Conv3d(64, 128, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm3d(128) self.pool3 = nn.MaxPool3d(kernel_size=(2, 2, 2)) self.conv4 = nn.Conv3d(128, 256, kernel_size=3, padding=1) self.bn4 = nn.BatchNorm3d(256) self.pool4 = nn.MaxPool3d(kernel_size=(2, 2, 2)) self.avg_pool = nn.AdaptiveAvgPool3d((1, 1, 1)) self.fc = nn.Linear(256, num_classes) def forward(self, x): x = self.pool1(torch.relu(self.bn1(self.conv1(x)))) x = self.pool2(torch.relu(self.bn2(self.conv2(x)))) x = self.pool3(torch.relu(self.bn3(self.conv3(x)))) x = self.pool4(torch.relu(self.bn4(self.conv4(x)))) x = self.avg_pool(x) x = x.view(x.size(0), -1) x = self.fc(x) return x网络结构图大致是:输入[3,16,112,112]——第一层卷积后得到[32,16,112,112],池化变成[32,16,56,56];第二层卷积池化后变成[64,8,28,28];第三层变成[128,4,14,14];第四层变成[256,2,7,7];最后自适应池化成[256,1,1,1]。
这里我特意设计成时间维度通过第二层和第三层的Pool3d逐渐减半,整体参数量不到500万,单卡训练毫无压力。
3.4 训练流程与超参数调整
训练动作识别模型和训练图像分类模型整体流程一致,但有几个动作识别特有的设置需要注意。
使用交叉熵损失函数,配合SGD优化器。SGD在视频任务上的表现往往优于Adam,虽然收敛慢一点,但最终精度更高。初始学习率设0.01,每训练10个epoch衰减为原来的十分之一。
批量大小建议设8或16,这取决于你的显存。8帧的clip、112×112分辨率,单样本显存开销不高,16的批量在12GB显存的显卡上能稳定跑。
import torch.optim as optim train_records = [r for r in records if r[2] == "train"] test_records = [r for r in records if r[2] == "test"] train_dataset = VideoDataset(train_records) test_dataset = VideoDataset(test_records) train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16, shuffle=True, num_workers=4) test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=16, shuffle=False, num_workers=4) model = Simple3DCNN(num_classes=5) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(30): model.train() running_loss = 0.0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() print(f"Epoch {epoch+1}/{30}, Loss: {running_loss/len(train_loader):.4f}") # 每个epoch结束跑一次验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for inputs, labels in test_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f"Accuracy: {100 * correct / total:.2f}%")热门数据增强技巧也要用上:随机裁剪、水平翻转、随机时域裁剪(对clip的起始帧做随机偏移)都能有效防止过拟合。
3.5 评估与推理流程
训练完成后,保存模型权重,然后进入推理阶段。推理和训练有两点不同:一是模型切到eval模式,关掉dropout和BatchNorm的统计更新;二是不做随机数据增强,只做中心裁剪。
torch.save(model.state_dict(), "action_model.pth") # 推理阶段 model.load_state_dict(torch.load("action_model.pth")) model.eval() model.to(device) cap = cv2.VideoCapture("test_video.avi") # 采样逻辑与训练时一致,这里省略重复代码 with torch.no_grad(): clip_tensor = clip_tensor.unsqueeze(0).to(device) logits = model(clip_tensor) pred_class = torch.argmax(logits, dim=1).item() class_names = ["Swing", "Archery", "GolfSwing", "Cycling", "Guitar"] print(f"预测动作类别:{class_names[pred_class]}")在128GB内存、单张RTX 3090的配置下,这个小模型整个训练大概20分钟完成,测试集准确率能到75%以上。如果你想追求更高精度,把clip长度加长到32帧,或者换用预训练的I3D模型,都能往上走不少。
4. 常见问题与排查技巧实录
4.1 训练不收敛或严重过拟合
动作识别模型参数量大、数据集相对小,过拟合是新手最容易踩的坑。具体表现是训练loss持续下降、验证准确率反而下滑。
第一个排查点是数据增强是否到位。强烈建议加上随机裁剪和水平翻转,如果数据量特别小,还可以做多尺度裁剪,相当于变相扩增数据。第二个排查点是Dropout和权重衰减,全连接层后面加Dropout(0.5),SGD的weight_decay设到1e-4,都能有效抑制过拟合。第三个是正则化更强的迁移学习方案:直接加载Kinetics预训练权重做微调,而不是从零训练,效果立竿见影。
4.2 光流方案的存储与读取坑
如果你用的是双流网络路线,光流提取是绕不开的麻烦。OpenCV里Farneback光流算法虽然CPU也能跑,但处理UCF101全量数据集要跑好几个小时。更麻烦的是光流图以float16或float32格式存盘,存储量通常是视频体积的10倍以上,一个几千段视频的小数据集就能占掉几十GB磁盘空间。
省空间的思路是把光流场量化到0~255的整数范围再存成PNG格式,两张图分别存x方向分量和y方向分量,读取时再反归一化回浮点数。读取时的IO瓶颈也不容忽视,建议用lmdb或TFRecord之类的格式打包,比逐个文件读取快得多。
4.3 3D模型训练时显存爆炸
显存不够是最常见的硬件痛点。几个实用方案:把输入分辨率从224降到112,精度损失不大但显存占用降到四分之一;减少clip长度从32帧降到16帧甚至8帧;使用混合精度训练,在PyTorch中只需加一行torch.cuda.amp.autocast();如果整体都降下来还放不下,就用梯度累积。
4.4 时序信息利用不充分的判断方法
有时候模型在验证集上表现还行,但测试视频里动作做到一半、或者背景与训练集差异很大时就拉胯。一个简单的诊断方法:把测试视频的帧顺序打乱再送入模型,如果准确率没有明显下降,说明模型根本没学到时序信息,只是靠单帧外观在硬猜。
打乱帧序后精度掉20个点以上,说明模型确实建模了时序模式;如果几乎不掉点,就说明你的模型退化成“加强版单帧分类器”了。这时要检查网络的时间下采样步长是否过大、clip长度是否不足、以及训练数据里是否存在严重的静态外观偏置(比如某个类别总是出现在同一背景下)。
4.5 在线视频流场景的实时性问题
很多人训练完模型做实时推理时发现速度远达不到预期。3D卷积天生计算量大,在CPU上跑尤其吃力。实际项目里我常用的优化手段有这么几个:每N帧才抽一帧过模型,通过跳帧把推理频率降下来;采用“双阶段”策略——先用轻量2D目标检测把人物框出来,只裁剪人物区域做动作识别,既能减少计算量又能屏蔽背景干扰;用TensorRT/TVM对模型做量化加速,FP16或INT8量化后的推理延迟能再降一半以上。
接下来可以怎么扩展
这个基础项目跑通之后,可以往三个方向继续深入。一是做时序动作检测,给模型加一个时间维度的候选段生成模块,让它不仅能识别动作类别,还能输出“从哪一帧开始到哪一帧结束”;二是试试骨架动作识别路线,先用姿态估计提取人体关键点,再用ST-GCN这类图卷积网络学习骨架序列的时空图结构,对复杂背景干扰的鲁棒性更强;三是接入现成的大规模视频预训练模型做微调,比如基于视频掩码自编码的预训练权重,在更大数据集上能带来显著精度提升。
我个人跑项目下来最大的体会是:动作识别看起来像一个分类任务,但真正难的不是把网络搭出来,而是让网络真正学到“动态”而非“静态”。很多奇怪的效果问题,追根溯源都会回到时序建模能力上。所以做实验时不要只看最终准确率,试着去诊断模型是否真的利用了时间信息,这个习惯能让你的调试效率高出一大截。