两小时课程视频压成41张拼图:多模态大模型视频理解开源管线
两小时的课程录像,25fps,算下来 18 万帧。这个数字是我在写第一版脚本时打印到终端里的,当时盯着屏幕愣了好几秒——不是被吓到,是意识到自己差点走错路。我最初的想法很朴素:抽帧、把帧喂给多模态模型、让它输出带时间戳的课程笔记。等我把 token 账算清楚,发现光是 2fps 的粗抽帧就有 1.4 万张图,按低分辨率每张 512 token 估算,输入规模直接冲到七百万 token 量级。这就不是"贵不贵"的问题了,是根本塞不进上下文。
于是整个项目从"怎么写提示词"变成了"怎么把 18 万帧压到模型真的能看完的规模"。最后的方案是把视频压成 41 张拼图(contact sheet),配合 OCR 抽出来的屏幕文字,一次性喂给一个多模态大模型,跑完 2 小时视频总墙钟时间 20 分 20 秒,也就是 5.9× 实时。这套 LLM 视频理解管线我已经开源出来了,这篇文章把每一段拆开讲,包括我返工三次才搞明白的几个坑。
1. 18 万帧这个数的由来:先算清 token 预算,再决定怎么压
1.1 25fps 乘以 7200 秒,一个不该被跳过的乘法
很多人做视频理解,第一反应是"抽 1fps 就够了"。这句话在场景变化慢的内容上没错,但它掩盖了一个事实:抽帧率是拍脑袋定的,不是算出来的。我拿到素材的第一件事是跑
ffprobe
把元数据打出来。
ffprobe -v error -select_streams v:0 \
-show_entries stream=avg_frame_rate,r_frame_rate,nb_frames,width,height,duration \
-of default=noprint_wrappers=1 input.mp4
结果是这样的:25fps,7200.4 秒,1920×1080,标称总帧数 180,010。顺手也把音频流和字幕流看了一眼——这一步很关键,后面会讲为什么。
180,000 这个数字给人的直觉冲击其实是错的。真正决定成本的是"我打算把多少张图送进模型",而不是"视频里有多少帧"。所以第二步不是抽帧,是算账。
1.2 一张拼图到底值多少 token
多模态模型处理图像不是"一张图一个固定价格",主流做法都是先按固定边长切 tile,再把 tile 编码成视觉 token,最后拼一张全局缩略图。也就是说, 图像 token 数大致和 tile 数量线性相关 。
举个实际量级:一张 1536×864 的图,如果模型按 768×768 切片,会切成 2×2 共 4 个 tile,加上一张全局缩略图,视觉 token 大概落在 1,100 到 1,600 之间(不同厂商、不同版本的切法差异不小,必须自己实测标定,别信我给的绝对值)。
反过来算,如果我把 14,400 张粗抽帧逐张送进去,哪怕每张只按最低档 512 token 计,总量是 737 万 token。单次请求塞不进,拆成几十次请求的话,模型对全局叙事结构的理解会碎掉——它看第 8 批的时候已经忘了第 1 批讲了什么。
所以我把目标改写成: 用尽可能少的图,承载尽可能多的"值得写进笔记的瞬间" 。数量目标定在 40 张左右,因为 40 张 × 1,500 token ≈ 6 万 token,再加上 OCR 文本两万 token,总共 8 万 token 左右,能一次性放进主流长上下文模型,还留有输出空间。
180,000 帧 → 41 张图,压缩比约 4,390:1。听起来很暴力,但并不是均匀砍掉 4,390 帧里的 4,389 帧,中间是有漏斗的。
1.3 我的目标函数不是"最省 token",而是"不漏内容"
这里我要说个反直觉的观点: 压缩率不是指标,覆盖率才是 。
省 token 很容易,抽 5 帧也是省。但如果那 5 帧刚好都落在讲师喝水的片段上,这份笔记就废了。所以我给自己定了一个可验证的自检方法:
- 从原视频里按时间均匀随机取 200 帧,不看压缩结果,先人工标注"这帧有没有值得写进笔记的信息"(比如新标题、代码、图表、结论)。
- 跑完管线,检查这 200 帧里的"有效帧",有多少被最终 41 张拼图覆盖到。
- 覆盖率低于 95% 就回去调参数。
第一版的覆盖率只有 71%。漏掉的主要是三类:讲师快速翻页时的幻灯片过渡帧、屏幕右下角的板书小抄、以及讲解过程中弹出的短时图表。这三类恰好都是"帧间差异很小但信息量很大"的情况——这也直接决定了后面抽帧策略的设计方向。
2. 抽帧不是抽签:从 18 万到 369 的四级漏斗
2.1 第一级:解码只做一遍,把结果落到哪里
最朴素的做法是
ffmpeg -i input.mp4 -vf fps=2 frames/%06d.jpg
,一行命令出 14,400 张 JPEG。能跑通,但有三个隐患。
第一,JPEG 质量参数默认值偏高,这批图后面还要做感知哈希和拼图,重编码会累积损失。我改成输出 PNG 到内存盘,或者干脆用管道直接读原始帧数据,不落盘。
第二,也是最坑的:
用
fps=2
滤镜抽帧,输出的帧号是重新编号的,和原视频帧号对不上
。后面要回查时间戳时,你会发现自己手里只有
000123.png
,却不知道它是原视频的第几帧。解决办法是让每一帧都带上 PTS(显示时间戳)。我用的方式是先让 ffmpeg 把抽帧结果写成一个带时间戳的清单:
ffmpeg -v error -i input.mp4 \
-vf "fps=2,showinfo" -vsync 0 \
-f null - 2>&1 | grep -oP 'pts_time:\K[0-9.]+' > pts_2fps.txt
showinfo
会把每一帧的
pts_time
打到 stderr,抽出来就是一个和输出帧一一对应的时间轴。后面任何时候我都能用行号反查真实秒数。这个文件是整个管线的地基,千万别省。
第三,解码本身要快。1080p、H.264、2 小时,纯 CPU 解码大概要 6 到 8 分钟,这一项就吃掉三分之一的实时倍率。开启硬件解码后能压到 2 分半左右。我在有独显的机器上直接上
-hwaccel cuda
,在只有核显的机器上用
-hwaccel videotoolbox
或
vaapi
,脚本里做了一层能力探测,探测不到就回退软解,不报错。
这里有个细节值得单独提:
抽帧率不要设成固定值,要和视频的帧率挂钩
。25fps 的视频抽 2fps 是每 12 到 13 帧取一帧;如果是 60fps 的视频,2fps 意味着每 30 帧取一帧,可能整段跳过快速闪现的内容。我的做法是
fps = min(2.0, src_fps / 6)
,保证任何情况下至少每 6 帧看一眼。
2.2 第二级:场景检测和感知哈希,为什么我用两路而不是一路
从 14,400 帧往下砍,只用一种算法一定会出事。
先试纯场景检测(
ffmpeg
的
select='gt(scene,0.3)'
)。问题是它只看帧间的全局差异,对"幻灯片淡入淡出"这类缓慢过渡不敏感,对"讲师在镜头前晃了一下"却特别敏感。我在一段课上跑出来 1,200 个切点,其中有 400 多个是讲师挥手导致的。
再试纯感知哈希(pHash)。它对"画面结构变化"敏感,能很好地识别连续帧的重复,但它的致命伤我后面会专门讲——它会把"画面完全没变、只有底部字幕在滚动"的一组帧判为重复,然后把字幕内容整个丢掉。
最终方案是两路并联、取并集:
| 检测路径 | 判据 | 阈值起点 | 主要抓什么 |
|---|---|---|---|
| 场景切变 | 帧间直方图/像素差 | 0.32 到 0.38 | 幻灯片翻页、摄像头切换、镜头跳转 |
| 结构去重 | pHash 汉明距离 | 距离 ≤ 6 视为同帧 | 静态画面下的冗余帧 |
| 文本差异 | OCR 结果的行级 diff | 任意一行变化即保留 | 字幕滚动、代码逐行输入 |
第三路是后来加的,也是把覆盖率从 71% 拉到 90% 的关键。OCR 这一路贵,但它只在"画面结构没变"的帧组里跑,实际计算量并不大——因为结构没变的帧组本来就不多。
三道筛子过完,14,400 帧剩下大约 3,800 帧。压缩比约 3.8:1,看起来效率不高,但这一层的任务是 保真 ,不是压缩。真正砍量的是下一层。
2.3 第三级:时间配额采样,防止"重点段抽不到帧"
3,800 帧里,分布是极不均匀的。讲师讲一个复杂图表的 90 秒里可能产生 300 帧,而讲到一段结论时画面完全静止,90 秒只有 1 帧。
如果这时候直接按帧数下采样到 369 帧,会发生什么?图表段因为帧多,被大量保留;结论段因为帧少,可能一帧都不剩。结果就是笔记里全是图表的细节,没有讲师的总结。
我的处理是引入 时间配额 ,把 7,200 秒切成 369 个等长的时间桶,每个桶 19.5 秒,然后:
- 如果某个桶里有多帧候选,按"与桶内其他帧的差异度"排序,取差异最大的那一帧。差异大意味着这一帧在这个时间段里最不重复,通常也是信息最集中的。
- 如果某个桶里只有一帧,直接用。
- 如果某个桶里一帧都没有(比如那段视频编码损坏或全黑),标记出来,从相邻桶借一帧补位,避免出现时间轴空洞。
这一步做完,时间轴覆盖是 100% 均匀的。每个 19.5 秒的窗口至少有 1 个代表帧。代价是丢失了一些"同一秒里连续闪过的三张幻灯片",这个损失我用后面的 OCR 文本兜住了——幻灯片文字全部进了 OCR 结果,即使画面没被选中,文字还在。
2.4 那 6 张"加料"拼图是怎么被选出来的
369 个代表帧,平均铺开就是 41 张九宫格。但我留了一个后门: OCR 文本密度超过阈值的帧,会给它一个"加料"名额 。
具体做法是统计每个格子的 OCR 字符数和"等宽字体比例"(代码和终端输出的典型特征)。如果一整张拼图的 9 个格子都是高字符密度,这张拼图就会被重新渲染成 2 倍分辨率——单格从 512×288 变成 1024×576,整图从 1536×864 变成 3072×1728。
代价很直接:这张图的 token 消耗会翻大约 3 到 4 倍。所以名额是有限的,我给的配额是 41 张里最多 6 张。实测下来,一套编程课通常是 4 到 5 张触发,一门纯讲授型课程一张都不会触发。
提示:加料图的判定阈值不要设太松。我一开始设成"只要一半格子文字多就加料",结果 41 张里有 17 张触发,总 token 冲上去 40%,而那些触发图里有一半是讲师的名字模板页,纯浪费。
3. 41 张拼图长什么样:格子排布、分隔线与被模型看懂的秘密
3.1 3×3 还是 4×4:算一下整图分辨率和单格可读性
这是我最纠结的一个参数,前后试了 2×2、3×3、4×4、5×5 四档。
| 排布 | 单格尺寸 | 整图尺寸 | 单格可读性 | token 量 | 适用场景 |
|---|---|---|---|---|---|
| 2×2 | 960×540 | 1920×1080 | 幻灯片正文完全可读 | 高 | 幻灯片为主的课程 |
| 3×3 | 512×288 | 1536×864 | 大标题可读,正文需 OCR 兜底 | 中 | 通用均衡,我的默认值 |
| 4×4 | 384×216 | 1536×864 | 基本只能看出布局 | 中 | 快速浏览、找场景 |
| 5×5 | 307×173 | 1536×864 | 只能识别是幻灯片还是讲师 | 偏低 | 超长视频的粗索引 |
关键洞察在这里: 整图尺寸不变的情况下,格子越多,每格越小,但整图 token 基本不变 。所以这不是一个"省钱"的旋钮,而是一个"信息密度 vs 单格清晰度"的旋钮。
3×3 是我测试下来性价比最好的。512×288 的单格,幻灯片的大标题能看清,正文要靠 OCR;但至少能判断"这一格是什么类型的画面",配合 OCR 文本,模型足够还原内容。
顺带一个反直觉的结论: 4×4 和 3×3 的整图 token 几乎一样 ,但 4×4 的单格清晰度明显下降,而格子数只多了 78%。所以除非你的视频特别长、需要塞更多帧,否则别上 4×4。2×2 则是另一个极端——单格清晰但格子太少,2 小时视频只能铺 164 帧,覆盖会出问题。
3.2 角标、时间戳、分隔线,三个决定成败的细节
拼图渲染这段代码我改了五版,前四版都是因为模型"看串行"。
分隔线 。第一版我图省事,格子之间没留缝,直接拼在一起。结果模型把两个相邻格子里的内容读成了一张幻灯片的一部分,输出了完全不存在的标题。后来我加了 12px 的纯白分隔线,问题基本消失。缝太窄(4px)不行,模型在缩放后看不清;太宽(30px)浪费面积。12px 是我的经验值,约占整图宽度的 0.8%。
角标 。每个格子的左上角画一个大号数字,字号取格高的 9% 左右,白底黑字加半透明背景。这个数字有两个作用:一是让模型在输出时能引用"第 7 格",方便我反查时间戳;二是给模型一个明确的分割信号,进一步降低看串行的概率。
时间戳
。每个格子右下角叠一个
HH:MM:SS
的小字。这个必须在渲染阶段烧进图里,不能只写在提示词里。因为模型对"第 3 张图第 7 格"和"1:23:45"之间的映射关系经常搞错,把时间戳直接画在格子里,输出的准确率提升非常明显。
from PIL import Image, ImageDraw, ImageFont
CELL_W, CELL_H = 512, 288
GAP = 12
COLS, ROWS = 3, 3
canvas_w = COLS * CELL_W + (COLS + 1) * GAP
canvas_h = ROWS * CELL_H + (ROWS + 1) * GAP
canvas = Image.new("RGB", (canvas_w, canvas_h), "white")
draw = ImageDraw.Draw(canvas)
idx_font = ImageFont.truetype("DejaVuSans-Bold.ttf", int(CELL_H * 0.09))
ts_font = ImageFont.truetype("DejaVuSans.ttf", int(CELL_H * 0.055))
for i, (frame_img, ts_sec) in enumerate(cells):
r, c = divmod(i, COLS)
x = GAP + c * (CELL_W + GAP)
y = GAP + r * (CELL_H + GAP)
canvas.paste(frame_img.resize((CELL_W, CELL_H)), (x, y))
# 角标
draw.rectangle([x, y, x + int(CELL_W * 0.10), y + int(CELL_H * 0.14)], fill="white")
draw.text((x + 6, y + 2), str(i + 1), fill="black", font=idx_font)
# 时间戳
hh, rem = divmod(int(ts_sec), 3600)
mm, ss = divmod(rem, 60)
label = f"{hh:02d}:{mm:02d}:{ss:02d}"
draw.rectangle([x + CELL_W - 150, y + CELL_H - 44,
x + CELL_W, y + CELL_H], fill="white")
draw.text((x + CELL_W - 144, y + CELL_H - 42), label, fill="black", font=ts_font)
canvas.save("sheet_01.png", optimize=True)
3.3 排序的陷阱:时间顺序是对的,但要显式标注
关于格子的排列顺序,我犹豫过要不要按"内容主题"排——把讲同一个知识点的帧放一起。最后放弃了,原因很简单: 模型没有外部知识来校验这个顺序,一旦顺序和真实时间轴不一致,它给出的因果推理就全错了 。
举个例子。讲师在第 12 分钟提了一句"这个我们后面会讲",在第 45 分钟展开了。如果按主题排序把两帧放一起,模型很可能输出"讲师在此处先提出再展开",而实际上中间隔了半小时。这种错误在生成学习笔记时是灾难性的。
所以顺序永远是严格时间序,从左上到右下。同时我在提示词里显式说明这一点:「每张图从左到右、从上到下按时间递增排列,格内右下角标注的是该帧在原视频中的绝对时间」。
4. 把图和文字一起喂进去:提示词结构、JSON 约束与时间戳反查
4.1 提示词骨架:图在前、指令在后,以及为什么
提示词的组织方式我试过三种:指令在前图在后、图在前指令在后、图文交错。结论很明确—— 图在前、指令在后效果最好 ,尤其是当我要模型精确引用"第几张图第几格"的时候。
我的猜测是,模型先完成视觉编码,再读指令时已经带着"我看到了什么"去理解要求,引用会更准确。反过来,如果指令在前,模型会带着一个待填的模板去看图,容易"往里凑"内容。这个结论没有严格的论文支撑,是我在自己的测试集(大约 30 个视频、200 多张拼图)上反复验证出来的,你可以自己在你的场景上复现一下。
骨架大致长这样:
[图 1]
[图 2]
...
[图 41]
下面是 OCR 从原视频中提取的屏幕文字,按时间戳排列:
[00:00:12 - 00:00:48] 第 1 章 数据结构的本质
[00:00:49 - 00:01:30] 数组的内存布局 ...
(约两万字,略)
请你扮演一位课程笔记整理者,根据上面的拼图和 OCR 文本,
输出一份结构化的课程大纲,要求:
1. 每张拼图输出一个 section,section 内按格子顺序列出关键点;
2. 引用格式为「图 N 格 M @ HH:MM:SS」,时间戳必须来自格内标注;
3. 屏幕文字(幻灯片、代码、终端输出)必须逐字引用,不要改写;
4. 只描述图中确实存在的内容,看不清就写"该格画面模糊"。
输出为 JSON 数组,schema 见下。
这里有个容易忽略的点: OCR 文本一定要带时间戳区间 ,不能只给一坨纯文本。因为模型需要把 OCR 文本和拼图对齐,如果文本没时间信息,它只能靠猜。
4.2 强制 JSON 输出的三层防护
直接说"请输出 JSON",模型有至少三成的概率给你一段包在 Markdown 代码块里的 JSON,再加一句"以上就是整理结果"。所以我在工程上做了三层防护。
第一层,用模型侧的原生结构化输出能力(比如 JSON Schema 约束、tool calling 或 response format 参数),这层能解决 80% 的问题。但注意,不同厂商、不同版本对这个特性的支持程度差别很大,有的模型开了约束之后输出质量会下降(因为解码被限制,模型的推理链被压缩了),这一点必须实测。
第二层,解析容错。写一个还原器,处理这几种常见畸形:被 ```json 包裹、前后有多余文本、字符串里有未转义的换行、时间戳格式不一致(1:23 和 01:23:45 混用)。
import json, re
def robust_parse(raw: str):
s = raw.strip()
# 剥掉 markdown 代码块
m = re.search(r"```(?:json)?\s*(.+?)\s*```", s, re.S)
if m:
s = m.group(1)
# 截取最外层数组或对象
for opener, closer in (("[", "]"), ("{", "}")):
start, end = s.find(opener), s.rfind(closer)
if start != -1 and end > start:
s = s[start:end + 1]
break
try:
return json.loads(s)
except json.JSONDecodeError:
# 兜底:容忍裸换行
s = re.sub(r"(?<!\\)\n", " ", s)
return json.loads(s)
第三层,语义校验。这一步最重要,也是很多人会跳过的。解析成功不等于内容正确。我必须校验:
-
image_id是否在 1 到 41 之间,cell_index是否在 1 到 9 之间; -
输出的
timestamp是否落在该格子的真实时间戳附近(我的容忍窗口是 ±25 秒,因为格子本身就是 19.5 秒一个); -
on_screen_text字段的内容,是否能在 OCR 结果里找到对应片段(用模糊匹配,相似度低于 0.7 的标记为可疑)。
校验不通过就进入修复重试:把错误项单独挑出来,附上"你上次输出的第 12 项时间戳是 00:03:15,但第 2 张图第 3 格的时间戳是 00:07:42,请修正",重新请求一次。实测下来,重试能把错误率从 12% 压到 3% 以内。
4.3 索引表:从"第 3 张第 7 格"回到 1:23:45
整条管线里我最满意的一个设计,是那张毫不起眼的索引表。它就是一张 CSV(或者 parquet),每行对应一个格子:
| image_id | cell_index | src_frame_idx | pts_seconds | lost_frames | ocr_chars |
|---|---|---|---|---|---|
| 1 | 1 | 12 | 0.48 | 0 | 0 |
| 1 | 2 | 318 | 12.72 | 11 | 42 |
| 1 | 3 | 630 | 25.20 | 12 | 118 |
| ... | ... | ... | ... | ... | ... |
| 41 | 9 | 179,882 | 7195.28 | 9 | 0 |
有了它,模型输出的任何一句引用,我都能在两毫秒内换算回原视频的精确位置,然后一键裁出对应的视频片段。写笔记的时候如果想看上下文,直接跳到那个时间点就行。
lost_frames
这一列是我特意加的,记录这个格子代表的时间窗内被丢弃了多少帧。它有两个用途:一是排查异常(如果某格 lost_frames 突然飙到 800,说明那段时间画面剧烈变化,很可能漏了东西);二是给输出加上置信度标注,lost_frames 高的格子,笔记里对应的结论我会标个"此处画面变化快,建议回看原视频"。
ocr_chars
则用来判断内容密度。字符数很低但 lost_frames 很高的格子,是最需要人工复核的位置。
5. 5.9× 实时是怎么抠出来的:解码、并行、缓存三件事
5.1 先把 20 分 20 秒拆开看
"5.9× 实时"这个说法容易被误读,我先把定义说清楚: 端到端墙钟时间 1,220 秒,处理 7,200 秒的视频,7200 / 1220 ≈ 5.9 。这个数字包含了从读文件到输出最终 JSON 的全过程,但不是每次都这样——第一次跑要下载模型、编译依赖,那些时间我没算进去。
时间账大致是这样:
| 阶段 | 耗时 | 占比 | 瓶颈类型 |
|---|---|---|---|
| 解码 + 2fps 抽帧 | 2 分 40 秒 | 13% | IO + 硬件解码 |
| 场景检测 + pHash 去重 | 4 分 20 秒 | 21% | 单核 CPU |
| OCR(仅差异帧) | 3 分 40 秒 | 18% | CPU / 小模型推理 |
| 拼图渲染 | 55 秒 | 5% | 内存带宽 |
| 多模态模型推理(含重试) | 6 分 30 秒 | 32% | 网络 + API 限流 |
| 索引、校验、落盘、调度 | 2 分 15 秒 | 11% | 杂项 |
看完这张表你会发现, 真正的大头是模型推理,占了三分之一 ,而且这部分时间你基本控制不了(除非自建推理)。所以想继续提速,方向不是"把抽帧算法写得更快",而是"减少送进模型的 token"或者"把单次请求拆成并行请求"。
我试过把 41 张图拆成 4 批并行请求,推理时间从 6 分 30 秒降到 2 分 10 秒左右,但代价是跨批次的叙事连贯性明显变差——模型在写第 4 批的时候不知道第 1 批讲了什么,章节之间会出现重复和断裂。最后还是退回单次请求,用时间换质量。
5.2 按 GOP 切片并行解码,别用单进程硬扛
解码阶段我做过一次优化,效果比预期大。
默认的 ffmpeg 解码是单线程顺序的,2 小时 1080p 素材,就算开了硬件加速,输出 14,400 张图也要两分半。后来我改成按关键帧(I 帧)边界把视频切成 N 段,N 进程并行解码,每段各自抽帧,最后按 PTS 归并。8 核机器上这个阶段直接压到了 40 秒出头。
坑在于切分点必须落在 I 帧上,不能随便按时间切。因为非关键帧依赖前面的参考帧,从中间切会导致开头一段花屏,抽出来的帧直接废掉。获取 I 帧位置可以用:
ffprobe -v error -select_streams v:0 \
-show_frames -show_entries frame=pts_time,key_frame \
-of csv=p=0 input.mp4 | awk -F, '$2==1 {print $1}'
拿到 I 帧时间点列表后,按"累计时长尽量均分"的策略切成 N 段,每段的前后各留 1 秒重叠区,避免边界附近的帧丢失。最后按 PTS 去重合并。
5.3 内容哈希缓存,让重跑变成几乎免费
调参阶段我会反复重跑整条管线。如果每次都从头来,一天下来什么也干不了。所以我给每个阶段的输出都加了内容寻址缓存。
缓存键的构造方式是:
输入文件的内容哈希 + 该阶段的参数哈希
。比如场景检测阶段的键是
sha256(视频前1MB + 文件大小 + 视频时长 + 阈值参数)
。这样只要视频没变、参数没变,结果直接命中。
这里有个细节我踩过: 不要用文件路径当缓存键 。我一开始就是这么干的,结果把同一个视频复制了一份改名,缓存直接失效,又跑了一遍。也别只哈希文件名和修改时间——有些人会原地覆盖同名文件。
更细一层,拼图渲染阶段的缓存键应该是"这一组 9 帧的哈希集合",而不是整条管线的哈希。这样当你只改了某一帧的选择逻辑时,只有受影响的几张拼图需要重渲染,其余 30 多张直接复用。
缓存带来的另一个好处是 断点续跑 。Windows 上长任务被系统更新打断是常事,有了缓存,重启后直接跳过已完成的阶段。我把这一步做成了自动的,脚本启动时先扫一遍缓存目录,打印"已完成 32/41 张拼图,从第 33 张继续"。
5.4 什么时候值得上 GPU,什么时候不值得
这个问题被问得最多。我的经验是分三段看:
解码 :有独显就上硬件解码,收益很明确,解码阶段能快 3 倍以上。核显也能用,但不同驱动版本的表现差异大,一定要在你的目标机器上实测,别信别人的跑分。
感知哈希和场景检测 :可以放到 GPU 上算,但收益不明显。这两步的核心操作是缩略图生成加整型比较,本质是内存带宽受限,CPU 多核并行已经足够。我试过用 CUDA 重写,速度只快了 15%,还引入了额外的显存拷贝开销,最后放弃了。
OCR :这才是 GPU 真正能帮上忙的地方。如果用小型文本检测识别模型,GPU 能把 OCR 阶段从 3 分 40 秒压到 1 分钟以内。但注意,OCR 只跑在差异帧上(大约 3,800 帧),如果视频里幻灯片切换很少,OCR 的绝对耗时本来就不高,GPU 的收益也就有限。
推理 :如果你自建多模态模型推理,GPU 是刚需;如果调 API,那 GPU 和你没关系,你要关心的是并发限制和重试退避。
综合下来,我的建议是:先别急着上 GPU,把管线在 CPU 上跑通、把参数调好。等确定了参数,再针对解码和 OCR 两个点做加速。我见到太多人一开始就折腾 GPU 环境,结果花了两天配驱动,管线本身还没跑起来。
6. 翻车记录:那些让我返工的坑
6.1 pHash 去重把字幕整个吃掉了
这是我付出代价最大的一次返工,也是我后来加第三路 OCR 差异检测的直接原因。
情况是这样的:讲师在讲一个静态的架构图,画面整整 4 分钟没变,只有底部的中文字幕在滚动。pHash 对这种"整体结构不变、局部小幅变化"的帧几乎是无感的,汉明距离一直在 3 到 5 之间,全部被判为重复帧,4 分钟里只留了 1 帧。
结果就是笔记里这 4 分钟的内容完全空白。更糟的是,这 4 分钟恰好是整节课的核心讲解段落。
修复方案是加文本差异检测:对一组被判为重帧的帧序列,抽样跑 OCR,如果 OCR 结果的行级 diff 超过阈值(我用的判据是"有任意一行新增或发生变化"),就把变化的帧单独保留下来。这一路加进去之后,14,400 帧的去重结果从 3,100 帧涨到了 3,800 帧,多出来的 700 帧基本都是这类"画面静、文字动"的情况。
注意:字幕信息其实可以从音轨的语音转写里拿到,不一定非要靠 OCR 抽帧。我的建议是两条路都走——语音转写负责"讲师说了什么",OCR 负责"屏幕上显示了什么"。这两者的交集很小,合并起来信息量最大。
6.2 帧号除以 fps 得到的时间是错的
这个问题我在第一版里栽得很惨。我的第一批输出里,模型引用"图 12 格 5 @ 00:34:20",我按这个时间点去原视频里找,发现那里讲的是完全不相干的内容。
排查过程是这样的:先怀疑模型,让它重新输出,结果还是错的。再怀疑提示词,改成"必须严格引用格内时间戳",错误率降了一半,但还有。最后才想到去核对索引表——发现问题出在我自己身上。
原因是那个视频是**可变帧率(VFR)**的。屏幕录制软件为了省空间,静态画面时输出 5fps,滚动时输出 30fps,平均下来是 25fps。我按"帧号 / 25"算时间,而正确的做法是用每一帧自己的 PTS。在 VFR 视频上,这个误差会随着时间不断累积,到视频末尾可能差好几分钟。
修复很简单,但代价是我要把前面所有已生成的索引表全部重算。方法就是前面 2.1 节提到的,让每一帧都带上自己的
pts_time
,绝不用帧号做除法。
顺便说一句,
用
ffprobe
看
avg_frame_rate
和
r_frame_rate
这两个字段的差异,能快速判断视频是不是 VFR
。如果两者差距明显,就别用帧号算时间。
6.3 模型把相邻格子读成一张图
这个坑在 3.2 节提过,但我把它单独拎出来说,因为它太常见了。
症状是模型输出的内容里,出现了"幻灯片上有两个标题"、"代码块上下重复"这类描述。回去看拼图,发现是两个相邻格子的内容被合并描述了。
我试了三种改进,效果依次递增:
- 加分隔线(12px 白色)——能解决七成;
- 加左上角大号数字角标——再解决一成五;
- 在提示词里明确写"每张图由 9 个独立格子组成,格子之间由白色分隔线分开,不存在跨格子的连续内容"——最后这点补上,基本绝迹。
第三条看似废话,但实测真的有用。模型需要被明确告知"这是拼图不是原图",否则它默认按单张图的语义去理解。
6.4 OCR 结果和模型描述打架时,信谁
偶尔会出现这种情况:OCR 从幻灯片里读出的文字是"Apriori 算法",模型描述写的是"A priori 算法"。这种小差异无所谓。但如果 OCR 读出"时间复杂度 O(n²)",模型描述写"时间复杂度 O(n log n)",那就必须查了。
我的处理原则是: 结构性事实(数字、变量名、公式、代码)以 OCR 为准,语义性描述以模型为准 。因为 OCR 虽然会认错字,但它不会凭空创造;模型虽然能理解上下文,但它会"脑补"合理的但不存在的内容。
落地做法是在校验阶段加一条规则:模型输出的
on_screen_text
字段,必须能在 OCR 结果里找到相似度 0.7 以上的片段。找不到的,要么是模型幻觉,要么是 OCR 漏了。两种情况的处理不同——前者替换成 OCR 原文,后者标记出来提示我人工看一眼那格画面。
实测下来,这套规则拦下的"幻觉内容"大概占输出总量的 2% 到 4%,但拦下的往往是最关键的数字和结论,价值很高。
7. 这套管线不该用在什么地方:说清楚边界
7.1 需要运动信息的场景,直接劝退
拼图压缩的本质是 把时间维度折叠成二维网格 ,而折叠掉的恰恰是帧间的运动信息。所以凡是"动作本身携带信息"的场景,这套方案都不适用。
具体来说,这些场景别用:体育动作分析、装配线上的操作合规检查、手势交互识别、舞蹈或健身教学的动作纠正、需要判断"物体从哪来、往哪去"的视频理解。这些任务的答案藏在帧与帧之间的关系里,你把 9 帧并排放在一张图上,模型能看到 9 个状态,但看不到状态之间的转变过程。
有人会想,那我把格子减小、格子数增多,不就能近似出运动了吗?理论上有几分道理,但实测效果很差。12 格一组的连续帧放在一起,模型的描述倾向于"画面中的人从一个位置移动到另一个位置"这种笼统表述,具体到"手肘在第 3 帧到第 5 帧之间抬高了多少度",完全无能为力。要这类精度,老老实实上专门的动作识别模型或者逐帧视频编码方案。
7.2 短而密和长而疏,是两种完全不同的策略
这是我做第二个项目时才想明白的: 视频理解不是一种任务,至少是两种 。
短而密的视频 ,比如 5 分钟的产品演示、3 分钟的操作教程。这类视频信息密度极高,每一秒都可能有新东西。对这种素材,压缩是负收益——你直接把关键帧按 1fps 抽出来逐张送进去,比做拼图效果好得多。5 分钟的视频抽 300 帧,按 500 token 一帧算 15 万 token,稍微贵,但如果你抽到 0.5fps 就是 150 帧、7.5 万 token,完全可以承受。
长而疏的视频 ,比如 2 小时课程、会议录像。这类视频信息密度低,大量时间花在重复、停顿、闲话上。这时候拼图压缩才有意义,因为你要处理的是"量"的问题,而不是"精度"的问题。
我给自己划的线是: 视频时长超过 40 分钟,或者时长除以"有效信息密度"的估算值超过 20 分钟,就用拼图方案;否则直接抽帧送 。有效信息密度这个值我是用前面的 OCR 差异检测粗略估的——幻灯片变化频繁的课,密度高;纯口述讨论,密度低。
7.3 音轨信息量大的视频,先做语音转写
很多情况下,视频的信息主体在音频里,画面只是个说话的人。访谈、播客录像、纯讲授型课程都属于这一类。
这种素材正确的处理顺序是: 先做语音转写,拿到带时间戳的文字稿;再用低频抽帧(比如每 30 秒一帧)补充画面信息 。因为语音转写的 token 效率远高于图像——两小时的语音转写大概 3 万字,也就 2 万 token 左右,而两小时高质量图像编码要花掉十倍以上的 token。
反过来,如果视频里屏幕内容占主导(编程教学、软件演示、数据分析讲解),那就反过来:图像为主,语音转写为辅。
怎么判断?我的方法是算一个粗略的比例:统计画面里有明显文字内容的时间占比,以及音频里"人声能量超过阈值"的时间占比。前者高就是屏幕主导,后者高就是语音主导。两个都高的(比如边讲边写代码),那就都上,反正总量也在预算内。
7.4 参数速查表
最后把整条管线的关键参数整理成一张表,方便直接抄作业。需要说明的是,这些值是我在 1080p、25fps、中文课程类视频上标定出来的,换素材类型一定要重新标定,尤其是场景检测和文字密度这两个阈值。
| 参数 | 取值 | 调整方向 |
|---|---|---|
| 粗抽帧率 |
min(2.0, src_fps/6)
| 画面变化快的素材上调 |
| 场景检测阈值 | 0.32 到 0.38 | 切点太多就上调,漏切就下调 |
| pHash 汉明距离阈值 | ≤ 6 视为同帧 | 重复帧太多就上调 |
| OCR 文本差异触发 | 任意一行变化 | 不敏感就改成"两行以上变化" |
| 时间桶数量 | 与目标格子数一致 | 由拼图张数 × 9 反推 |
| 单格尺寸 | 512×288 | 上 2×2 时用 960×540 |
| 分隔线宽度 | 12px | 模型看串行就加宽 |
| 角标字号 | 格高的 9% | 模型读不出编号就加大 |
| 时间戳容忍窗口 | ±25 秒 | 一般不需要改 |
| OCR 相似度校验阈值 | 0.7 | 误报多就下调到 0.6 |
| 加料图名额 | 41 张里最多 6 张 | 按 token 预算分配 |
还有一个小经验:
这套参数最需要盯的不是任何一个阈值,而是
lost_frames
那一列的分布
。跑完一遍先别急着看输出,把索引表按
lost_frames
降序排一下,看看前 10 个格子都是什么。如果它们集中在某一段,说明那段视频有问题(画面剧变、编码损坏、或者有转场特效),值得单独回看。我靠这个方法抓到过两次漏内容的情况,一次是讲师快速翻过 20 页 PPT,一次是视频中间有一段扫码跳转的广告。
更多推荐

所有评论(0)