说实话,第一次跑完这套管线的时候,我自己都有点不太敢信:一段2小时零几分的教学录像,原始视频解出来整整18万帧,最后喂给多模态LLM的只有41张关键帧,整套流程跑完只用了大概20分钟,换算下来就是5.9倍实时。也就是说,视频还没放完一遍,机器已经把里面的知识点、章节结构、重要板书全部整理成结构化文档了。

其实这个项目最开始纯粹是被逼出来的。团队要做一批录播课程的自动理解与检索,视频总量加起来几千小时,如果每一帧都要过LLM,成本和时间都扛不住。后来我换了思路:人看2小时课程,真正需要拍照记下来的"关键画面"也就几十张,那LLM为什么不能这样干?顺着这个想法,我开源了这套LLM视频理解管线,今天把整个设计思路、选型过程、实操细节和踩过的坑一次性说清楚。

如果你是做视频内容分析、课程知识抽取、或者想把长视频变成结构化知识库的,这篇文章应该能帮你省掉不少调研时间。代码虽然是我针对教学视频调的,但换到会议记录、直播回放、监控录像分析这些场景,核心逻辑一样能跑。

1. 面对18万帧,先别急着上LLM:视频理解的底层矛盾

1.1 直接丢帧给LLM为什么是灾难

18万帧是什么概念?常规30fps的录屏视频,2小时正好18万帧。很多人第一反应是"把帧抽出来,每隔几帧拼一张大图丢给LLM分析"。这个方案听起来简单,实际操作几步就会卡死。

先说token成本。一张1080p的帧,传给视觉语言模型,即便压缩到448x448分辨率,折算下来也要大几百到一千多个token。2小时视频哪怕每秒只取1帧,也有7200帧,光图像token就是几百万。这种量级,任何按token付费的API都能让预算报表瞬间难看。更麻烦的是上下文窗口,绝大多数多模态模型的单次输入上限就在几千到几万token之间,18万帧哪怕全部降采样,也塞不进一次推理。硬塞的后果是模型"注意不到"关键细节,输出质量断崖式下跌。

再想一层,视频本身就是极度冗余的。教学视频里讲师站在PPT前讲3分钟,画面变化可能只有嘴唇在动;代码演示里屏幕停留在一段代码上,唯一的动静可能是光标闪烁。这些连续帧之间的信息增量,可能连1%都不到。把大量近乎重复的帧喂给LLM,不仅浪费算力,还会让模型把注意力平均分摊到每一帧上——这和人类看视频抓不住重点是一个道理。

所以我做这套管线的第一个原则是: 在让LLM"看"视频之前,先把视频压缩成LLM真正需要"看"的东西。

1.2 人看视频的方式,就是最好的算法原型

我经常跟人讲,别把视频理解想得多玄乎,先想想人是怎么看教学视频的。正常学生看2小时课程,不会一帧不落地盯着屏幕,他是这样看的:

  • 画面切换了(比如PPT翻页、屏幕切换),注意力会重新集中
  • 屏幕上出现新文字、新公式,会多看一眼,甚至截图
  • 讲师反复强调某个点,会记住那个片段
  • 一段内容讲完了,脑子里会有一个"这段大概讲了什么"的总结

这恰恰就是一套完美的高效视频理解算法。画面切换是场景边界,新文字是信息增量,重复强调是重点,总结归纳是LLM最擅长的事。我的管线把这个认知模型拆成了四个模块:先粗抽帧,再筛关键帧,然后用多模态LLM逐张理解,最后把理解结果聚合起来做结构化输出。

这套逻辑还有一个隐藏的好处:它跟具体模型解耦。今天你用GPT-4V也好,用开源的Qwen2.5-VL也好,只要接口支持"图片进、文字出",管线就能跑。模型迭代不需要重构代码,换一个base_url就行。

1.3 项目定位与适用场景

这个管线开源出来之后,很多人问它跟视频理解模型(比如VideoLLaMA)有什么区别。我的回答是: 它不是一个模型,而是一套工程方案。 VideoLLaMA这类模型试图让模型直接理解视频帧序列,效果确实好,但训练和推理成本高,对长视频的支持也有限。我的管线则是用工程手段,把任何长视频先降维成"一组高信息密度的图片",再用成熟的多模态模型理解。

