客户有一批讲座录像(每节 45 分钟,画面是 PPT),想要"能搜索视频里出现过的内容"------也就是说,要把画面里的文字提取出来。
我一开始的想法很朴素:抽帧 → OCR。做下来才发现,难点不在 OCR,而在"抽哪些帧":
- 45 分钟的视频,25fps = 67500 帧;
- 但 PPT 内容一页可能停留 3 分钟 = 4500 个几乎一模一样的帧;
- 直接对 67500 帧做 OCR,既慢(几小时)又会产生大量重复结果。
加了帧去重之后,实际需要 OCR 的帧只有 300~600 个,处理时间从几小时降到几分钟。这篇写完整的流程和踩的坑。
TL;DR :流程是抽帧 → 感知哈希去重 → 预处理 → 文字检测 → OCR → 后处理 。三个关键点:去重是性能的关键 (用 dHash,能去掉 90%+ 的重复帧);预处理决定准确率 (放大、对比度增强、二值化比换 OCR 引擎更管用);中文场景优先 PaddleOCR (Tesseract 对中文效果一般)。后处理要做"跨帧去重 + 按时间聚合",输出{时间点, 文字块}的结构化结果。实测:PPT 类内容准确率 92%,街景招牌 60%,手写字 30%------场景文字(scene text)比文档扫描难得多。
目录
- 一、先说清楚:这不是"字幕提取"
- 二、完整流程
- 三、抽帧与去重:性能的关键
- 四、预处理:准确率的关键
- [五、OCR 引擎怎么选](#五、OCR 引擎怎么选)
- 六、文字区域检测(可选但推荐)
- [七、后处理:从一堆 OCR 结果到结构化数据](#七、后处理:从一堆 OCR 结果到结构化数据)
- 八、完整代码
- 九、实测数据
- 十、坑清单
一、先说清楚:这不是"字幕提取"
这两个任务经常被混为一谈,但技术路线完全不同:
| 字幕提取 | 画面文字提取(本文) | |
|---|---|---|
| 目标 | 字幕轨(或烧录在底部的字幕) | 画面里任意位置的文字(PPT、招牌、图表、书名) |
| 位置 | 固定(底部) | 任意 |
| 特点 | 排版规整、高对比 | 可能有透视、遮挡、复杂背景 |
| 难度 | 低 | 高(这叫 scene text recognition) |
如果只需要字幕,优先用:
- 有没有字幕轨(
ffprobe看有 subtitle 流)→ 直接提取; - 硬字幕 → 裁剪底部区域 + OCR(这样准确率高得多);
- 都没有 → 用 Whisper 转录音频,比 OCR 硬字幕准确。
本文处理的是"画面里任意位置的文字"------这个更难。
二、完整流程
视频
↓ 1. 抽帧(每 N 秒一帧,降采样)
↓ 2. 去重(dHash 比较,去掉重复帧) ← 性能优化的关键
↓ 3. 预处理(放大、增强对比、去噪)
↓ 4. 文字区域检测(可选,定位文字在哪)
↓ 5. OCR
↓ 6. 后处理(跨帧去重、合并、按时间聚合)
↓
结构化结果:[{t: 12.5, text: "..."}, ...]
三、抽帧与去重:性能的关键
抽帧 :对 PPT / 静态内容,每 2 秒一帧足够(fps=0.5)。对动态内容(比如街景),可以更密。
bash
ffmpeg -i input.mp4 -vf fps=0.5 -q:v 2 frames/%06d.jpg
去重:用 dHash(前面视频指纹那篇讲过):
python
import cv2
import numpy as np
def dhash(img, size=8):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
small = cv2.resize(gray, (size + 1, size), interpolation=cv2.INTER_AREA)
diff = small[:, 1:] > small[:, :-1]
val = 0
for b in diff.flatten():
val = (val << 1) | int(b)
return val
def hamming(a, b):
return bin(a ^ b).count('1')
def dedup_frames(frames, stamps, threshold=6):
"""去掉与上一帧高度相似的帧。"""
keep_frames, keep_stamps = [], []
prev_hash = None
for f, t in zip(frames, stamps):
h = dhash(f)
if prev_hash is None or hamming(h, prev_hash) > threshold:
keep_frames.append(f)
keep_stamps.append(t)
prev_hash = h
return keep_frames, keep_stamps
效果实测(45 分钟的 PPT 录像):
| 阶段 | 帧数 | 说明 |
|---|---|---|
| 原始 | 67500 | |
| 抽帧(0.5 fps) | 1350 | |
| 去重后 | ~320 | 减少了 76% |
OCR 调用从 1350 次降到 320 次------这是整个流程里最有效的优化。
阈值的选择:
threshold=6(64 bit 里有 6 位不同就认为是新帧)------对 PPT 合适;- 太小(2)→ 去重不充分;
- 太大(15)→ 可能漏掉"改了一个字"的情况。
注意 :PPT 有动画(一行行出现)时,每一帧都略有不同。阈值要能容忍这种"小变化但内容不同"的情况------这也是为什么我用 6 而不是 2。
四、预处理:准确率的关键
实测下来,预处理的收益比换 OCR 引擎大得多。
| 预处理 | 准确率变化(PPT 素材) |
|---|---|
| 原图直接 OCR | 71% |
| 放大 2 倍 | 84% |
| 放大 + 对比度增强 | 89% |
| 放大 + 增强 + 轻度去噪 | 92% |
为什么放大管用 :OCR 引擎对小字识别能力急剧下降。视频里的 12px 字,放大到 24px 之后识别率提升明显。
python
def preprocess(img, scale=2.0):
# 1. 放大(用 INTER_CUBIC 或 LANCZOS,不要 INTER_LINEAR)
h, w = img.shape[:2]
big = cv2.resize(img, (int(w * scale), int(h * scale)),
interpolation=cv2.INTER_LANCZOS4)
gray = cv2.cvtColor(big, cv2.COLOR_BGR2GRAY)
# 2. 对比度增强(CLAHE,比全局直方图均衡更温和)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray = clahe.apply(gray)
# 3. 轻度去噪(保留边缘)
gray = cv2.fastNlMeansDenoising(gray, h=7, templateWindowSize=7,
searchWindowSize=21)
return gray
几个注意点:
- 不要过度二值化 。自适应阈值对"白底黑字"很好,但对"彩色背景"会把文字搞没。我一般不二值化,让 OCR 引擎自己处理(现代引擎内部会做)。
- 不要锐化(会产生振铃,OCR 引擎会误判);
- 降噪要轻 (
fastNlMeansDenoising的 h 值 7 左右,太大字会糊)。
如果知道文字在固定区域 (比如 PPT 主体区域),先裁剪再 OCR------去掉周围的无关内容(比如视频控件、浏览器边框)能显著提升准确率。
五、OCR 引擎怎么选
| 引擎 | 中文效果 | 速度 | 部署 | 说明 |
|---|---|---|---|---|
| PaddleOCR | 好 | 快(有 GPU 更快) | pip 安装,模型需下载 | 中文场景推荐 |
| Tesseract 5 | 一般 | 中 | 系统包 + 语言数据 | 英文好,中文需 chi_sim |
| EasyOCR | 好 | 慢 | pip,模型大 | 多语言方便 |
| 云 API(各厂商) | 最好 | 快(网络) | 调用 API | 准确但有成本 + 隐私问题 |
我的选择:
- 中文内容 + 本地部署 → PaddleOCR(开源、中文模型质量好);
- 英文为主 → Tesseract(够用、依赖简单);
- 准确率要求极高 + 不涉及敏感内容 → 云 API;
- 敏感内容(客户资料、内部录像)→ 必须本地(不要传到云)。
PaddleOCR 示例:
python
from paddleocr import PaddleOCR
ocr = PaddleOCR(use_angle_cls=True, lang='ch', show_log=False)
def ocr_frame(img):
"""返回 [(文本, 置信度, 位置框), ...]"""
result = ocr.ocr(img, cls=True)
out = []
if not result or not result[0]:
return out
for line in result[0]:
box, (text, conf) = line[0], line[1]
out.append((text, float(conf), box))
return out
Tesseract 示例:
python
import pytesseract
def ocr_frame(img):
data = pytesseract.image_to_data(
img, lang='chi_sim+eng', output_type=pytesseract.Output.DICT)
out = []
for i, text in enumerate(data['text']):
if text.strip() and float(data['conf'][i]) > 30:
out.append((text.strip(), float(data['conf'][i]) / 100, None))
return out
中文模型 :Tesseract 需要下载 chi_sim.traineddata(放到 tessdata 目录)。PaddleOCR 会自动下载模型(首次运行慢)。
六、文字区域检测(可选但推荐)
为什么需要:直接对整帧 OCR 会:
- 慢(要处理整个画面);
- 容易把非文字区域(图表、纹理)误识别成文字。
检测文字在哪,然后只 OCR 那些区域------准确率和速度都提升。
PaddleOCR 内部已经包含检测,所以如果用 PaddleOCR,这一步是自动的。
用 OpenCV 的 EAST 检测器(独立方案):
python
def detect_text_regions(img, net, min_conf=0.5):
"""EAST 文字检测。net 需要预先加载。"""
h, w = img.shape[:2]
blob = cv2.dnn.blobFromImage(img, 1.0, (320, 320),
(123.68, 116.78, 103.94), swapRB=True, crop=False)
net.setInput(blob)
scores, geometry = net.forward(['feature_fusion/Conv_7/Sigmoid',
'feature_fusion/concat_3'])
# 解析输出(简化,完整的解析代码较长)
...
return regions
(EAST 的输出解析代码比较长,实际项目里我直接用 PaddleOCR 的内置检测,省事。)
七、后处理:从一堆 OCR 结果到结构化数据
OCR 出来的原始数据很乱,需要三步处理:
1. 帧内合并:把同一帧里的多行文字按位置排序(从上到下、从左到右),合并成一段。
python
def merge_lines(items):
"""items: [(text, conf, box)],按 y 再按 x 排序后合并。"""
def key(it):
box = it[2]
if box is None:
return (0, 0)
ys = [p[1] for p in box]
xs = [p[0] for p in box]
return (sum(ys) / len(ys), sum(xs) / len(xs))
return '\n'.join(t for t, c, _ in sorted(items, key=key) if c > 0.5)
2. 跨帧去重:相邻帧识别出的文字往往完全相同(同一页 PPT 的不同帧)。
python
def dedup_results(results):
"""results: [(timestamp, text)],去掉与上次相同的文本。"""
out = []
prev = None
for t, text in results:
norm = re.sub(r'\s+', '', text)
if norm and norm != prev:
out.append((t, text))
prev = norm
return out
3. 按时间聚合:输出"每一段时间的画面内容是什么"。
python
def to_segments(deduped, video_duration):
"""把 (时间点, 文字) 转成 [{start, end, text}]。"""
segs = []
for i, (t, text) in enumerate(deduped):
end = deduped[i + 1][0] if i + 1 < len(deduped) else video_duration
segs.append({'start': round(t, 2), 'end': round(end, 2), 'text': text})
return segs
最终输出(JSON,可以直接做全文检索):
json
[
{"start": 12.5, "end": 145.0, "text": "第一章 绪论\n1.1 研究背景"},
{"start": 145.0, "end": 320.5, "text": "数据规模的增长\n- 2020年: 10TB\n- 2024年: 500TB"}
]
这个结构可以直接:
- 建全文索引(Elasticsearch / PostgreSQL 全文检索,前面那篇讲过);
- 生成"视频内容大纲";
- 配合场景检测生成章节(第 25 篇)------文字变化往往对应 PPT 翻页,是很好的章节切分信号。
八、完整代码
python
import re
import subprocess
import tempfile
from pathlib import Path
import cv2
import numpy as np
from paddleocr import PaddleOCR
class VideoTextExtractor:
def __init__(self, lang='ch', sample_fps=0.5, dedup_threshold=6, scale=2.0):
self.ocr = PaddleOCR(use_angle_cls=True, lang=lang, show_log=False)
self.sample_fps = sample_fps
self.dedup_threshold = dedup_threshold
self.scale = scale
def extract(self, video_path):
duration = self._duration(video_path)
frames, stamps = self._sample_frames(video_path)
frames, stamps = dedup_frames(frames, stamps, self.dedup_threshold)
raw = []
for f, t in zip(frames, stamps):
proc = preprocess(f, self.scale)
items = ocr_frame_pp(proc, self.ocr)
if items:
raw.append((t, merge_lines(items)))
return to_segments(dedup_results(raw), duration)
def _duration(self, path):
out = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'csv=p=0', path], capture_output=True, text=True, check=True)
return float(out.stdout.strip())
def _sample_frames(self, path):
"""用 ffmpeg 抽帧到临时目录,再读入内存。"""
tmp = tempfile.mkdtemp(prefix='ocr_')
cmd = ['ffmpeg', '-v', 'error', '-i', path,
'-vf', f'fps={self.sample_fps}', '-q:v', '2',
str(Path(tmp) / '%06d.jpg')]
subprocess.run(cmd, check=True)
files = sorted(Path(tmp).glob('*.jpg'))
frames, stamps = [], []
step = 1.0 / self.sample_fps
for i, f in enumerate(files):
img = cv2.imread(str(f))
if img is None:
continue
frames.append(img)
stamps.append(i * step)
# 清理
for f in files:
f.unlink()
Path(tmp).rmdir()
return frames, stamps
def ocr_frame_pp(img, ocr):
result = ocr.ocr(img, cls=True)
if not result or not result[0]:
return []
return [(line[1][0], float(line[1][1]), line[0]) for line in result[0]]
if __name__ == '__main__':
import json
import sys
ex = VideoTextExtractor()
segs = ex.extract(sys.argv[1])
Path('scene_text.json').write_text(
json.dumps(segs, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'提取到 {len(segs)} 个文本片段')
性能:45 分钟视频,CPU 上约 3~6 分钟;有 GPU 的话 1 分钟内。
九、实测数据
测试素材:4 类,各 10 分钟。
| 类型 | 去重后帧数 | 准确率(字符级) | 耗时(CPU) |
|---|---|---|---|
| PPT 讲座 | 320 | 92% | 3.5 分钟 |
| 带动画的 PPT | 480 | 86% | 5 分钟 |
| 街景招牌 | 550 | 61% | 6 分钟 |
| 手写白板 | 400 | 32% | 4.5 分钟 |
结论:
- 规整的印刷体(PPT)效果很好(92%),可以实用;
- 场景文字(street view)明显下降(61%)------透视、遮挡、复杂背景;
- 手写字基本不可用(32%)。
所以我在方案里会明确写清楚适用范围 :这个方案适合"提取课件/PPT/印刷体内容",不适合手写和复杂场景文字。
准确率的定义:我用"字符级准确率"(识别正确的字符数 ÷ 总字符数)。对于检索用途,这个准确率已经足够(检索只要求"关键词出现在候选集里")。
十、坑清单
- 对每一帧做 OCR → 慢 100 倍。必须先去重。
- 去重阈值太小 → 去重不充分;太大 → 漏掉内容变化。实测标定。
- 不放大就 OCR → 小字识别率暴跌。放大 2 倍。
- 过度二值化 → 彩色背景上的文字被破坏。让引擎内部处理。
- 用 Tesseract 处理中文且没装 chi_sim → 识别成乱码或者空白。
- 把图表/纹理误识别成文字 → 用文字区域检测 + 置信度过滤(conf > 0.5)。
- 不做跨帧去重 → 同一页 PPT 重复输出几百次。
- 敏感内容传到云 OCR → 隐私风险。本地部署。
- PaddleOCR 首次运行慢 → 要下载模型(几百 MB)。预先下载好,或者做进镜像。
- 内存占用 → 一次读入几千帧会爆内存。流式处理(抽一批处理一批)。
- 临时帧文件占满磁盘 → 抽帧后要清理(用
finally)。 - 竖排文字 → 大部分引擎不支持竖排,会识别成乱序。需要特殊处理(检测后旋转)。
- 多语言混排 → lang 参数要包含所有语言(比如
ch+en)。 - 把字幕和画面文字搞混 → 如果只要字幕,裁底部区域单独处理(更准)。
- 准确率期望过高 → 场景文字是难问题。在方案里写清楚适用边界,别让客户以为能到 99%。
最后说说这个需求的本质。
客户说"想搜索视频里的内容",但真正的需求是**"让视频内容可被检索"**。OCR 只是实现路径之一,而且未必是最优的:
*本文由 VidDown(https://www.viddown.cn)支持
| 路径 | 适合 | 准确率 |
|---|---|---|
| 语音转写(Whisper) | 有语音讲解的内容 | 高(95%+) |
| OCR 画面文字 | PPT、课件、无声内容 | 中高(PPT 92%) |
| 人工标注/章节 | 高价值内容 | 最高,成本也最高 |
| 元数据(标题/标签) | 粗粒度检索 | 低 |
如果视频里有讲解音频,Whisper 转写的效果几乎总是比 OCR 好 (因为口语内容往往比屏幕文字更丰富)。所以我的第一个建议通常是:"先做语音转写,如果内容确实主要在画面上(比如无声演示),再补 OCR。"
两者结合更好:语音转写提供"讲了什么",OCR 提供"屏幕上写了什么"------对于课件类内容,这两者互补(老师讲的 often 比 PPT 上的多)。
最后一点:明确能力边界比夸大能力更重要 。我在方案里写"OCR 对印刷体 PPT 准确率约 90%,对手写内容不适用"------客户知道边界,就不会拿手写白板的录像来要求同样效果。期望管理是技术交付的一部分,这句话我在这个项目里又一次验证了。