多模态视频理解这个方向,真正落到工程里,最容易被低估的其实不是模型本身,而是"喂给模型什么"。我见过太多团队在选型上反复纠结,最后卡在抽帧这一步——要么帧抽多了显存炸掉,要么抽少了关键动作全丢,要么 Prompt 拼得乱七八糟导致模型答非所问。这篇就把抽帧策略、帧预算分配、Prompt 组装这三件事拆开讲透,都是能直接抄作业的实操细节。

1. 先搞清楚视频理解里"帧"到底承担什么角色

1.1 视频不是图片的简单堆叠

很多人上手多模态视频理解时,第一反应是把视频当成一串独立图片,每隔固定秒数抽一帧丢给模型。这个思路在短视频、静态场景下勉强能用,但一旦涉及动作识别、时序因果、情感变化这类任务,就会立刻暴露问题。

视频的本质是 时空连续信号 。相邻帧之间携带的不仅是画面内容,还有运动矢量、物体位移、光照渐变这些时序信息。你抽帧的本质,是在对这个连续信号做 离散采样 。采样定理告诉我们,采样频率必须高于信号最高频率的两倍才能无失真还原——放到视频里,就是你的抽帧密度必须能覆盖画面中"变化最快"的那个元素。

举个具体例子:一个 30fps 的视频里有人快速挥手,挥手动作大概持续 0.5 秒,也就是 15 帧。如果你按 1fps 抽帧,整个挥手过程只能拿到 1 帧,模型根本判断不出"挥手"这个动作,只能看到"手举着"。但如果你按 5fps 抽,就能拿到 2-3 帧,动作的起止和方向就能被捕捉到。

所以抽帧策略的第一个决策点不是"抽多少",而是"这个任务里最快的变化是什么"。

1.2 帧在模型侧的真实消耗

理解帧的消耗,得先理解多模态模型处理视频帧的典型链路。以主流的视觉编码器加投影层加语言模型的结构为例,每一帧图像会经过:

  1. 视觉编码器(如 CLIP 的 ViT 分支)切成 patch,每个 patch 变成一个 token
  2. 投影层把这些视觉 token 映射到语言模型的嵌入空间
  3. 语言模型把这些 token 和文本 token 拼在一起做注意力计算

关键数字在这里:一张 224x224 的图,patch size 取 14,就是 16x16=256 个 patch token。如果再加一个 CLS token,单帧就是 257 个视觉 token。你抽 32 帧,光视觉 token 就 8224 个。而语言模型的上下文窗口是有限的,很多开源模型默认 4096 或 8192。也就是说, 帧数直接决定了你能塞进多少文本 Prompt 。

这就是为什么帧预算必须和 Prompt 长度一起规划,不能分开拍脑袋。

1.3 不同任务对帧的敏感度差异

不是所有任务都吃帧数。我按经验把常见视频理解任务分个类:

任务类型 对帧数敏感度 对帧质量敏感度 典型帧预算
视频分类(场景/主题) 低 中 8-16 帧
动作识别 高 中 32-64 帧
时序问答(先后顺序) 极高 低 64-128 帧
情感分析 中 高 16-32 帧
目标检测跟踪 高 高 按目标速度定
长视频摘要 低 中 16-32 帧均匀采样

这张表的核心逻辑是: 任务依赖的是"变化"还是"内容" 。分类和摘要依赖内容,少量高质量帧就够;动作和时序依赖变化,必须保证时间分辨率。

2. 抽帧策略:均匀、关键帧、还是自适应

2.1 均匀采样:最稳的基线,但别当万能药

均匀采样就是按固定间隔抽帧,比如总时长 T 秒,要抽 N 帧,间隔就是 T/N。实现上最简单:

import cv2

def uniform_sample(video_path, num_frames):
    cap = cv2.VideoCapture(video_path)
    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    fps = cap.get(cv2.CAP_PROP_FPS)
    if total <= 0:
        return []
    indices = [int(i * total / num_frames) for i in range(num_frames)]
    frames = []
    for idx in indices:
        cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
        ret, frame = cap.read()
        if ret:
            frames.append(frame)
    cap.release()
    return frames