所以它特别适合这么几类场景:

  • 录播课程、网课的知识点抽取和章节索引生成
  • 会议录像、培训视频的关键内容摘要
  • 直播回放里的产品演示环节定位
  • 任何"视频很长、但有效信息密度很低"的内容理解任务

如果视频本身就是快节奏的(比如电影预告片、体育比赛集锦),这套管线的压缩效果会打折,因为每一帧信息量都很大。但在教学、会议、监控这类场景下,"先压缩再理解"的思路是效率最高的。

2. 管线整体解析:四层结构如何把大问题变小

2.1 四层管线配置快览

整套管线从输入视频到输出结构化结果,一共四层:

层级 模块 输入 输出 主要工具
L1 抽帧层 mp4/mov原始视频 原始帧序列 ffmpeg
L2 筛选层 原始帧序列 关键帧小集合 OpenCV + dHash/Pearson差异
L3 理解层 关键帧集合 逐帧文字描述 多模态LLM API
L4 聚合层 逐帧描述 结构化摘要/知识点大纲 LLM(纯文本)+ JSON Schema

L1和L2是纯本地计算,不花一分钱API费用,速度快得可以忽略不计。L3和L4才真正调用LLM,但由于L2已经把帧数量压到了几十张的量级,这两个环节的成本也完全可控。

这个四层设计不是我凭空想出来的,而是把"该省的地方省到底、该花的地方不吝啬"这个原则贯彻到了每一层。L1、L2是纯算法问题,用CPU跑就行,没必要花token让LLM去判断"这两帧像不像"。L3、L4才是LLM发挥价值的地方——理解内容、归纳逻辑、生成结构化知识。 把重复劳动留给免费的计算,把创造性理解交给昂贵的模型,这是整个管线性价比最高的设计决策。

2.2 为什么选择先压帧、再理解,而不是边抽帧边理解

设计过程中我其实纠结过一个方案:不先抽帧,而是每隔一段时间截一张图,边截边让LLM分析,分析完清空上下文继续下一段。这种"流式理解"看起来更实时,但实际用下来有两个致命问题。

第一个问题是上下文割裂。视频的前半段和后半段往往有逻辑关联,比如讲师在第30分钟提到"正如我们之前讲的",如果前面的分析已经被清空了,LLM根本不知道"之前讲的"是什么。第二个问题是重复分析。同一个PPT可能连续讲解了10分钟,流式方案每隔30秒截一图,这个PPT至少被分析20次,但信息量几乎是一次。输出还特别容易自相矛盾——模型第一次说"这是第三章",第二次又说"这是第五章"。

所以最终选择了两阶段方案:先全局筛选关键帧,再做整体理解。关键帧筛选结果天然自带时间戳和主题边界,而两级LLM结构(先逐帧描述,再把描述聚合)能保证全局上下文不丢失。这类工程选型,往往不是看哪个方案更"聪明",而是看哪个更不容易出错。

2.3 从18万到41:压缩比背后的信息论逻辑

18万帧压缩到41张图,压缩比接近4400:1。很多人第一反应是"压这么多,信息肯定丢光了"。其实从信息论的角度看,视频帧之间的时间冗余极高,真正的独立信息量远没有18万帧那么多。

我做过一个实验:随意抽取教学视频相邻两帧做pHash对比,汉明距离小于8的比例超过95%。也就是说,绝大多数相邻帧的内容差异比两张随机噪点图还小。这类帧在信息论里属于"完全冗余",删掉它们对理解视频内容毫无影响。

41张关键帧这个数字也不是我拍脑袋定的,而是L2筛选层在默认阈值下的自然输出。如果调高灵敏度阈值,可以输出100帧以上;调低阈值,可能只剩20帧左右。41是"信息覆盖与成本"的平衡点:既能覆盖所有PPT翻页、章节切换、板书重点,又不会让LLM在极度相似的图上浪费token。这个数字会随着视频类型浮动,但方法论是通用的。

3. 核心实操:关键帧筛选的三种策略与最终选型

3.1 基线方案:均匀间隔抽帧,为什么不够

刚开始做这个项目,我用的最朴素方案:用ffmpeg每10秒截一帧,2小时视频截出来720帧,然后直接全量丢给LLM。结果有两个问题很突出。

