视频理解:时序建模 本节摘要:视频是一串图像加上把它们连起来的物理。每个视频模型要么把时间当成一个额外轴(3D 卷积)、要么当成一段要去 attend 的序列(Transformer)、要么当成提取一次再池化的特征(2D+pool)。本节把这三大家族的成本与精度权衡摆清楚,实现帧采样、时间池化和 2D+pool 基线分类器,讲透 I3D「膨胀」3D 核为何能直接迁移 ImageNet 权重、(2+1)D 分解卷积做了什么不同,并认识 Kinetics、Something-Something V2 等动作识别数据集的评估方式。读完本节,你能为给定任务在三类架构间做出合理选择。 对应原课程:Phase 4 · Lesson 12 · (原英文 )。
本节摘要:视频是一串图像加上把它们连起来的物理。每个视频模型要么把时间当成一个额外轴(3D 卷积)、要么当成一段要去 attend 的序列(Transformer)、要么当成提取一次再池化的特征(2D+pool)。本节把这三大家族的成本与精度权衡摆清楚,实现帧采样、时间池化和 2D+pool 基线分类器,讲透 I3D「膨胀」3D 核为何能直接迁移 ImageNet 权重、(2+1)D 分解卷积做了什么不同,并认识 Kinetics、Something-Something V2 等动作识别数据集的评估方式。读完本节,你能为给定任务在三类架构间做出合理选择。
对应原课程:Phase 4 · Lesson 12 ·
video-understanding(原英文phases/04-computer-vision/12-video-understanding/docs/en.md)。
阅读完本节,你应当能够:
30 秒、30 fps 的视频是 900 张图。朴素地说,视频分类就是把图像分类跑 900 次再做某种聚合。当动作几乎在每一帧都看得见(体育、烹饪、健身)时它有效;但当动作本身由运动定义时它彻底失败:「从左推到右」在每一帧里都是两个静止物体。
每个视频架构的核心问题是:时序结构何时被建模、怎么建模? 答案决定了一切——算力成本、预训练策略、能否复用 ImageNet 权重、要训什么数据集。
本节比静态图像章节短是有意的。核心图像机制已经就位,视频理解主要是讲时间的故事:采样、建模、聚合。
拿一个 2D CNN(ResNet、EfficientNet、ViT),对每个采样帧独立跑一遍,把每帧嵌入平均(或最大池化、或注意力池化),送进分类器。
优点:ImageNet 预训练直接迁移;实现最简单;便宜——T 帧 × 单图推理成本。
缺点:无法建模运动,动作只是外观的聚合;时间池化对顺序不变,「开门」和「关门」看起来一样。
适用:外观为主的任务、小视频数据集上的迁移学习、初始基线。
把 2D (H, W) 核换成 3D (T, H, W) 核,网络同时在空间和时间上卷积。早期家族:C3D、I3D、SlowFast。
I3D 的招:拿一个预训练的 2D ImageNet 模型,沿新时间轴复制每个 2D 核把它「膨胀」成 3D。3×3 的 2D 卷积变成 3×3×3 的 3D 卷积,于是 3D 模型直接拿到了强力预训练权重,而非从头训。
优点:直接建模运动;I3D 膨胀白送迁移学习。
缺点:比 2D 对应物多约 T/8 的 FLOPs(时间核为 3、堆叠 3 次时);时间核小,长程运动要金字塔或双流。
适用:运动是信号的动作识别(Something-Something V2、运动密集的 Kinetics 类别)。
把视频切成时空 patch 网格,在所有 patch 间做注意力。TimeSformer、ViViT、Video Swin、VideoMAE。
要紧的注意力模式:
T·H·W 平方复杂度,贵。优点:每个主流基准上的 SOTA 精度;可经 patch 膨胀从图像 Transformer(ViT)迁移;支持稀疏注意力的长上下文视频。
缺点:算力大;注意力模式选不好运行时就炸。
适用:大数据集、高保真视频理解、多模态视频+文本任务。
10 秒 30 fps 的片段是 300 帧;全喂给任何模型都是浪费。标准策略:
T 通常取 8、16、32 或 64。T 越大,时序信号越多但算力越大。
两个层级:
两者都要报。一个 78% 片段 / 82% 视频的模型严重依赖测试时平均;80% / 81% 的则每片段更鲁棒。
均匀采样器和密集采样器,作用在帧列表(或视频张量)上。
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 个下标,用来切片视频张量。
对每帧跑 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"输出: {model(x).shape}") print(f"参数量: {sum(p.numel() for p in model.parameters()):,}")
1100 万参数,ImageNet 预训练,逐帧跑、平均、分类。这个基线在外观为主的任务上常常只比正经 3D 模型低 5~10 个点——有时还更高,因为它复用了更强的 ImageNet 主干。
把单个 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 权重形状: {tuple(conv2d.weight.shape)}") print(f"3D 权重形状: {tuple(conv3d.weight.shape)}") x = torch.randn(1, 3, 8, 56, 56) print(f"3D 输出形状: {tuple(conv3d(x).shape)}")
除以 time_kernel 是为了保持激活幅度大致不变——这对第一遍不破坏批归一化统计至关重要。
把 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 输出: {tuple(c(x).shape)}")
一个完整的 R(2+1)D 网络就是把 ResNet-18 里每个 3×3 卷积换成 Conv2Plus1D。
两个库覆盖生产级视频工作:
torchvision.models.video——R(2+1)D、MViT、Swin3D,带 Kinetics 预训练权重,API 与图像模型一致。pytorchvideo(Meta)——模型库、Kinetics / SSv2 / AVA 的数据加载器、标准变换。视觉-语言视频模型(视频字幕、视频问答)用 transformers(VideoMAE、VideoLLaMA、InternVideo)。
本节产出两个可复用文件(位于原课程 outputs/):
prompt-video-architecture-picker.md:一个提示词——按「外观 vs 运动」、数据集大小、算力预算,在 2D+pool / I3D / (2+1)D / Transformer 之间挑。skill-frame-sampler-auditor.md:一个技能——审查视频流水线的采样器,标记常见 bug:off-by-one 下标、num_frames < T 时采样不均、缺少保宽高比裁剪等。Conv2Plus1D,搭一个 R(2+1)D-18。第一个卷积的权重从 ImageNet 预训练的 ResNet-18 膨胀而来。在练习 2 的运动数据集上训练并打败 FramePool。time_kernel 保激活尺度,3D 模型直接拿到 ImageNet 权重。下一节进入3D 视觉与 NeRF——从二维图像升到三维场景,用神经辐射场表示并渲染新视角。