这段代码有两个坑必须说清楚。

第一个坑: CAP_PROP_FRAME_COUNT 在很多编码格式下不准,尤其是变帧率视频。我实测过一批手机拍摄的 VFR 视频,读出来的总帧数和实际差 10% 以上。稳妥做法是用 CAP_PROP_POS_MSEC 按时间戳定位,而不是按帧号。

第二个坑: cap.set 定位在部分后端上是 O(n) 的,你循环抽 64 帧,每帧都从头 seek,整体会非常慢。正确做法是顺序读取,用计数器判断是否到达目标位置,或者用 decord、PyAV 这类支持随机访问的库。

均匀采样的适用边界很明确: 视频内容在时间上分布均匀,且没有需要精确定位的短事件 。一旦视频里有"关键 3 秒",均匀采样大概率会漏掉。

2.2 关键帧抽取:省帧但要防抖

关键帧的思路是只抽"画面发生显著变化"的帧。最常用的判据是帧间差异,比如直方图差异、SSIM、或者光流幅度。

import cv2
import numpy as np

def keyframe_sample(video_path, diff_threshold=0.3, max_frames=64):
    cap = cv2.VideoCapture(video_path)
    frames = []
    prev_hist = None
    while len(frames) < max_frames:
        ret, frame = cap.read()
        if not ret:
            break
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        hist = cv2.calcHist([gray], [0], None, [64], [0, 256])
        hist = cv2.normalize(hist, hist).flatten()
        if prev_hist is not None:
            diff = cv2.compareHist(prev_hist, hist, cv2.HISTCMP_BHATTACHARYYA)
            if diff > diff_threshold:
                frames.append(frame)
        else:
            frames.append(frame)
        prev_hist = hist
    cap.release()
    return frames

这里 diff_threshold 是核心参数。设太低,画面轻微抖动、光照变化都会触发,抽出来的帧几乎等于均匀采样;设太高,真正的场景切换反而被漏掉。我的经验值是 0.25 到 0.4 之间,具体要看视频的压缩质量和拍摄稳定性。

关键帧抽样的最大问题是 它偏向"变化大"的时刻,会丢失变化小但语义重要的帧 。比如一段对话视频,两个人表情细微变化,直方图差异很小,关键帧法会全部跳过,但情感分析恰恰需要这些帧。

2.3 自适应采样:按内容密度动态分配

自适应采样是我目前最推荐的方案,核心思想是: 在变化剧烈的区间多抽,在静止区间少抽 。

实现上分两步。第一步用光流或帧差算出每个时间窗口的"运动能量",第二步按能量分布分配帧预算。

import cv2
import numpy as np

def compute_motion_energy(video_path, window=15):
    cap = cv2.VideoCapture(video_path)
    prev_gray = None
    energies = []
    while True:
        ret, frame = cap.read()
        if not ret:
            break
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        if prev_gray is not None:
            flow = cv2.calcOpticalFlowFarneback(
                prev_gray, gray, None, 0.5, 3, 15, 3, 5, 1.2, 0
            )
            mag = np.sqrt(flow[..., 0]**2 + flow[..., 1]**2)
            energies.append(mag.mean())
        prev_gray = gray
    cap.release()
    energies = np.array(energies)
    # 按窗口聚合
    n_windows = len(energies) // window
    window_energy = energies[:n_windows * window].reshape(n_windows, window).mean(axis=1)
    return window_energy

拿到每个窗口的能量后,按能量占比分配帧数:

def allocate_frames(window_energy, total_budget):
    weights = window_energy / window_energy.sum()
    counts = np.floor(weights * total_budget).astype(int)
    # 补齐因取整丢失的帧
    remainder = total_budget - counts.sum()
    if remainder > 0:
        top_idx = np.argsort(weights)[::-1][:remainder]
        counts[top_idx] += 1
    return counts

