早在尝试让大模型直接“看”视频的时候,我踩过一个非常典型的坑:把视频按固定间隔抽帧,抽出几百张图片,一股脑全部交给多模态大模型,然后希望它能给出一个漂亮的视频总结。结果并不意外——上下文长度直接爆掉,token 费用飙升,模型回答也越来越“敷衍”,甚至开始忽略中间的细节,只盯着开头和结尾几帧做判断。

后来我把同样的视频换了一种采样策略,只保留 12 帧,再交给同一个模型,回答质量反而明显提升。这个对比让我彻底明白了一件事: Frame selection is the whole game 。对 LLM 来说,视频理解本质上不是“模型能不能看”,而是“你给它看什么”。帧选得好,模型能抓住关键事件;帧选得差,再强的视觉编码器也救不回来。

本文会围绕“如何让 LLM 看懂视频”这条主线,从原理拆解到完整代码实现,讲清楚为什么帧选择如此重要、常用的帧选择策略有哪些、以及如何用 Python 构建一套可落地的视频帧采样工具。无论你是做视频内容理解、RAG 视频检索,还是准备给大模型接入视频数据,这篇文章都可以帮你少走很多弯路。

1. 背景与核心概念:LLM 为什么不能直接“看”视频?

1.1 视频不是 LLM 的原生输入

我们通常说“让 LLM 看视频”,这个说法其实并不准确。绝大多数 LLM 的原生输入是文本,即使是 GPT-4V、Qwen-VL、LLaVA 这类多模态大模型(VLM),它们真正处理的也不是连续的视频流,而是 图像帧序列 。

一个标准的视频理解链路通常是这样的:

视频文件 → 抽帧(Frame Extraction) → 图像帧 → 视觉编码器 → 视觉 Token → LLM 推理 → 文本输出

也就是说,视频进入模型之前,必须先把连续的时间轴拆成离散的图像。这个“拆”的过程,就是帧选择。帧选择的结果直接决定了两件事:一是模型能看到哪些视觉信息,二是输入上下文有多大。

1.2 直接抽帧会遇到的“三座大山”

如果不加思考,直接把视频按固定间隔抽帧然后全量塞给模型,通常会遇到三个问题。

第一, token 爆炸 。一个视频如果按 1 秒 1 帧抽取,10 分钟的视频就是 600 帧。按照常见的视觉编码器设计,每张图会被切分成几十到几百个视觉 token。600 帧意味着数万甚至十几万 token,很多模型的上下文窗口根本装不下,就算装得下,推理成本也会高到难以接受。

第二, 信息冗余 。视频的大多数帧之间的变化非常小,比如一个采访类视频,说话人坐在镜头前,背景几乎不变,如果连续抽 30 帧,可能 25 帧都是高度重复的。这些重复帧不仅浪费 token,还会干扰模型对重点内容的关注。

第三, 时间维度丢失或错乱 。LLM 在理解视频时,往往需要知道“事件发生的顺序”和“事件之间的因果关系”。如果帧与帧之间是均匀抽取的,时间关系还能大致保留;如果是随机抽取,或者采样点过密但没有突出关键事件,模型很容易给出混乱的答案。

1.3 为什么说“帧选择是全部关键”

“Frame selection is the whole game”这句话虽然略显夸张,但很有道理。原因在于, 帧选择决定了模型输入的信息上界 。如果关键事件帧没有被选中,模型无论多强都不可能“脑补”出没看到的内容;反过来,如果选中了合适的帧,即使帧数很少,模型也能凭借视觉语言对齐能力和常识推理能力,给出相当准确的回答。

所以,帧选择本质上是一个 信息压缩与关键信息保留 的平衡问题。好的帧选择策略,是要用尽量少的帧,覆盖视频中尽量多的重要事件。

2. 常见的帧选择策略与适用场景

在做实战之前,先梳理一下目前常用的帧选择策略。不同策略适合不同视频类型,没有哪个策略是万能的。

2.1 均匀采样(Uniform Sampling)

