两小时的课程录像,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 帧刚好都落在讲师喝水的片段上,这份笔记就废了。所以我给自己定了一个可验证的自检方法:

  1. 从原视频里按时间均匀随机取 200 帧,不看压缩结果,先人工标注"这帧有没有值得写进笔记的信息"(比如新标题、代码、图表、结论)。
  2. 跑完管线,检查这 200 帧里的"有效帧",有多少被最终 41 张拼图覆盖到。
  3. 覆盖率低于 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 节提过,但我把它单独拎出来说,因为它太常见了。

症状是模型输出的内容里,出现了"幻灯片上有两个标题"、"代码块上下重复"这类描述。回去看拼图,发现是两个相邻格子的内容被合并描述了。

我试了三种改进,效果依次递增:

  1. 加分隔线(12px 白色)——能解决七成;
  2. 加左上角大号数字角标——再解决一成五;
  3. 在提示词里明确写"每张图由 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,一次是视频中间有一段扫码跳转的广告。

Logo

邀请您加入社区

更多推荐