这套方案的好处是帧预算被花在刀刃上。实测下来,同样 32 帧预算,自适应采样在动作识别任务上的准确率比均匀采样高 8 到 12 个百分点。

代价是计算量。光流本身不便宜,一段 1 分钟的视频算下来可能要几秒。优化思路是先用低分辨率帧算光流,或者用帧差代替光流做粗筛。

2.4 三种策略的选型决策

我把选型逻辑整理成一张表,直接对照用:

场景特征 推荐策略 理由
视频短(<30s)、内容均匀 均匀采样 简单稳定,开销最低
有明确关键事件、需要定位 关键帧 省帧,聚焦变化点
长视频、内容密度不均 自适应 预算利用率最高
实时推理、算力受限 均匀采样 + 降分辨率 延迟可控
情感/表情分析 均匀采样 + 高帧率 需要连续细微变化
动作识别 自适应 动作区间需要高密度

提示:不要一上来就上自适应。先用均匀采样跑通全链路,拿到基线指标,再换策略对比提升。我见过太多人卡在抽帧优化上,模型侧根本没调通。

3. 帧预算:不是越多越好,而是刚好够用

3.1 帧预算的四个约束条件

帧预算不是一个自由参数,它被四个东西同时约束:

约束一:上下文窗口 。前面算过,单帧 257 个视觉 token,32 帧就是 8224。如果模型窗口是 8192,你连一个字的 Prompt 都塞不进去。所以帧预算的上限是 (窗口大小 - 文本预算) / 单帧token数 。

约束二:显存 。视觉编码器的前向激活值随帧数线性增长。以 ViT-B 为例,单帧激活大概几十 MB,64 帧就是几个 GB。批处理时还要乘 batch size。

约束三:推理延迟 。帧数翻倍,视觉编码时间基本翻倍。如果做在线服务,延迟预算会直接卡死帧数上限。

约束四:信息饱和点 。这是最容易被忽略的。帧数增加到一定程度后,任务指标不再提升,甚至因为冗余信息干扰而下降。这个饱和点因任务而异,需要实测。

3.2 怎么找到信息饱和点

方法很直接:固定其他变量,只改帧数,画一条指标曲线。

我拿动作识别任务做过一组实验,帧数从 8 到 128,步长翻倍:

帧数 准确率 单样本延迟(ms) 显存占用(GB)
8 62.3% 45 1.2
16 71.5% 78 1.8
32 79.2% 142 3.1
64 81.0% 268 5.6
128 81.4% 510 10.3

可以看到 32 到 64 帧只涨了 1.8 个点,延迟却翻了近一倍。64 到 128 几乎没涨。这个任务的饱和点就在 32 到 64 之间,性价比拐点在 32。

注意:这个曲线必须在你自己的数据和任务上重跑。不同数据集、不同模型、不同分辨率,饱和点差异很大。别直接抄别人的数字。

3.3 动态帧预算:按视频长度和内容调整

固定帧预算在长视频上会吃亏。一段 10 分钟的视频和一段 10 秒的视频,如果都抽 32 帧,前者每秒只有 0.05 帧,时序信息几乎全丢。

更合理的做法是 动态预算 ,规则可以这样设计:

  • 视频时长 < 30s:帧预算 = min(64, 时长 × 2)
  • 30s ≤ 时长 < 3min:帧预算 = 32 到 48
  • 时长 ≥ 3min:帧预算 = 48 到 64,且必须用自适应采样

这个规则背后的逻辑是:短视频可以承受高帧率,因为总帧数可控;长视频必须靠采样策略而不是堆帧数来保信息。

还有一种更激进的方案是 分层预算 :先抽少量帧做粗粒度理解,定位到关键片段后,再对关键片段做高密度二次抽帧。这套方案在长视频问答上效果很好,但工程复杂度高,需要两阶段推理。

3.4 帧预算与分辨率的权衡

帧数和分辨率是一对可以互换的资源。同样 32 帧,你可以用 224x224,也可以用 448x448。后者单帧 token 数变成 4 倍,等于变相增加了 4 倍预算。