第一个问题,720帧对LLM来说还是太多。按每帧800 token粗算,全量理解要消耗将近60万token,一次API请求根本放不下,还得分批处理、再做合并,工程复杂度一下就上来了。第二个问题更本质:均匀抽帧会错过"关键瞬间"。讲师在PPT上临时圈了一个重点、写了一个公式,可能只停留了3秒就被翻页了。如果抽帧点刚好错过这3秒,这个信息就永久丢失了。

均匀抽帧的本质问题在于: 它完全不看视频内容,只是机械地每隔固定时间截一次图。 这种方案的召回率完全依赖运气。后来我意识到,帧筛选必须依赖帧的内容本身——这直接导致了后面两个方案的诞生。

3.2 进阶方案:感知哈希差分,能去掉大部分冗余

第二个方案是给每一帧算感知哈希值,比较相邻帧的差异程度。感知哈希(pHash/dHash)会把图片压缩成一个固定长度的二进制串,两张图越相似,它们的哈希串汉明距离越小。我当时的实现是这样:

import cv2
import numpy as np

def dhash(image, hash_size=8):
    resized = cv2.resize(image, (hash_size + 1, hash_size))
    diff = resized[:, 1:] > resized[:, :-1]
    return sum([2 ** i for i, v in enumerate(diff.flatten()) if v])

def hamming_distance(h1, h2):
    return bin(h1 ^ h2).count('1')

流程很简单:先按每秒1帧把视频抽成7200帧缩略图,逐帧计算dHash,然后依次比较相邻帧。汉明距离小于10就说明画面几乎没变,跳过;大于某个阈值就说明画面发生显著切换,保留为关键帧候选。这个方案跑下来,7200帧直接被压到300多帧,冗余问题解决了。

但这个方案有个明显的盲区:画面没变,不代表内容没变。讲师站在同一个PPT前讲了10分钟,前5分钟在讲概念A,后5分钟在讲概念B,dHash看不出任何区别,因为画面几乎一样。这种"同画面、不同内容"的情况,光靠视觉相似度是区分不了的。单纯做帧去重是不够的,必须引入"语义级"的信号。

3.3 实战方案:三重触发器协同,既有画面边界也有内容边界

最终线上跑的版本,用的是三重触发器。任何一个触发条件满足,就记录一帧:

第一重是 场景切换触发器 。利用dHash/颜色直方图突变检测,捕捉PPT翻页、窗口切换、画面跳变。这一重负责找到画面的物理边界。

第二重是 OCR文字触发器 。对候选帧做OCR,提取屏幕上的文字。当前帧的文字和上一帧保留的文字有明显差异时(比如出现了一个新标题、新公式),强制再记录一帧。这一重解决的就是"PPT没翻页但内容变了"的盲区。

第三重是 强制周期触发器 。无论前面两重触发与否,每隔90秒强制保留一帧。这保证了即使某个极端场景下画面和文字长时间不变,模型对这段"冗长但稳定"的内容也不会完全失明。

def is_key_frame(prev_frame, cur_frame, prev_text, cur_text, last_forced_time):
    hash_diff = hamming_distance(dhash(prev_frame), dhash(cur_frame))
    text_changed = len(set(prev_text) ^ set(cur_text)) > threshold
    time_elapsed = cur_timestamp - last_forced_time

    if hash_diff > HASH_THRESHOLD:
        return True, "scene_change"
    if text_changed:
        return True, "text_change"
    if time_elapsed > FORCE_INTERVAL:
        return True, "forced"
    return False, None

这三重触发器组合起来,效果非常显著。以那节2小时课程为例:场景切换触发了33帧,OCR文字变化额外补充了5帧,强制周期触发兜底了3帧,最终合并去重后得到41张关键帧。对比一下,如果只靠场景切换,PPT连续讲解的段落会大量漏掉;只靠OCR,画面切换时出现过但很快消失的闪现内容又捕捉不到。三个触发器各管一段,合在一起才覆盖完整。

OCR这里我可以多说一句:文字差异的判定,不是简单的字符串不等,而是先做文本去噪和排序,再去比较"有意义的新文字"。否则讲师鼠标划过屏幕、光标闪烁这种噪点文字变化,会触发大量假阳性帧。

