帧选择决定视频理解效果:多模态LLM关键帧抽取实战

发布时间:2026/8/27 21:56:20
帧选择决定视频理解效果:多模态LLM关键帧抽取实战 很多刚开始做视频理解的开发者很容易把注意力放在“用哪个多模态大模型”“Prompt 怎么写”上结果真正跑下来才发现效果好坏最关键的一步其实是喂给模型的那些帧是怎么选出来的。换句话说对于“让 LLM 看视频”这件事帧选择Frame Selection几乎决定了整个任务的准确率上限。本文会从问题出发讲清楚为什么不能直接把视频塞给 LLM再给出一个完整的可复用方案包含均匀采样、场景切换检测、CLIP 语义去重等策略的实现代码以及让多模态大模型基于关键帧做分析的工程化思路。1. 背景为什么大模型“看”视频要先解决帧选择1.1 多模态 LLM 的视频输入限制目前的 LLM 虽然统一了文本、图片、音频等多种模态但绝大多数模型仍然以“图像文本”作为视频理解的基本单元。原因很简单视频本质上是时间轴上的连续图像序列同时叠加了音频、字幕等额外信息。直接让模型处理原始视频流不仅会让输入 token 数量爆炸也会让模型难以稳定捕捉关键语义。为了控制输入规模常规做法是把视频拆成图像帧序列再交给多模态模型逐张或组合分析。也就是说“让 LLM 看视频”实际上被拆解为两个环节从视频中抽取若干帧把帧和对应的文本 Prompt 一起交给 LLM。第二个环节有大量现成工具和文档但第一个环节往往被当作“预处理”一笔带过。在实际项目中帧抽得不好后续模型能力再强也很难补救。这里就引出了本文的核心观点Frame selection is the whole game帧选择就是整个游戏。1.2 直接抽帧的痛点很多入门教程会给出类似cv2.VideoCapture每隔 N 帧保存一次的代码。这种思路确实简单但在真实视频上会遇到几个明显问题信息冗余连续帧之间画面高度相似导致送入模型的 token 浪费在重复信息上关键瞬间丢失均匀抽样可能正好漏掉动作变化、物体出现或场景切换的关键点上下文混乱如果一帧中同时出现多个目标模型很难理解它到底要回答什么问题成本过高多模态模型的费用通常与输入图像数量成正比抽得越多越贵延迟也越高。因此帧选择不是简单的“压缩采样”而是要在有限预算内尽量保住影响任务结果的关键信息。这是视频理解工程必须面对的核心优化点。1.3 本文的范围与目标本文不会泛泛介绍“视频理解有哪些任务”而是围绕一个具体目标展开如何为 LLM 生成一组高质量的关键帧。适用场景包括视频问答VideoQA视频摘要与标题生成视频内容审核基于视频的 RAG 检索多模态 Agent 的视频理解前置模块。读完本文你应该能理解各种帧选择策略的适用场景并搭建一个包含均匀采样、场景切换检测、语义去重三种策略的 Python 工具后续可以接入任意多模态 LLM 使用。2. 环境准备与版本说明2.1 运行环境本文示例代码在以下环境中验证操作系统Ubuntu 22.04 / macOS 13 / Windows 10均可运行Python3.9 及以上主要依赖OpenCV、NumPy、Transformers、Pillow如果你使用的是本地大模型或多模态 API请根据自己的实际接口调整调用部分。示例中的“OpenAI 兼容接口”只是演示切入口具体模型参数需要按你使用的模型替换。2.2 安装依赖建议先创建一个干净的虚拟环境再安装依赖。python -m venv .venv source .venv/bin/activate # Windows 下使用 .venv\Scripts\activate安装基础依赖pip install --upgrade pip pip install opencv-python numpy pillow如果要做 CLIP 语义去重需要安装 Transformers 和 Torchpip install torch torchvision transformers安装完成后可以快速验证 OpenCV 是否可用python -c import cv2; print(cv2.__version__)需要说明的是Transformers 和 PyTorch 的版本迭代较快。如果你已经安装过旧版本建议先升级到比较新的版本避免CLIPModel相关接口不兼容。2.3 项目目录结构为了让后续代码清晰我们使用如下目录结构video_frame_selector/ ├── frame_selector/ │ ├── __init__.py │ ├── base.py │ ├── uniform.py │ ├── scene.py │ ├── clip_dedup.py │ └── pipeline.py ├── scripts/ │ ├── extract_frames.py │ └── analyze_with_llm.py ├── samples/ │ └── demo.mp4 └── output/ └── frames/其中frame_selector是我们的核心代码库scripts放置可执行脚本output保存抽帧结果。你可以在项目根目录下创建这些文件夹mkdir -p video_frame_selector/frame_selector mkdir -p video_frame_selector/scripts mkdir -p video_frame_selector/samples mkdir -p video_frame_selector/output/frames3. 帧选择的核心原理3.1 均匀采样最朴素的基线均匀采样就是每隔固定时间间隔或帧数抽取一帧。它实现简单适合时长较短、内容变化均匀的视频比如访谈节目、固定机位直播等。实现思路读取视频总帧数和 FPS根据目标帧数计算采样间隔逐帧遍历并保存对应帧。均匀采样的优点是开销低、代码简单缺点是缺乏内容感知能力。如果一个视频前半段是静态画面后半段是快速动作均匀采样会把大量预算浪费在静止画面上而快速动作阶段又可能只采到很少的帧。因此均匀采样更适合作为基线baseline和后续策略对比的参照对象。3.2 场景切换检测尊重视频内容结构视频通常由多个镜头组成镜头之间往往存在明显的亮度、颜色或内容突变。如果我们能找到这些边界就可以在每个镜头内选取最有代表性的帧。OpenCV 提供了createBackgroundSubtractorMOG2、calcHist等方法也可以直接计算帧间差异。常见做法是计算连续帧的直方图差异或绝对像素差差异超过阈值就认为发生了场景切换。场景切换检测的优点是可以避免跨镜头混叠让抽取的帧覆盖更多内容缺点是对镜头内部的渐变、运动比较敏感需要调阈值。3.3 语义去重用 CLIP 判断画面是否重复CLIPContrastive Language-Image Pre-training是一种把图像和文本映射到同一向量空间的模型。我们可以用 CLIP 为每一帧生成语义向量然后计算相邻帧之间的余弦相似度删除语义过于接近的帧。这种方法的优势非常明显能识别“像素不同但语义相同”的重复画面不依赖具体的视频内容类型很容易和文本检索结合。同时也要注意CLIP 模型本身有一定计算开销。如果视频很长不建议对每一帧都跑 CLIP而是先在场景切换检测之后再对候选帧做语义去重。3.4 综合策略多信号混合选择实际工程中最佳做法不是只用一种策略而是组合使用。常用流程是用均匀采样或场景切换检测得到候选帧对候选帧做 CLIP 语义去重如果视频有字幕或音频转写文本可以进一步根据文本内容挑选与问题最相关的帧最后把剩余帧送入多模态 LLM。这种“先粗筛、后细选”的思路既能控制算力开销又能保留内容多样性。下文实战部分会实现一个最小可用的综合 Pipeline。4. 实战编写一个可复用的视频帧选择器4.1 定义帧选择器接口为了避免代码堆在一起我们为帧选择器定义统一接口。所有策略都实现select方法输入视频路径和目标帧数输出关键帧列表。# 文件路径video_frame_selector/frame_selector/base.py from abc import ABC, abstractmethod from dataclasses import dataclass from typing import List dataclass class Frame: 关键帧对象保存时间点和帧内容。 index: int timestamp: float image: numpy.ndarray class BaseFrameSelector(ABC): 所有帧选择器的基类。 def __init__(self, target_frames: int 8): self.target_frames target_frames abstractmethod def select(self, video_path: str) - List[Frame]: 从视频中选择关键帧。 pass这里定义了一个Frame数据类包含帧序号、时间戳和图像数据。每个选择器只需要实现select方法即可。4.2 实现均匀采样均匀采样器是最简单的实现。它读取视频的 FPS 和总帧数然后按时间间隔均匀取帧。# 文件路径video_frame_selector/frame_selector/uniform.py import cv2 from .base import BaseFrameSelector, Frame class UniformFrameSelector(BaseFrameSelector): 按固定时间间隔均匀采样作为 baseline。 def select(self, video_path: str) - list[Frame]: cap cv2.VideoCapture(video_path) total_frames int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) fps cap.get(cv2.CAP_PROP_FPS) indices self._generate_indices(total_frames) frames [] current 0 for idx in indices: cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ret, frame cap.read() if ret: timestamp idx / fps frames.append(Frame(indexidx, timestamptimestamp, imageframe)) current 1 cap.release() return frames def _generate_indices(self, total_frames: int) - list[int]: if total_frames self.target_frames: return list(range(total_frames)) step total_frames // self.target_frames return [min(step * i, total_frames - 1) for i in range(self.target_frames)]这里有一点值得注意cap.set(cv2.CAP_PROP_POS_FRAMES, idx)在某些视频格式上可能定位不够精确。如果遇到抽出的帧与预期不符可以改用逐帧读取并判断帧号的方式。4.3 实现场景切换检测下面使用帧间直方图差异来检测场景切换。具体做法是把每一帧转为 HSV 颜色空间计算颜色直方图计算相邻帧的直方图相关距离当距离超过设定阈值时认为是新场景起点。# 文件路径video_frame_selector/frame_selector/scene.py import cv2 import numpy as np from .base import BaseFrameSelector, Frame class SceneChangeSelector(BaseFrameSelector): 通过直方图差异检测场景切换在每个场景中取一帧。 def __init__(self, target_frames: int 8, threshold: float 0.6): super().__init__(target_frames) self.threshold threshold def select(self, video_path: str) - list[Frame]: cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) selected_frames [] prev_hist None frame_index 0 while True: ret, frame cap.read() if not ret: break hist self._compute_hist(frame) if prev_hist is None: selected_frames.append(Frame(frame_index, frame_index / fps, frame)) prev_hist hist else: score cv2.compareHist(prev_hist, hist, cv2.HISTCMP_BHATTACHARYYA) if score self.threshold: selected_frames.append(Frame(frame_index, frame_index / fps, frame)) prev_hist hist frame_index 1 cap.release() # 如果检测到的场景帧少于目标数用均匀采样的帧补全 if len(selected_frames) self.target_frames: indices list(range(0, frame_index, max(1, frame_index // self.target_frames))) for idx in indices[: self.target_frames]: cap cv2.VideoCapture(video_path) cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ret, frame cap.read() if ret: selected_frames.append(Frame(idx, idx / fps, frame)) cap.release() return selected_frames[: self.target_frames] staticmethod def _compute_hist(frame: np.ndarray) - np.ndarray: hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) hist cv2.calcHist([hsv], [0, 1], None, [50, 60], [0, 180, 0, 256]) cv2.normalize(hist, hist) return hist阈值参数threshold需要根据视频内容调整。阈值越小越容易将普通动作变化判定为场景切换阈值越大越容易漏掉真正的镜头切换。4.4 实现 CLIP 语义去重CLIP 语义去重的核心是先用预训练模型把候选帧编码成向量再通过两两比较或增量比较保留语义差异较大的帧。# 文件路径video_frame_selector/frame_selector/clip_dedup.py import numpy as np from typing import List from .base import Frame class CLIPDedupFilter: 基于 CLIP 向量的语义去重过滤器。 def __init__(self, similarity_threshold: float 0.95): self.similarity_threshold similarity_threshold self._model None self._processor None def _load_model(self): if self._model is None: from transformers import CLIPModel, CLIPProcessor model_name openai/clip-vit-base-patch32 self._model CLIPModel.from_pretrained(model_name) self._processor CLIPProcessor.from_pretrained(model_name) return self._model, self._processor def filter(self, frames: List[Frame]) - List[Frame]: model, processor self._load_model() images [frame.image for frame in frames] # 使用 CPU / GPU 推理 import torch device cuda if torch.cuda.is_available() else cpu model.to(device) inputs processor(imagesimages, return_tensorspt).to(device) with torch.no_grad(): image_features model.get_image_features(**inputs) image_features image_features / image_features.norm(dim-1, keepdimTrue) kept [] kept_feature None for frame, feature in zip(frames, image_features.cpu().numpy()): if kept_feature is None: kept.append(frame) kept_feature feature else: similarity np.dot(kept_feature, feature.T) if similarity self.similarity_threshold: kept.append(frame) kept_feature feature return kept需要注意这里的相似度阈值默认是 0.95表示如果两帧 CLIP 向量余弦相似度超过 0.95就认为语义重复。实际项目中需要根据自己的视频内容做调整。为了节省内存建议传入 CLIP 去重器之前先把候选帧缩放到较小尺寸例如 224x224。4.5 组装 Pipeline把三种策略组合成一个 Pipeline。基本流程是先用均匀采样得到一批候选帧再用场景切换检测补充可能存在内容变化的帧最后通过 CLIP 去重保留语义差异大的帧。# 文件路径video_frame_selector/frame_selector/pipeline.py from typing import List from .base import Frame from .uniform import UniformFrameSelector from .scene import SceneChangeSelector from .clip_dedup import CLIPDedupFilter class FrameSelectionPipeline: 综合帧选择 Pipeline。 def __init__(self, target_frames: int 8, scene_threshold: float 0.6, similarity_threshold: float 0.95): self.target_frames target_frames self.uniform_selector UniformFrameSelector(target_frames) self.scene_selector SceneChangeSelector(target_frames, scene_threshold) self.dedup_filter CLIPDedupFilter(similarity_threshold) def run(self, video_path: str, use_clip: bool True) - List[Frame]: frames [] frames.extend(self.uniform_selector.select(video_path)) frames.extend(self.scene_selector.select(video_path)) # 按时间戳排序并去掉相邻重复帧 frames sorted(frames, keylambda f: f.index) deduped [] seen_ts set() for frame in frames: ts round(frame.timestamp, 2) if ts not in seen_ts: deduped.append(frame) seen_ts.add(ts) if use_clip and len(deduped) self.target_frames: deduped self.dedup_filter.filter(deduped) return deduped[: self.target_frames]为了演示这里没有刻意控制每个策略的帧数而是采用“取并集 去重 截断”的思路。生产环境中你还可以为每个策略设置不同的权重或者根据视频时长动态调整策略组合。5. 让多模态 LLM 分析关键帧5.1 调用示例帧选择完成之后下一步是把关键帧交给多模态 LLM。我们以 OpenAI 兼容接口为例演示如何构造请求。# 文件路径video_frame_selector/scripts/analyze_with_llm.py import base64 import os import cv2 from openai import OpenAI def encode_frame_to_base64(frame) - str: 把 OpenCV 的 BGR 帧编码为 base64 字符串。 ret, buffer cv2.imencode(.jpg, frame) if not ret: raise ValueError(frame encoding failed) return base64.b64encode(buffer).decode(utf-8) def build_messages(frame_list, question: str): 构建多模态消息。 这里假设 API 支持图片 url 与 base64 data url。 content [{type: text, text: question}] for frame in frame_list: b64 encode_frame_to_base64(frame.image) content.append({ type: image_url, image_url: { url: fdata:image/jpeg;base64,{b64} } }) return [ { role: user, content: content } ] def analyze_frames(frame_list, question: str, api_key: str None): 调用 OpenAI 兼容接口进行分析。 client OpenAI( api_keyapi_key or os.getenv(OPENAI_API_KEY), ) response client.chat.completions.create( modelgpt-4o, # 替换成你实际使用的多模态模型 messagesbuild_messages(frame_list, question), max_tokens512, temperature0.3, ) return response.choices[0].message.content这段代码只是一个演示。如果你使用的是国内大模型或其他平台请把client.chat.completions.create换成对应 SDK 的接口。5.2 控制 Prompt 输出多模态模型对多帧输入的理解能力很大程度上取决于 Prompt 描述。建议在 Prompt 中明确以下几点一共提供了多少帧帧与帧之间按时间顺序排列需要回答的具体问题输出格式要求。示例 Prompt这是一段视频中的若干关键帧按时间顺序排列。 请根据这些帧回答下面的问题 问题视频中的主角做了什么动作 要求 1. 先描述你看到的画面 2. 再给出结论 3. 如果无法判断请回答“信息不足”。在build_messages中把上述 Prompt 放入text字段即可。5.3 量化对比不同采样策略为了直观感受帧选择的重要性我们可以写一个简单的对比脚本分别用均匀采样、场景切换、综合 Pipeline 抽取关键帧再调用 LLM 做问答最后人工或自动评估答案准确率。下面是简化版的评估伪代码思路# 文件路径video_frame_selector/scripts/evaluate_compare.py from frame_selector.uniform import UniformFrameSelector from frame_selector.scene import SceneChangeSelector from frame_selector.pipeline import FrameSelectionPipeline from analyze_with_llm import analyze_frames VIDEO_PATH samples/demo.mp4 QUESTIONS [ 视频里出现了几个主要人物, 主角最后去了哪里, ] def evaluate(selector_name: str, frame_list, questions): print(f {selector_name} ) print(f关键帧数量: {len(frame_list)}) for q in questions: answer analyze_frames(frame_list, q) print(fQ: {q}) print(fA: {answer}) if __name__ __main__: uniform_selector UniformFrameSelector(target_frames8) scene_selector SceneChangeSelector(target_frames8, threshold0.6) pipeline FrameSelectionPipeline(target_frames8) uniform_frames uniform_selector.select(VIDEO_PATH) scene_frames scene_selector.select(VIDEO_PATH) pipeline_frames pipeline.run(VIDEO_PATH, use_clipFalse) evaluate(均匀采样, uniform_frames, QUESTIONS) evaluate(场景切换, scene_frames, QUESTIONS) evaluate(综合Pipeline, pipeline_frames, QUESTIONS)注意这个脚本运行成本较高会多次调用 LLM。实际对比时建议先用固定的几个测试视频并把结果记录到文本文件再慢慢分析。6. 常见问题与排查思路在实际项目里帧选择环节经常遇到的问题可以总结为以下几点。问题现象常见原因解决思路抽帧结果出现黑屏/花屏视频解码不稳定cap.set定位不准改用逐帧遍历检查视频编码格式先转码成 mp4 再处理关键帧数量太少场景切换阈值太高降低阈值或结合均匀采样作为兜底关键帧数量太多阈值太低或动作变化过于频繁提高阈值或在 CLIP 去重中调低相似度上限CLIP 模型加载慢本地没有缓存预训练权重提前下载模型使用更小的 CLIP 变体如openai/clip-vit-base-patch32内存溢出保存了过多高清帧在抽帧时缩小尺寸使用生成器而不是一次性保存所有帧API 请求超时帧数量太多导致请求体过大降低帧数把图片压缩到更小的分辨率使用异步批量请求结果出现幻觉关键帧没有包含事实依据增加关键帧数量在 Prompt 中要求模型“只依据给定帧回答”7. 最佳实践与工程建议7.1 用时间戳保留顺序信息在把帧交给 LLM 之前一定要保留帧的时间戳。很多模型对多帧输入的顺序并不敏感因此你需要把时间戳放入 Prompt或者把图片按时间顺序拼接成网格图同时标注每帧所在的时间范围。7.2 结合音频与字幕视频理解不应该只依赖视觉信息。对于访谈、新闻类视频字幕和音频转写文本往往包含大量关键信息。你可以把字幕文本作为一种弱信号辅助帧选择。例如根据文本中的关键词定位相关时间段优先抽取这些时间段附近的帧将对应文本也交给 LLM。这类“文本视觉”混合策略在长视频处理中效果提升非常明显。7.3 控制图像分辨率与压缩率多模态模型的输入图片通常会被缩放你并不需要把原始高清帧全部上传。建议在抽帧后统一缩放到 512x512 或 768x768不仅节省 token还能提升推理速度。不过要注意如果视频中有需要识别的小字、小目标分辨率过低会导致信息丢失。7.4 建立可评测的黄金数据集帧选择策略好不好不能靠感觉判断。建议先从你的业务场景中挑选 10 到 20 个代表性视频人工标注“正确答案”然后建立一个自动评估脚本。每次修改采样逻辑都在固定数据集上重新评测避免“修了一个问题又引入另一个问题”。7.5 缓存抽取结果视频抽帧是一个耗时操作。同一段视频如果被多个任务复用最好把抽取的关键帧保存到磁盘并记录帧对应的视频路径、时间戳、策略参数。缓存结构可以参考cache/ video_demo_01/ uniform_8/ 00000.jpg 00033.jpg pipeline_8/ 00000.jpg 00088.jpg这样后续做实验时可以更快地对比不同策略而不用反复解码视频。7.6 设置异常兜底逻辑视频文件可能损坏也可能帧全部为黑屏。在 Pipeline 中需要设置兜底逻辑如果抽取结果为空就退化为均匀采样取前几帧如果连视频都打不开则记录错误日志并跳过本次任务。# 示例兜底逻辑 try: frames pipeline.run(video_path, use_clipTrue) except Exception as exc: print(fPipeline failed: {exc}, fallback to uniform) frames uniform_selector.select(video_path)8. 总结与下一步学习帧选择决定了 LLM 看到的视频内容也就决定了它最终能答出什么。仅仅把视频切割成若干帧是不够的我们需要思考每帧是否包含信息增量是否覆盖了关键动作是否符合任务目标。均匀采样是一个简单可靠的基线但真实业务项目中建议把均匀采样、场景切换检测、CLIP 语义去重三者结合起来再配合字幕、音频等文本信号做二次筛选。如果继续深入可以关注以下几个方向基于视频特征提取模型的实时帧选择例如使用视频编码器计算帧级特征与 RAG 结合把关键帧的视觉 embedding 存成向量索引实现跨视频检索基于强化学习的动态采样策略让模型在线决定下一步“看”哪一帧把帧选择器抽象成独立服务供多个 LLM 应用复用。如果你的项目也遇到了“模型明明很强视频理解效果却很差”的问题不妨先把注意力拉回到帧选择这一层。换一种采样策略往往比换更大的模型更有效。