视频理解 —— 时序建模
一段视频是一串图像,加上把它们连起来的物理。每个视频模型要么把时间当成一个额外的轴(3D 卷积)、一个可注意的序列(transformer),要么当成一个提取一次再池化的特征(2D+池化)。
类型: Learn + Build 语言: Python 前置要求: 阶段 4 第 03 课(CNN)、阶段 4 第 04 课(图像分类) 预计时间: ~45 分钟
学习目标
- 区分三种主要的视频建模方法(2D+池化、3D 卷积、时空 transformer),预测它们的成本和准确率权衡
- 用 PyTorch 实现帧采样、时序池化,以及一个 2D+池化的基线分类器
- 解释为什么 I3D 的"膨胀"3D 核能从 ImageNet 权重很好地迁移,以及因式分解的 (2+1)D 卷积做法有何不同
- 读懂标准动作识别数据集和指标:Kinetics-400/600、UCF101、Something-Something V2;clip 级和 video 级的 top-1 准确率
问题背景
一段 30 秒、30 fps 的视频是 900 张图像。朴素地看,视频分类就是把图像分类跑 900 次再做某种聚合。当动作在几乎每一帧都可见时(体育、烹饪、健身视频),这行得通;而当动作由运动本身定义时就惨败:"把某物从左推到右"在每一帧里看起来都是两个静止的物体。
每个视频架构的核心问题是:时序结构何时被建模、怎么建模?答案驱动其余一切——算力成本、预训练策略、能否复用 ImageNet 权重、模型在什么数据集上训练。
这一课故意比静态图像那几课短。图像的核心机制已经就位,视频理解大多是关于时序这条线:采样、建模、聚合。
核心概念
三个架构家族
flowchart LR
V["视频片段<br/>(T 帧)"] --> A1["2D + 池化<br/>每帧跑一次 2D CNN,<br/>在时间上平均"]
V --> A2["3D 卷积<br/>在<br/>T x H x W 上卷积"]
V --> A3["时空<br/>transformer<br/>在 (t, h, w) token 上<br/>做注意力"]
A1 --> C["Logits"]
A2 --> C
A3 --> C
style A1 fill:#dbeafe,stroke:#2563eb
style A2 fill:#fef3c7,stroke:#d97706
style A3 fill:#dcfce7,stroke:#16a34a2D + 池化
拿一个 2D CNN(ResNet、EfficientNet、ViT)。在每个采样帧上独立跑它。把逐帧嵌入做平均(或 max-pool,或 attention-pool)。把池化后的向量喂给分类器。
优点:
- ImageNet 预训练直接迁移。
- 实现最简单。
- 便宜:T 帧 * 单图推理成本。
缺点:
- 无法建模运动。动作 = 外观的聚合。
- 时序池化对顺序不变;"开门"和"关门"看起来一样。
何时用:外观主导的任务、小视频数据集上的迁移学习、初始基线。
3D 卷积
把 2D (H, W) 核换成 3D (T, H, W) 核。网络在空间和时间上都做卷积。早期家族:C3D、I3D、SlowFast。
I3D 技巧:拿一个预训练的 2D ImageNet 模型,把每个 2D 核沿一个新的时间轴复制来"膨胀"它。一个 3x3 的 2D 卷积变成一个 3x3x3 的 3D 卷积。这给 3D 模型强大的预训练权重,而不用从零训练。
优点:
- 直接建模运动。
- I3D 膨胀白送迁移学习。
缺点:
- 比 2D 对应物多 T/8 的 FLOPs(时序核为 3、堆 3 次时)。
- 时序核很小;长程运动需要金字塔或双流方法。
何时用:运动即信号的动作识别(Something-Something V2、Kinetics 里运动密集的类别)。
时空 transformer
把视频 token 化成时空 patch 的网格,在全部 patch 上做注意力。TimeSformer、ViViT、Video Swin、VideoMAE。
要紧的注意力模式:
- 联合(Joint) —— 在 (t, h, w) 上做一次大注意力。在
T*H*W上是二次的;贵。 - 分离(Divided) —— 每个块两次注意力:一次在时间上,一次在空间上。近似线性缩放。
- 因式分解(Factorised) —— 时间注意力和空间注意力跨块交替。
优点:
- 在每个主流基准上都是 SOTA 准确率。
- 通过 patch 膨胀从图像 transformer(ViT)迁移。
- 通过稀疏注意力支持长上下文视频。
缺点:
- 吃算力。
- 需要小心选注意力模式,否则运行时膨胀。
何时用:大数据集、高保真视频理解、多模态视频+文本任务。
帧采样
一段 10 秒、30 fps 的片段是 300 帧;把全部 300 帧喂给任何模型都是浪费。标准策略:
- 均匀采样 —— 在片段上均匀挑 T 帧。2D+池化的默认。
- 密集采样 —— 随机的连续 T 帧窗口。3D 卷积常用,因为运动需要相邻帧。
- 多片段(Multi-clip) —— 从同一视频采多个 T 帧窗口,各自分类,测试时把预测平均。
T 通常是 8、16、32 或 64。T 越高 = 越多时序信号、越多算力。
评估
两个层级:
- Clip 级准确率 —— 模型看一个 T 帧片段,报告 top-k。
- Video 级准确率 —— 把每个视频的多个片段的 clip 级预测平均;更高、更稳。
永远两个都报。一个 78% clip / 82% video 的模型严重依赖测试时平均;一个 80% / 81% 的模型在单片段上更鲁棒。
你会遇到的数据集
- Kinetics-400 / 600 / 700 —— 通用动作数据集。40 万片段;YouTube URL(很多现在已失效)。
- Something-Something V2 —— 运动定义的动作("把 X 从左移到右")。无法用 2D+池化解决。
- UCF-101、HMDB-51 —— 更老、更小,仍在被报告。
- AVA —— 空间和时间上的动作定位;比分类更难。
v4-video-temporal动手构建
第 1 步:帧采样器
作用于帧列表(或视频张量)的均匀和密集采样器。
import numpy as np
def sample_uniform(num_frames_total, T):
if num_frames_total <= T:
return list(range(num_frames_total)) + [num_frames_total - 1] * (T - num_frames_total)
step = num_frames_total / T
return [int(i * step) for i in range(T)]
def sample_dense(num_frames_total, T, rng=None):
rng = rng or np.random.default_rng()
if num_frames_total <= T:
return list(range(num_frames_total)) + [num_frames_total - 1] * (T - num_frames_total)
start = int(rng.integers(0, num_frames_total - T + 1))
return list(range(start, start + T))两者都返回 T 个索引,你用它们切片视频张量。
第 2 步:一个 2D+池化基线
在每帧上跑一个 2D ResNet-18,平均池化特征,分类。
import torch
import torch.nn as nn
from torchvision.models import resnet18, ResNet18_Weights
class FramePool(nn.Module):
def __init__(self, num_classes=400, pretrained=True):
super().__init__()
weights = ResNet18_Weights.IMAGENET1K_V1 if pretrained else None
backbone = resnet18(weights=weights)
self.features = nn.Sequential(*(list(backbone.children())[:-1])) # 保留全局平均池化
self.head = nn.Linear(512, num_classes)
def forward(self, x):
# x: (N, T, 3, H, W)
N, T = x.shape[:2]
x = x.view(N * T, *x.shape[2:])
feats = self.features(x).view(N, T, -1)
pooled = feats.mean(dim=1)
return self.head(pooled)
model = FramePool(num_classes=10)
x = torch.randn(2, 8, 3, 224, 224)
print(f"output: {model(x).shape}")
print(f"params: {sum(p.numel() for p in model.parameters()):,}")一千一百万参数,ImageNet 预训练,逐帧跑,平均,分类。在外观密集的任务上,这个基线常常和正经的 3D 模型差距在 5-10 个点以内——有时还更好,因为它复用了一个更强的 ImageNet 骨干。
第 3 步:一个 I3D 风格的膨胀 3D 卷积
把单个 2D 卷积沿一个新的时间轴重复权重,变成 3D 卷积。
def inflate_2d_to_3d(conv2d, time_kernel=3):
out_c, in_c, kh, kw = conv2d.weight.shape
weight_3d = conv2d.weight.data.unsqueeze(2) # (out, in, 1, kh, kw)
weight_3d = weight_3d.repeat(1, 1, time_kernel, 1, 1) / time_kernel
conv3d = nn.Conv3d(in_c, out_c, kernel_size=(time_kernel, kh, kw),
padding=(time_kernel // 2, conv2d.padding[0], conv2d.padding[1]),
stride=(1, conv2d.stride[0], conv2d.stride[1]),
bias=False)
conv3d.weight.data = weight_3d
return conv3d
conv2d = nn.Conv2d(3, 64, kernel_size=3, padding=1, bias=False)
conv3d = inflate_2d_to_3d(conv2d, time_kernel=3)
print(f"2D weight shape: {tuple(conv2d.weight.shape)}")
print(f"3D weight shape: {tuple(conv3d.weight.shape)}")
x = torch.randn(1, 3, 8, 56, 56)
print(f"3D output shape: {tuple(conv3d(x).shape)}")除以 time_kernel 让激活量级大致保持不变——这对第一遍不破坏批归一化统计量很重要。
第 4 步:因式分解的 (2+1)D 卷积
把一个 3D 卷积拆成一个 2D(空间)和一个 1D(时序)卷积。相同感受野,更少参数,在某些基准上准确率更好。
class Conv2Plus1D(nn.Module):
def __init__(self, in_c, out_c, kernel_size=3):
super().__init__()
mid_c = (in_c * out_c * kernel_size * kernel_size * kernel_size) \
// (in_c * kernel_size * kernel_size + out_c * kernel_size)
self.spatial = nn.Conv3d(in_c, mid_c, kernel_size=(1, kernel_size, kernel_size),
padding=(0, kernel_size // 2, kernel_size // 2), bias=False)
self.bn = nn.BatchNorm3d(mid_c)
self.act = nn.ReLU(inplace=True)
self.temporal = nn.Conv3d(mid_c, out_c, kernel_size=(kernel_size, 1, 1),
padding=(kernel_size // 2, 0, 0), bias=False)
def forward(self, x):
return self.temporal(self.act(self.bn(self.spatial(x))))
c = Conv2Plus1D(3, 64)
x = torch.randn(1, 3, 8, 56, 56)
print(f"(2+1)D output: {tuple(c(x).shape)}")一个完整的 R(2+1)D 网络就是一个 ResNet-18,把每个 3x3 卷积换成 Conv2Plus1D。
实际使用
两个库覆盖生产视频工作:
torchvision.models.video—— R(2+1)D、MViT、Swin3D,带预训练的 Kinetics 权重。和图像模型同一套 API。pytorchvideo(Meta)—— 模型库、Kinetics / SSv2 / AVA 的 data loader、标准变换。
做视觉-语言视频模型(视频字幕、视频问答),用 transformers(VideoMAE、VideoLLaMA、InternVideo)。
拿去用
这一课产出:
outputs/prompt-video-architecture-picker.md—— 一个 prompt,根据"外观 vs 运动"、数据集规模和算力预算,挑选 2D+池化 / I3D / (2+1)D / transformer。outputs/skill-frame-sampler-auditor.md—— 一个 skill,检查视频 pipeline 的采样器并标出常见 bug:差一索引、num_frames < T时采样不均、缺少保宽高比的裁剪等。
练习
- (简单) 算 FramePool(T=8)与 I3D 风格 3D ResNet(T=8)的(近似)FLOPs。论证为什么 2D+池化便宜 3-5 倍。
- (中等) 生成一个合成视频数据集:随机的球往随机方向移动,按运动方向打标签("从左到右""从右到左""对角向上")。在它上面训练 FramePool。展示它达到接近随机的准确率,证明对运动任务光靠外观不够。
- (困难) 把 ResNet-18 里每个 Conv2d 换成
Conv2Plus1D,搭一个 R(2+1)D-18。从一个 ImageNet 预训练的 ResNet-18 膨胀第一个卷积的权重。在练习 2 的运动数据集上训练,打败 FramePool。
关键术语
| 术语 | 大家嘴上怎么说 | 它实际是什么 |
|---|---|---|
| 2D + 池化 | "逐帧分类器" | 在每个采样帧上跑一个 2D CNN,跨时间平均池化特征,分类 |
| 3D 卷积 | "时空核" | 在 (T, H, W) 上卷积的核;能原生建模运动 |
| 膨胀(Inflation) | "把 2D 权重抬到 3D" | 沿新时间轴重复 2D 卷积的权重来初始化 3D 卷积权重,再除以 kernel_T 保住激活尺度 |
| (2+1)D | "因式分解卷积" | 把 3D 拆成 2D 空间 + 1D 时序;更少参数,中间多一个非线性 |
| 分离注意力 | "先时间后空间" | 每层两次注意力的 transformer 块:一次在同一帧的 token 上,一次在同一位置的 token 上 |
| Clip | "T 帧窗口" | T 帧的一个采样子序列;视频模型消费的单位 |
| Clip vs video 准确率 | "两种评估设置" | Clip = 每视频一个样本,video = 跨多个采样片段平均 |
| Kinetics | "视频界的 ImageNet" | 400-700 个动作类别、30 万+ YouTube 片段,标准的视频预训练语料 |
延伸阅读
- I3D: Quo Vadis, Action Recognition (Carreira & Zisserman, 2017) —— 引入膨胀和 Kinetics 数据集
- R(2+1)D: A Closer Look at Spatiotemporal Convolutions (Tran et al., 2018) —— 因式分解卷积,至今仍是强基线
- TimeSformer: Is Space-Time Attention All You Need? (Bertasius et al., 2021) —— 第一个强大的视频 transformer
- VideoMAE (Tong et al., 2022) —— 视频的掩码自编码器预训练;当前主导的预训练配方