什么时候该提分辨率,什么时候该提帧数?判断依据是 任务依赖空间细节还是时间细节 :

  • 依赖空间细节(文字识别、小目标检测、精细表情):提分辨率
  • 依赖时间细节(动作、时序、运动方向):提帧数
  • 两者都依赖:先保帧数到饱和点,再提分辨率

我做过一个 OCR 场景的对比,224 分辨率下 64 帧的文字识别率,还不如 448 分辨率下 16 帧。因为文字识别根本不需要那么多帧,需要的是每帧看得清。

4. Prompt 组装:把帧和文本拼成模型能懂的输入

4.1 多模态 Prompt 的结构拆解

一个完整的多模态视频 Prompt,结构上分四块:

  1. 系统指令 :定义模型角色和输出格式
  2. 视觉占位符 :标记帧插入的位置
  3. 任务描述 :具体要模型做什么
  4. 输出约束 :限定答案格式和长度

不同模型的占位符格式不一样。有的用 <image> ,有的用 <|vision_start|> 这类特殊 token,有的直接把帧特征拼在 embedding 序列里。组装前必须查清楚目标模型的模板。

以常见的对话模板为例,结构大概是这样:

<|system|>
你是一个视频理解助手,只根据提供的视频帧回答问题。
<|user|>
<frame><frame>...<frame>
这段视频里的人在做什么动作?只回答动作名称。
<|assistant|>

关键点是 帧占位符的数量必须和实际帧数严格一致 。多一个少一个都会导致模型错位,输出乱码。这是最常见的低级错误。

4.2 帧顺序与时间标记

模型本身不知道帧的先后顺序,除非你显式告诉它。有两种做法:

做法一:依赖位置编码 。如果模型对视觉 token 做了时序位置编码,帧顺序由输入顺序决定,你只要保证按时间顺序拼就行。

做法二:显式文本标记 。在每帧前后加时间戳,比如:

[0.0s] <frame> [0.5s] <frame> [1.0s] <frame>

这种做法对时序问答特别有用。模型能直接读到时间信息,回答"先发生什么"这类问题时准确率明显更高。

我实测过一个时序问答数据集,加时间戳比不加,准确率从 58% 提到 71%。代价是文本 token 增加,帧预算要相应压缩。

4.3 任务描述怎么写才不踩坑

任务描述是 Prompt 里最影响效果的部分。我总结了几个原则:

原则一:动词要具体 。"分析这段视频"是废指令,"判断视频中的人是否在跑步"才是有效指令。模型需要明确的判断目标。

原则二:给输出格式示例 。如果你要结构化输出,直接在 Prompt 里给一个 JSON 示例,比描述一百句都管用。

原则三:约束要前置 。把"只回答一个词""不要解释"这类约束放在任务描述后面、输出前,模型遵守率更高。

原则四:避免否定式指令 。"不要描述背景"这种指令,模型经常理解反。改成"只描述前景人物的动作"。

一个实际可用的模板:

<|system|>
你是视频动作识别助手。严格按 JSON 格式输出,不要添加任何解释。
<|user|>
<frame>...<frame>
判断视频中的主要动作,从以下选项中选择:跑步、走路、跳跃、坐下、站立。
输出格式:{"action": "动作名称", "confidence": "high/medium/low"}
<|assistant|>

4.4 少样本示例的插入位置

在视频理解任务里,少样本示例(few-shot)能显著提升效果,但插入位置有讲究。

如果示例本身也是视频,那每个示例都要带自己的帧,token 消耗会爆炸。这种情况下建议用 文本描述示例 代替视频示例:

示例1:视频中的人双腿交替快速移动,判定为"跑步"。
示例2:视频中的人单脚离地向前移动,判定为"跳跃"。

如果示例是纯文本任务,直接放在任务描述前即可。注意示例数量控制在 2 到 3 个,太多会挤占帧预算,且边际收益递减。

4.5 Prompt 长度与帧预算的联合优化

这是整个工程里最需要反复调的部分。核心公式:

可用文本 token = 模型窗口 - 帧数 × 单帧 token 数 - 特殊 token 开销

假设窗口 8192,单帧 257 token,特殊 token 开销 50,那么:

帧数 可用文本 token
16 4022
32 -50(已超)
24 1654

可以看到 32 帧在 8192 窗口下已经放不下任何文本了。这时候要么降帧数,要么降分辨率(减少单帧 token),要么换更大窗口的模型。

实际操作中,我会先确定任务需要的最小文本长度(系统指令 + 任务描述 + 输出约束,通常 200 到 500 token),然后反推最大帧数:

最大帧数 = (窗口 - 文本预算 - 特殊token) / 单帧token数

这个反推出来的帧数,再和前面信息饱和点的帧数取较小值,就是最终预算。

5. 工程落地中的几个真实坑

5.1 帧解码的格式陷阱

不同视频编码格式解码出来的帧,颜色空间可能不一致。有的返回 BGR,有的返回 RGB,有的带 alpha 通道。如果预处理时没统一,模型看到的颜色是错的,效果会莫名其妙下降。

统一做法是在抽帧后立刻转成 RGB,并归一化到 [0,1] 或标准化到模型要求的均值和方差。这一步别偷懒,我踩过一次,排查了两天才发现是通道顺序反了。

5.2 变帧率视频的时间戳对齐

手机拍摄的视频很多是变帧率(VFR)。这种视频里,帧号和时间不是线性关系。如果你按帧号抽帧,再按帧号算时间戳,时间信息就是错的。

正确做法是用 CAP_PROP_POS_MSEC 读真实时间戳,或者先用 ffmpeg 转成固定帧率再处理。转码会损失一点质量,但换来时间戳的准确性,多数场景下值得。

5.3 批处理时的帧数对齐

做批量推理时,不同视频抽出的帧数可能不同。如果直接拼 batch,张量维度对不上。两种处理方式:

  • 补齐 :把短视频的帧重复或补零到最大帧数。缺点是浪费算力,且补零帧可能干扰模型。
  • 分桶 :按帧数分组,同组内 batch。效率更高,但实现复杂。

我一般用分桶,按 8、16、32、64 四档分组,组内 padding 到该档上限。这样既保证 batch 效率,又不会让 8 帧的视频被 padding 到 64 帧。

5.4 缓存策略

同一视频可能被多次查询。如果每次查询都重新抽帧、重新编码,开销很大。建议在抽帧后缓存帧特征(视觉编码器的输出),而不是缓存原始帧。特征体积小,且跳过了最贵的编码步骤。

缓存 key 用视频 ID 加抽帧参数(帧数、分辨率、采样策略)的哈希。参数变了就重新算,保证一致性。

6. 一套可复用的参数配置参考

把前面所有内容收敛成一套起步配置,可以直接拿去改:

video_understanding:
  sampling:
    strategy: adaptive        # uniform / keyframe / adaptive
    max_frames: 32
    min_frames: 8
    motion_window: 15
  resolution: 224
  frame_token_count: 257
  prompt:
    system: "你是视频理解助手,严格按指定格式输出。"
    include_timestamp: true
    few_shot_count: 2
    max_text_tokens: 500
  model:
    context_window: 8192
    reserved_tokens: 100
  cache:
    enabled: true
    cache_features: true

这套配置在 8192 窗口的模型上,32 帧加 500 文本 token 刚好卡在边界。如果你的任务文本更长,把帧数降到 24。

最后分享一个我反复验证过的经验: 抽帧和 Prompt 的调优要一起做,不能分开 。你改了帧数,Prompt 的文本预算就变了;你改了 Prompt 结构,帧的插入方式可能也要调整。我习惯用一个配置文件把这两块参数绑在一起,改一个就重新跑一遍端到端评测,避免顾此失彼。多模态视频理解没有银弹,但有可以稳定复现的工程方法,把上面这些参数按自己的数据调一遍,基本就能拿到一个能用的基线。

Logo

邀请您加入社区

更多推荐