这是最简单的方法:每隔固定间隔抽取一帧。

frame_interval = fps * 2  # 每 2 秒抽一帧

适合场景:

  • 视频内容本身变化比较均匀,比如纪录片、教学视频。
  • 对实时性要求高,需要快速处理的场景。
  • 作为 baseline,用于和更复杂的策略做对比。

缺点也很明显:遇到长时间静态画面会冗余,遇到快速变化的动作场景又容易遗漏关键瞬间。

2.2 镜头边界检测(Shot Boundary Detection)

视频是由一个个“镜头”组成的,镜头之间存在明显的画面突变。镜头边界检测的目标就是找到这些突变点,然后在每个镜头内部选取代表帧。

这个方法比较适合:

  • 新闻视频、影视剧、广告片等有明显剪辑节奏的内容。
  • 需要“按场景理解”的任务,比如视频摘要、剧情分析。

镜头边界检测的常见手段包括:

  • 像素差法:比较相邻帧的像素差异。
  • 直方图差法:比较相邻帧的颜色直方图差异。
  • 光流法:通过光流变化判断画面运动剧烈程度。

直方图差法是工程上最常用、性价比最高的方案。

2.3 基于语义相似度的去重(Semantic Deduplication)

先对每一帧做视觉特征提取,然后计算相邻候选帧之间的相似度,过滤掉相似度太高的帧。

这种方法更适合:

  • 视频内容高度静态,比如讲座、采访、直播录屏。
  • 对 token 成本敏感,需要严格控制帧数上限。

常用工具包括 OpenAI CLIP 模型,或者其他图像 embedding 模型。

2.4 动态运动感知采样(Motion-Aware Sampling)

根据视频帧间运动强度动态调整采样密度:运动剧烈的地方多采样,运动平缓的地方少采样。

这种方法适合:

  • 体育赛事、舞蹈、动物行为分析等运动信息占主导的视频。
  • 需要精细捕捉动作细节的场景。

工程实现上,可以用光流、帧差绝对值等作为运动强度的衡量指标。

2.5 策略对比

策略 实现难度 信息保留能力 冗余控制 典型场景
均匀采样 低 中 弱 通用 baseline
镜头边界检测 中 强 中 影视、新闻、剧情
语义去重 中高 强 强 采访、讲座、录屏
运动感知采样 高 强 中 体育、舞蹈、动作分析

实战中,往往不是只用一种策略,而是多种策略组合。下面两节会给出两个完整的代码实现。

3. 环境准备与项目结构

帧选择工具的核心依赖是 OpenCV 和一些常用的 Python 库。虽然不同版本之间存在细微差异,但整体思路是通用的。

以我本机环境为例:

  • 操作系统:Ubuntu 22.04 / macOS 均可
  • Python:3.10+
  • 依赖库:opencv-python、numpy、Pillow、transformers、torch

先创建项目目录:

llm-video-frame-selection/
├── config.py
├── extract.py
├── scene_sampler.py
├── semantic_filter.py
├── llm_prompt.py
├── videos/
│   └── demo.mp4
└── output/
    └── frames/

安装依赖可以使用:

pip install opencv-python numpy pillow transformers torch

需要说明的是,如果你的网络环境无法直接下载 CLIP 模型权重,可以先跳过 5.2 节中的模型加载部分,用自己的本地图像特征模型替代。思路是通用的。

4. 实战案例一:基于镜头边界检测的帧采样器

这一节的完整目标是:输入一个视频,自动检测镜头边界,然后从每个镜头中抽取最有代表性的 1~2 帧,最终输出一组覆盖整个视频内容的帧集合。

4.1 实现思路

整体流程如下:

  1. 用 OpenCV 读取视频,获取总帧数和 fps。
  2. 对每一帧做缩放和颜色直方图计算。
  3. 比较相邻帧的直方图差异,差异超过阈值则认为是镜头切换点。
  4. 对每个镜头区间,按均匀原则抽取代表帧。
  5. 保存抽帧结果,同时输出每帧对应的时间戳。