4. 从帧到知识:LLM理解环节的工程落地

4.1 多模态LLM选型与接口封装

关键帧选出来之后,理解阶段就相对轻松了。多模态LLM这部分,我做了通用接口封装,方便切换不同模型:

from openai import OpenAI

class VisionClient:
    def __init__(self, base_url, api_key, model_name):
        self.client = OpenAI(base_url=base_url, api_key=api_key)
        self.model = model_name

    def describe_frame(self, image_path, timestamp, context):
        base64_image = encode_image(image_path)
        prompt = f"""
        这是视频 {timestamp} 时刻的画面。
        上一张关键帧的上下文:{context}
        请用简洁的中文描述这张画面中的核心信息。
        如果是PPT,请完整识别标题和正文要点;如果是代码,
        请指出关键代码逻辑;如果是讲师画面,请描述其讲解主题。
        """
        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": prompt},
                    {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
                ]
            }]
        )
        return response.choices[0].message.content

选模型的时候我给自己定了个门槛:单图理解能力要稳,中文OCR识别要准,上下文窗口不能太小。实测下来,云端模型的效果确实好,但要注意的是 不要让输出的描述太冗长 。我早期的提示词写得太开放,模型经常一张图输出四五百字,其中一半是废话。后来把提示词收紧,要求"逐条输出卡片式要点",40多张图的处理速度立刻快了一大截,输出质量反而更高。

4.2 分帧描述、合并再总结的两级结构

有了每张关键帧的独立描述之后,下一步是聚合。这一步我采用了两级LLM结构:第一级对单帧生成"局部描述",第二级把所有局部描述放在一起,生成全局的结构化总结。

值得说明的是,这里的"上下文"拼接方式对最终效果影响很大。我最初只是把所有描述简单拼接成一个长文本丢给LLM,结果模型经常在总结时遗漏中间段落的内容。后来改成把描述按时间戳排列,同时保留每帧的原始OCR文字和场景切换标记,再让LLM做总结,效果就稳定多了。

第二级总结的输出格式我是严格用JSON Schema约束的:

{
  "title": "课程标题",
  "chapters": [
    {
      "start_time": "00:12:30",
      "end_time": "00:18:45",
      "topic": "章节主题",
      "key_points": ["要点1", "要点2"],
      "frames_ref": [3, 4, 5]
    }
  ],
  "summary": "整体摘要"
}

用JSON输出的最大好处是后续可以直接落库、建索引、对接检索系统。LLM输出JSON偶尔会不稳定,我在管线上加了重试和正则纠偏。关于这点,后面专门在常见问题里说。

4.3 关键参数与提示词设计

这里分享几个我调出来的核心参数:

参数 推荐值 说明
抽帧间隔 1秒1帧 信息密度适中,兼顾后续筛选
dHash汉明距离阈值 10 小于10视为画面未变
OCR文字差异阈值 新增有效词≥3 过滤光标移动等噪点
强制周期 90秒 避免长段落全盲区
LLM温度 0.2 降低随机性,输出更稳定
单帧描述字数 80~120字 信息密度最优,token友好

提示词是这套管线里最需要"打磨"的部分。我不太建议直接抄别人的提示词,因为模型版本、视频类型都会影响效果。我的做法是先从一次性跑通开始,再逐步收敛。第一版提示词可以写得宽泛一些,看看模型在真实帧上会输出什么,然后再针对性地收敛。这种"先放开、再收紧"的调法,比一开始就按理想效果设计提示词要快得多。

5. 完整实操记录:2小时课程如何5.9×跑完

5.1 环境准备与工作目录

整个项目完全基于Python,依赖也很少。我在开源仓库里提供了requirements.txt,核心是opencv-python、Pillow、openai、paddleocr(OCR部分可选)、numpy。ffmpeg需要独立安装,建议用4.4以上版本,因为硬件加速(cuda或videotoolbox)在旧版本上兼容性问题比较多。

工作目录结构很简单:

video_pipeline/
├── input/               # 原始视频
├── frames/              # L1抽帧输出
├── keyframes/           # L2筛选后的关键帧
├── output/              # L3/L4输出
├── pipeline.py          # 主流程
├── frame_selector.py    # 关键帧筛选
├── vision_client.py     # 多模态LLM封装
└── config.yaml          # 全局配置