这里比较关键的是“直方图差异”的计算方式:

diff = cv2.compareHist(hist1, hist2, cv2.HISTCMP_BHATTACHARYYA)

Bhattacharyya 距离取值范围在 0 到 1 之间,值越大表示两帧差异越大。一般可以设置阈值为 0.4 到 0.6,具体需要根据视频类型调整。

4.2 完整代码

4.2.1 配置文件 config.py
# 文件路径:config.py
VIDEO_PATH = "videos/demo.mp4"
OUTPUT_DIR = "output/frames"

# 抽帧参数
SCENE_CHANGE_THRESHOLD = 0.45
MIN_SCENE_LENGTH = 8           # 一个镜头至少 8 帧
MAX_FRAMES_PER_SCENE = 2       # 每个镜头最多抽取几帧
RESIZE_WIDTH = 448              # 缩放到统一宽度,加速直方图计算
4.2.2 场景检测与采样主逻辑 scene_sampler.py
# 文件路径:scene_sampler.py
import os
import cv2
import numpy as np
from config import VIDEO_PATH, OUTPUT_DIR, SCENE_CHANGE_THRESHOLD, MIN_SCENE_LENGTH, MAX_FRAMES_PER_SCENE, RESIZE_WIDTH


def frame_hist(frame):
    """
    计算一个 BGR 帧的颜色直方图并归一化。
    """
    resized = cv2.resize(frame, (RESIZE_WIDTH, int(frame.shape[0] * RESIZE_WIDTH / frame.shape[1])))
    hist = cv2.calcHist([resized], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
    cv2.normalize(hist, hist)
    return hist.flatten()


def detect_scene_boundaries(cap, total_frames):
    """
    返回镜头边界帧索引列表。例如 [0, 120, 300, ...] 表示每段镜头的起始帧。
    """
    boundaries = [0]
    ret, prev_frame = cap.read()
    if not ret:
        return boundaries

    prev_hist = frame_hist(prev_frame)

    for idx in range(1, total_frames):
        ret, frame = cap.read()
        if not ret:
            break
        current_hist = frame_hist(frame)
        diff = cv2.compareHist(prev_hist, current_hist, cv2.HISTCMP_BHATTACHARYYA)
        if diff >= SCENE_CHANGE_THRESHOLD:
            # 过短的镜头直接忽略,避免噪声
            if idx - boundaries[-1] >= MIN_SCENE_LENGTH:
                boundaries.append(idx)
        prev_hist = current_hist

    return boundaries


def sample_frames_from_scene(cap, start_frame, end_frame, max_frames):
    """
    在 [start_frame, end_frame) 区间内均匀抽取最多 max_frames 帧。
    返回 [(frame_index, timestamp_in_seconds)]
    """
    scene_len = end_frame - start_frame
    if scene_len <= 0:
        return []

    # 决定抽帧数:1 帧或 2 帧,如果场景很短则 1 帧
    num_frames = min(max_frames, max(1, scene_len // 20))

    positions = np.linspace(start_frame, end_frame - 1, num_frames, dtype=int)
    results = []

    cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
    for pos in positions:
        cap.set(cv2.CAP_PROP_POS_FRAMES, int(pos))
        ret, frame = cap.read()
        if not ret:
            continue
        timestamp = int(pos) / cap.get(cv2.CAP_PROP_FPS)
        results.append((int(pos), round(timestamp, 2), frame))

    return results


def main():
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    cap = cv2.VideoCapture(VIDEO_PATH)
    if not cap.isOpened():
        print("视频打开失败,请检查路径")
        return

    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    fps = cap.get(cv2.CAP_PROP_FPS)

    print(f"视频总帧数: {total_frames}, fps: {fps:.2f}")

    boundaries = detect_scene_boundaries(cap, total_frames)
    boundaries.append(total_frames)

    print(f"检测到 {len(boundaries) - 1} 个镜头边界 -> {boundaries}")

    all_frames = []
    for i in range(len(boundaries) - 1):
        start = boundaries[i]
        end = boundaries[i + 1]
        sampled = sample_frames_from_scene(cap, start, end, MAX_FRAMES_PER_SCENE)
        all_frames.extend(sampled)

    print(f"最终抽样帧数: {len(all_frames)}")

    for idx, (frame_idx, ts, frame) in enumerate(all_frames):
        filename = f"{OUTPUT_DIR}/frame_{idx:04d}_t{ts:.2f}s.jpg"
        cv2.imwrite(filename, frame)
        print(f"保存 {filename}")

    cap.release()
    print("抽样完成")


if __name__ == "__main__":
    main()

4.3 运行与验证

准备一个 demo 视频放到 videos/ 目录下,然后运行:

python scene_sampler.py

预期你会看到类似这样的输出:

视频总帧数: 2450, fps: 29.97
检测到 8 个镜头边界 -> [0, 210, 542, 860, 1205, 1590, 1990, 2200, 2450]
最终抽样帧数: 14
保存 output/frames/frame_0000_t0.00s.jpg
...

从结果中可以看到,一个 80 秒左右的视频,只用了 14 帧就完成了内容覆盖。相比均匀抽样可能抽出的 80 帧,压缩比提升了 5 倍以上。

4.4 结果说明与调参建议

镜头边界检测的效果高度依赖 SCENE_CHANGE_THRESHOLD 这个参数:

  • 如果视频剪辑风格偏快、画面切换频繁,阈值可以适当调大,比如 0.5。
  • 如果视频画面变化平缓,阈值可以调小,比如 0.3,避免漏掉渐变镜头。
  • 对于淡入淡出、叠化这类渐变镜头,直方图差异法是检测不出来的,需要更复杂的手段,比如双阈值法,本文先不展开。

实际应用时,建议先用几段有代表性的视频跑一遍,观察镜头边界和抽帧结果,再确定阈值。

5. 实战案例二:基于 CLIP 语义相似度的冗余过滤

镜头边界检测能解决“画面突变”的问题,但解决不了“镜头很长且画面高度相似”的问题。比如一个 10 分钟的讲座,镜头可能从头到尾都没有切换,但画面里的人物姿势、PPT 内容在缓慢变化。这种情况下,我们更关心的是语义上的“重复”。

这一节的思路是:先用均匀采样抽出一批候选帧,再用 CLIP 模型对每一帧做 embedding,最后基于向量相似度剔除冗余帧。

5.1 实现思路

  1. 均匀抽帧得到候选帧序列。
  2. 对每一帧用 CLIP 的图像编码器生成 512 维向量。
  3. 计算相邻候选帧之间的余弦相似度。
  4. 如果相似度高于阈值,表示前后两帧语义重复,保留前一帧,丢弃后一帧。
  5. 如果连续多帧都重复,则只保留该片段中的首尾帧或中间一帧。

这样能有效压缩高度冗余的视频,同时保留画面发生实质变化的帧。

5.2 完整代码

5.2.1 语义过滤主逻辑 semantic_filter.py
# 文件路径:semantic_filter.py
import os
import cv2
import numpy as np
import torch
from transformers import CLIPModel, CLIPImageProcessor
from config import VIDEO_PATH, OUTPUT_DIR, RESIZE_WIDTH

# 与 CLIP 输入尺寸保持一致
CLIP_MEAN = [0.48145466, 0.4578275, 0.40821073]
CLIP_STD = [0.26862954, 0.26130258, 0.27577711]


def uniform_sample(cap, total_frames, fps, interval_seconds=2):
    """
    均匀抽帧,返回 [(原始帧号, 时间戳, 帧 BGR 数组)]
    """
    interval_frames = max(1, int(fps * interval_seconds))
    results = []
    for frame_idx in range(0, total_frames, interval_frames):
        cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
        ret, frame = cap.read()
        if ret:
            ts = frame_idx / fps
            results.append((frame_idx, round(ts, 2), frame))
    return results


def frame_to_tensor(frame):
    """
    将 BGR 帧转换为 CLIP 需要的 rgb tensor,并 resize 到 224x224。
    """
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    rgb = cv2.resize(rgb, (224, 224))
    rgb = rgb.astype(np.float32) / 255.0
    rgb = (rgb - np.array(CLIP_MEAN)) / np.array(CLIP_STD)
    tensor = torch.from_numpy(rgb).permute(2, 0, 1).unsqueeze(0)
    return tensor


def cosine_similarity(vec1, vec2):
    v1 = vec1 / (np.linalg.norm(vec1) + 1e-9)
    v2 = vec2 / (np.linalg.norm(vec2) + 1e-9)
    return float(np.dot(v1, v2))


def main():
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    print("加载 CLIP 模型...")
    model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
    model.eval()

    cap = cv2.VideoCapture(VIDEO_PATH)
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    fps = cap.get(cv2.CAP_PROP_FPS)

    # 1. 均匀采样
    candidates = uniform_sample(cap, total_frames, fps, interval_seconds=2)
    print(f"均匀采样得到 {len(candidates)} 帧")

    # 2. 计算 embedding
    embeddings = []
    with torch.no_grad():
        for _, _, frame in candidates:
            tensor = frame_to_tensor(frame)
            emb = model.get_image_features(pixel_values=tensor)
            embeddings.append(emb.cpu().numpy().flatten())

    # 3. 相似度去重
    selected = [candidates[0]]
    selected_emb = [embeddings[0]]
    duplicate_count = 0

    for i in range(1, len(candidates)):
        sim = cosine_similarity(embeddings[i], selected_emb[-1])
        if sim > 0.95:
            duplicate_count += 1
            continue
        selected.append(candidates[i])
        selected_emb.append(embeddings[i])

    # 4. 保存结果
    print(f"去除 {duplicate_count} 个冗余帧,剩余 {len(selected)} 帧")
    for idx, (frame_idx, ts, frame) in enumerate(selected):
        filename = f"{OUTPUT_DIR}/semantic_frame_{idx:04d}_t{ts:.2f}s.jpg"
        cv2.imwrite(filename, frame)
        print(f"保存 {filename}")

    cap.release()
    print("语义过滤完成")


if __name__ == "__main__":
    main()
5.2.2 运行与结果分析
python semantic_filter.py

在采访类视频上,均匀采样 2 秒 1 帧可能抽出 60 帧,但语义去重后往往只剩 15~20 帧。压缩率非常可观,而且剩下的帧基本能覆盖画面发生实质变化的关键节点。

需要提醒的是: similarity > 0.95 这个阈值比较激进(意味着只保留差异很大的帧)。如果你希望保留更多上下文信息,可以放宽到 0.90 左右。CLIP 的相似度分布和视频类型关系很大,建议实际跑一遍后观察分数分布再定阈值。

另外,CLIP 模型权重从 Hugging Face 下载需要网络条件支持。如果下载失败,可以改用本地方便加载的图像 embedding 模型,核心逻辑不变。

6. 让选出的帧进入 LLM:Prompt 组装与上下文设计

帧选择只是第一步。选出来的帧最终要进入 LLM 进行推理,这里涉及到“视觉 token 如何被模型理解”和“如何设计 prompt 让模型用好这些帧”两个问题。

6.1 选出的帧如何变成模型输入

当前多模态大模型的处理方式基本一致:每张图像会被视觉编码器编码成若干个视觉 token。以 LLaVA 系列模型为例,输入图像会被切分成多个 patch,每个 patch 对应一个 token。因此, 帧的数量直接决定视觉 token 的数量 。

举个例子:假设你的模型把一张 224x224 的图片编码成 256 个视觉 token,那么 30 帧就是 7680 个视觉 token。加上文本 token,总输入量已经接近 8000 token。如果你的上下文窗口是 32K,那还能接受;但如果模型上下文只有 8K,就必须进一步压缩帧数。

所以工程上通常采用“两阶段”策略:

  1. 先用场景检测或语义去重,把候选帧压低到 20 帧以内。
  2. 再根据模型上下文窗口,进一步决定是每帧都保留,还是只保留关键帧。

6.2 一个可复用的 Prompt 模板

这里给出一个面向视频问答与摘要的 prompt 模板思路:

你是一个视频理解专家。下面给出了一段视频中按时间顺序抽取的关键帧。

时间戳和帧内容说明:
- t=0.00s: 视频开始,画面显示一个实验室环境
- t=5.20s: 一名研究员在操作显微镜
- t=12.80s: 屏幕上出现数据分析图表
- t=30.10s: 研究员面向镜头总结结果

请完成以下任务:
1. 用 2-3 句话概括这段视频的主要内容。
2. 按时间顺序列出主要事件。
3. 指出视频中最关键的转折点或结论。

要求:只依据上面给出的帧信息回答,不要猜测未出现的内容。

在实际调用模型时,需要把每一帧以多模态消息的形式加入对话。不同模型的具体调用方式不同,这里给出一个抽象思路,实际接口请以你使用的模型官方文档为准:

# 伪代码示例:按多模态对话格式组织输入
messages = [
    {
        "role": "user",
        "content": [
            {"type": "text", "text": prompt_text},
            {"type": "image", "image": "output/frames/frame_0000_t0.00s.jpg"},
            {"type": "image", "image": "output/frames/frame_0001_t5.20s.jpg"},
            # ...
        ]
    }
]

注意:如果模型不支持一次传入多张图片,可以改成“逐帧问答 + 汇总”的方式,先让模型分别描述每一帧,再统一汇总成视频总结。这种方式会牺牲一点效率,但兼容性更好。

6.3 Token 成本估算与优化

在做方案设计时,最好先做一个 token 预算表。虽然不同模型视觉 token 计算方式不同,但我们可以按照经验值粗估。

帧数 粗略视觉 token 数 是否适合长上下文模型 是否适合小上下文模型
5 帧 约 1280 适合 适合
10 帧 约 2560 适合 需谨慎
20 帧 约 5120 适合 超出
50 帧 约 12800 需谨慎 明显超出

这里的数值只是示意,不同模型的 patch size 和视觉塔结构差异很大。实际开发时,建议先用两三个视频跑一次,统计真实 token 消耗,再根据上下文窗口调整帧数上限。

7. 常见问题与排查清单

帧选择相关的代码,在实际跑起来以后会遇到不少问题。下面整理一份排查清单,覆盖我见过的高频场景。

问题现象 常见原因 解决思路
视频打开失败,cap.isOpened() 返回 False 路径错误或缺少解码器 检查路径;换用带 FFmpeg 的 OpenCV 版本
抽帧结果全是黑屏或绿屏 帧号定位不准,或视频编码特殊 用 cap.set 定位后立即 read,并做异常颜色检测
镜头检测结果太少 阈值过高,或视频是渐变转场 调低阈值;尝试直方图差 + 像素差双指标
语义去重结果太少 相似度阈值过低 调整阈值;分析 embedding 相似度分布
模型提示图片数量过多 帧数超出模型限制 减少帧数;分多次调用后再汇总
同一镜头内重要目标被漏掉 单独用场景边界采样,没有结合语义信息 场景采样 + 语义去重组合使用
处理长视频(1 小时以上)时内存爆炸 一次性读取所有帧 改为流式读取;限制候选帧数量;分批处理
CLIP 模型下载失败 网络受限 配置代理或换用本地模型文件

除了表格里的问题,还有一个非常容易忽略的坑: 时间戳对齐 。很多视频的 CAP_PROP_POS_FRAMES 定位并不完全精确,尤其是经过编辑软件二次压缩的视频。因此建议在保存帧时,总是通过 frame_idx / fps 重新计算时间戳,而不是直接信任元数据里的时间戳。

排查思路可以按这个顺序来:

  1. 先确认视频本身能打开、能读取帧。
  2. 单独运行某一策略,观察中间输出(比如直方图差异分数)。
  3. 再组合策略,观察最终帧集合是否覆盖了关键事件。
  4. 最后把帧送入模型,观察输出质量。

8. 工程落地建议与最佳实践

在本地 demo 跑通之后,如果要真正落地到项目里,下面这些工程建议会很有价值。

8.1 抽帧结果一定要做缓存

视频抽帧是计算密集型操作,尤其是 CLIP embedding 这类模型推理,速度很慢。建议把抽帧结果、embedding 向量、镜头边界列表全部缓存下来。

可以保存为 JSON 或 JSONL 文件,结构大致如下:

{
  "video": "demo.mp4",
  "fps": 29.97,
  "total_frames": 2450,
  "strategy": "scene+semantic",
  "frames": [
    {"index": 0, "timestamp": 0.0, "path": "output/frames/frame_0000.jpg"},
    {"index": 210, "timestamp": 7.0, "path": "output/frames/frame_0001.jpg"}
  ]
}

下次再处理同一个视频时,直接加载缓存结果,不用重新抽帧。

8.2 采样参数不要写死

帧选择策略的参数高度依赖视频内容。建议做成配置化,甚至可以通过一个简单的启发式方法自动选择:

  • 如果视频平均镜头切换频率高(比如 10 秒超过 5 次),用场景检测为主。
  • 如果视频平均镜头切换频率低,用语义去重为主。
  • 如果运动强度大,用运动感知采样为主。

参数化配置的好处是,后续接入不同业务场景时,不需要改动核心代码。

8.3 长视频处理要分片

对于 1 小时以上的视频,一次性加载全部帧做 embedding 既不现实也没必要。推荐分段处理:

视频 → 粗粒度分段(例如每 10 分钟一段)→ 每段分别做帧选择 → 全局汇总

分段处要注意前后重叠一部分,避免关键事件恰好落在边界上被切走。一般建议重叠 2 到 3 秒。

8.4 安全与合规提醒

在处理视频数据时,一定要确认视频内容的来源合法、授权明确。尤其是涉及人物肖像、版权内容、内部资料时,需要遵循相应的合规流程。在生成视频摘要或问答系统时,也要避免将模型用于制作虚假信息、误导性内容或侵犯他人隐私的场景。

8.5 如何评估帧选择的效果

一个非常实用的小技巧是:用“.npy”格式保存每帧的 embedding,然后定期做一个“召回率”测试。具体做法是:

  1. 人工标注视频中的 10 个关键事件。
  2. 检查帧选择结果是否覆盖了这些事件。
  3. 计算覆盖率。

覆盖率越高,说明帧选择策略越有效。这个指标比主观观察更可量化,也有利于后续调参。

9. 总结与下一步方向

回顾全文,核心的收获可以概括为三点:

  • LLM 看视频,本质上是通过视觉 token 理解离散帧,帧选择决定了信息的输入上界。
  • 常见的帧选择策略各有优劣,场景检测适合有剪辑节奏的内容,语义去重适合画面高度静态的内容,运动感知采样适合运动信息占主导的内容。
  • 落地工程时,要时刻关注 token 预算、时间戳对齐和策略参数化,并建立“抽帧结果缓存 + 覆盖率评估”的闭环。

如果接下来想继续深入,可以从下面几个方向入手:

  • 把光流信息接入采样策略,提升运动场景的帧召回能力。
  • 用 VLM 本身对候选帧做一次“内容质量打分”,再结合场景检测和语义去重,构成三级筛选。
  • 对抽帧结果做向量存储,接入 RAG,实现“视频素材语义检索”,用户输入自然语言就能定位到对应的视频片段。

帧选择这件事,听起来只是视频处理链路里的一个小环节,但它对最终模型表现的影响,往往比很多人想象中大得多。建议你拿一个真实业务视频跑一遍上面的代码,亲身体会一下“选帧前后,模型回答质量的天壤之别”。如果你的视频类型比较特殊,也欢迎在评论区交流你的采样策略。

Logo

邀请您加入社区

更多推荐