这里我特别建议把中间产物都保留在磁盘上,不要用内存传递。一是有利于断点续跑,比如LLM调用挂掉了,重新跑只花费几十秒;二是方便人工审查关键帧筛选质量——我经常直接打开keyframes目录肉眼扫一遍,看看这轮筛选有没有漏掉重点。

5.2 步骤一:ffmpeg硬件加速抽帧

第一层抽帧用ffmpeg,命令非常简单。关键是用硬件解码来加速,我以macOS为例用了videotoolbox,Linux上就是cuda:

# macOS 硬件加速,每秒1帧抽帧
ffmpeg -hwaccel videotoolbox -i input/course.mp4 \
  -vf "fps=1,scale=960:-2" -q:v 3 frames/%06d.jpg

这里有几个细节值得注意。

第一,抽帧分辨率不需要太高。960宽的帧已经足够人眼和OCR识别屏幕内容,再高纯属浪费磁盘和后续计算。关键帧提取阶段对分辨率不敏感,反而低分辨率处理更快。第二, -q:v 3 是JPEG质量参数,3到5之间比较合适,再高就得不偿失。第三,ffmpeg抽帧是I/O密集型任务,即使没有硬件加速,纯CPU抽帧2小时视频也只耗时几分钟,所以这一步不是性能瓶颈。

抽完帧数一下,2小时视频按1秒1帧就是7200帧左右。

5.3 步骤二:帧筛选压缩到41张

关键帧筛选是纯CPU计算,逻辑在前面已经讲过了。这里直接给一个粗略的时间参考:

  • 7200张960x540缩略图读入内存:约20秒
  • 逐帧dHash + 直方图特征计算:约30秒
  • 相邻帧差异比较 + 初步关键帧候选:不到1秒
  • OCR(用PaddleOCR CPU模式跑所有候选帧):约60~150秒,取决于候选帧数量

筛选完之后你会得到一个 keyframes_manifest.json ,里面记录了每一张关键帧的文件名、时间戳、触发原因(scene_change/text_change/forced)。这个manifest在后续LLM理解阶段非常重要——它就是我们给LLM的"视频时间轴地图"。

5.4 步骤三:LLM批处理与聚合

到这一步时,需要喂给多模态LLM的图片只有41张。假设每张图调用一次API需要5~10秒(取决于模型和网络),41张图跑完大约需要3~7分钟。我加了并发控制,同时跑3个线程,总耗时可以进一步压缩到2~3分钟。

这里有一个经验: 不要把并发拉太高。 很多API有Rate Limit,而且并发过高会导致响应延迟急剧增加,甚至触发429错误。3个并发是我在大多数模型上实测比较稳妥的数值,既不触发限流,又能把总耗时控制在分钟级。

最后一步聚合是纯文本LLM调用,输入41段描述,约4000~5000 token,输出JSON结构化结果。单次调用不到30秒。这样算下来,整个流程的耗时大概是:

步骤 耗时(实测)
ffmpeg抽帧 约1分钟
帧筛选+OCR 约2~3分钟
LLM逐帧理解(41张图) 约3~5分钟
LLM聚合总结 约30秒
总计 约7~9分钟

7200秒的视频,7~9分钟跑完,换算下来就是8~17倍实时。我README里写的5.9×是一个更保守的数值——因为当时跑的机器CPU较老、LLM接口也没有做并发优化,包括了首次冷启动和OCR完整加载模型的时间。这套管线在性能上还有相当的余量,不是极限优化过的结果。

5.5 输出示例与使用体验

跑完之后输出的是标准的JSON,可以直接转成Markdown、导入Notion、或者作为RAG语料进向量库。我这边的实际使用是这样的:把视频上传到指定目录,跑一遍管线,得到一个带时间戳的知识点大纲,然后把这个JSON喂给问答系统,学生问"课程里讲没讲过XX"时,系统可以直接定位到视频的第几分几秒。

从点击运行到看到结果,整个过程大约8分钟。如果当时视频还在录屏,那基本上就是"刚录完就已经整理好了"。

6. 常见问题与排查实录

6.1 问题速查表

把我在开发过程中遇到的高频问题整理成了一张速查表,方便直接对号入座:

现象 可能原因 解决方案
关键帧数量爆炸(几百张) dHash阈值过高或过严 调整汉明距离阈值到8~12区间
关键帧太少,明显漏掉重点 强制周期过长 缩短FORCE_INTERVAL到60秒
OCR触发大量无意义帧 文本去噪不足 过滤长度小于2的字符串,忽略纯标点
LLM输出JSON格式非法 模型版本不稳定 增加重试机制,用正则兜底提取JSON片段
抽帧后大量黑帧/花屏 视频编码异常 用 -vsync vfr 尝试,或更换源文件解码器
同一关键帧被重复记录 多个触发器同时命中 按时间戳去重,时间差小于2秒只保留一张
API调用报429 并发过高 降到2~3并发,增加退避重试
描述内容张冠李戴 上下文传递错误 检查上一帧上下文是否拼接成了无序列表

6.2 三个真实的坑

第一个坑是 在视频里出现"黑场"时,dHash会失灵 。有些录播课程在章节之间插入了纯黑过渡画面,两张黑场的dHash完全相同,直接导致序列里的场景切换判定失效。后来我加了一步预处理:如果一帧的平均亮度低于某个阈值,就把它标记为"过渡帧"直接跳过,不参与dHash比较。

第二个坑是 LLM对"同一画面不同时间"的两张图会给出互相矛盾的描述 。比如一章的标题页在第5分钟出现,第30分钟又出现一次,模型第一次可能把标题里的"第三章"误识别成"第二讲"。这个问题的根源在于模型没有全局视野,单张图描述不可避免有错。我的应对方案是在聚合阶段明确告诉LLM:"如果有章节标题重复出现,请以最后一次出现的编号为准",同时在输出JSON里增加一个 conflict_notes 字段,让模型把这个矛盾显式记录下来,而不是硬编一个可能错误的结果。

第三个坑是 OCR模型对代码类PPT特别容易误识 。教学视频里大量出现代码片段,PaddleOCR经常把下划线识别成空格、把 -> 识别成 一> ,导致文字差异检测失效。调试了好几天我才意识到问题不在管线逻辑,而在OCR精度本身。最终解决方案是:对OCR识别的文本做一次轻量归一化(去空格、统一标点、小写化),再去做差异计算,误报率立刻降下来了。

6.3 如何验证关键帧筛选质量

这是不少人忽略的一步。管线跑完之后,不要急着直接review LLM输出,先人工检查关键帧本身的质量。我通常会把关键帧合成一个PDF或视频拼图,快速翻一遍:

  • 如果看到连续几张几乎一样的图,说明触发器太敏感
  • 如果发现某一大段时间跨度里完全没有关键帧,说明那段可能有内容被漏掉了
  • 如果OCR文字和图像内容对不上,说明帧的时间戳或OCR提取有bug

这一步人工审查成本很低(41张图一分钟就看完了),但能帮你在问题传导到LLM之前就把bug消灭掉。我个人的经验是: 关键帧筛选阶段的问题,越早发现,修复成本越低。等LLM输出完才发现不对,重跑的成本就高了。

写在最后:这套管线还能怎么玩

其实这套管线的核心逻辑,远远不限于"视频理解"。把视频理解成"带时间戳的帧集合",再把帧集合压缩成"高信息密度的关键帧",这个思路完全可以横向迁移。比如我最近就在尝试把同样的管线接到直播流上,用流式抽帧加关键帧缓存,实现对直播内容的实时要点捕捉。还打算把关键帧描述阶段输出的向量,直接灌进RAG系统,做成一个"视频语义搜索引擎"。

回到最开头那个数字:18万帧压成41张图。这个压缩比背后不是说视频里99.9%的内容都没用,而是说我们找到了提取那0.1%有效信息的工程路径。做AI应用这几年,我越来越觉得,很多时候瓶颈不在模型能力,而在输入数据的组织方式。 喂给模型的不是越多越好,而是越"对"越好。 这套管线能让你用最少的token、最少的时间,让LLM真正看明白一段长视频,这也是我把它开源出来的初心。如果你手头也有批量视频需要理解,不妨直接拿去跑一版,有任何问题欢迎到仓库提issue,我会持续迭代。

Logo

邀请您加入社区

更多推荐