提示

返回博客列表

从视频画面里提取文字:OCR、文字检测与结构化输出

客户有一批讲座录像(每节 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)比文档扫描难得多。

目录

一、先说清楚:这不是"字幕提取"

这两个任务经常被混为一谈,但技术路线完全不同:

字幕提取 画面文字提取(本文)
目标 字幕轨(或烧录在底部的字幕) 画面里任意位置的文字(PPT、招牌、图表、书名)
位置 固定(底部) 任意
特点 排版规整、高对比 可能有透视、遮挡、复杂背景
难度 低 高(这叫 scene text recognition)

如果只需要字幕,优先用:

  1. 有没有字幕轨(ffprobe 看有 subtitle 流)→ 直接提取;
  2. 硬字幕 → 裁剪底部区域 + OCR(这样准确率高得多);
  3. 都没有 → 用 Whisper 转录音频,比 OCR 硬字幕准确。

本文处理的是"画面里任意位置的文字"——这个更难。

二、完整流程

视频
  ↓ 1. 抽帧(每 N 秒一帧,降采样)
  ↓ 2. 去重(dHash 比较,去掉重复帧)     ← 性能优化的关键
  ↓ 3. 预处理(放大、增强对比、去噪)
  ↓ 4. 文字区域检测(可选,定位文字在哪)
  ↓ 5. OCR
  ↓ 6. 后处理(跨帧去重、合并、按时间聚合)
  ↓
结构化结果:[{t: 12.5, text: "..."}, ...]

三、抽帧与去重:性能的关键

抽帧:对 PPT / 静态内容,每 2 秒一帧足够(fps=0.5)。对动态内容(比如街景),可以更密。

ffmpeg -i input.mp4 -vf fps=0.5 -q:v 2 frames/%06d.jpg

去重:用 dHash(前面视频指纹那篇讲过):

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 之后识别率提升明显。

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

几个注意点:

  1. 不要过度二值化。自适应阈值对"白底黑字"很好,但对"彩色背景"会把文字搞没。我一般不二值化,让 OCR 引擎自己处理(现代引擎内部会做)。
  2. 不要锐化(会产生振铃,OCR 引擎会误判);
  3. 降噪要轻(fastNlMeansDenoising 的 h 值 7 左右,太大字会糊)。

如果知道文字在固定区域(比如 PPT 主体区域),先裁剪再 OCR——去掉周围的无关内容(比如视频控件、浏览器边框)能显著提升准确率。

五、OCR 引擎怎么选

引擎 中文效果 速度 部署 说明
PaddleOCR 好 快(有 GPU 更快) pip 安装,模型需下载 中文场景推荐
Tesseract 5 一般 中 系统包 + 语言数据 英文好,中文需 chi_sim
EasyOCR 好 慢 pip,模型大 多语言方便
云 API(各厂商) 最好 快(网络) 调用 API 准确但有成本 + 隐私问题

我的选择:

  • 中文内容 + 本地部署 → PaddleOCR(开源、中文模型质量好);
  • 英文为主 → Tesseract(够用、依赖简单);
  • 准确率要求极高 + 不涉及敏感内容 → 云 API;
  • 敏感内容(客户资料、内部录像)→ 必须本地(不要传到云)。

PaddleOCR 示例:

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 示例:

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 检测器(独立方案):

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. 帧内合并:把同一帧里的多行文字按位置排序(从上到下、从左到右),合并成一段。

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 的不同帧)。

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. 按时间聚合:输出"每一段时间的画面内容是什么"。

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,可以直接做全文检索):

[
  {"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 翻页,是很好的章节切分信号。

八、完整代码

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/印刷体内容",不适合手写和复杂场景文字。

准确率的定义:我用"字符级准确率"(识别正确的字符数 ÷ 总字符数)。对于检索用途,这个准确率已经足够(检索只要求"关键词出现在候选集里")。

十、坑清单

  1. 对每一帧做 OCR → 慢 100 倍。必须先去重。
  2. 去重阈值太小 → 去重不充分;太大 → 漏掉内容变化。实测标定。
  3. 不放大就 OCR → 小字识别率暴跌。放大 2 倍。
  4. 过度二值化 → 彩色背景上的文字被破坏。让引擎内部处理。
  5. 用 Tesseract 处理中文且没装 chi_sim → 识别成乱码或者空白。
  6. 把图表/纹理误识别成文字 → 用文字区域检测 + 置信度过滤(conf > 0.5)。
  7. 不做跨帧去重 → 同一页 PPT 重复输出几百次。
  8. 敏感内容传到云 OCR → 隐私风险。本地部署。
  9. PaddleOCR 首次运行慢 → 要下载模型(几百 MB)。预先下载好,或者做进镜像。
  10. 内存占用 → 一次读入几千帧会爆内存。流式处理(抽一批处理一批)。
  11. 临时帧文件占满磁盘 → 抽帧后要清理(用 finally)。
  12. 竖排文字 → 大部分引擎不支持竖排,会识别成乱序。需要特殊处理(检测后旋转)。
  13. 多语言混排 → lang 参数要包含所有语言(比如 ch + en)。
  14. 把字幕和画面文字搞混 → 如果只要字幕,裁底部区域单独处理(更准)。
  15. 准确率期望过高 → 场景文字是难问题。在方案里写清楚适用边界,别让客户以为能到 99%。

最后说说这个需求的本质。

客户说"想搜索视频里的内容",但真正的需求是"让视频内容可被检索"。OCR 只是实现路径之一,而且未必是最优的:

路径 适合 准确率
语音转写(Whisper) 有语音讲解的内容 高(95%+)
OCR 画面文字 PPT、课件、无声内容 中高(PPT 92%)
人工标注/章节 高价值内容 最高,成本也最高
元数据(标题/标签) 粗粒度检索 低

如果视频里有讲解音频,Whisper 转写的效果几乎总是比 OCR 好(因为口语内容往往比屏幕文字更丰富)。所以我的第一个建议通常是:"先做语音转写,如果内容确实主要在画面上(比如无声演示),再补 OCR。"

两者结合更好:语音转写提供"讲了什么",OCR 提供"屏幕上写了什么"——对于课件类内容,这两者互补(老师讲的 often 比 PPT 上的多)。

最后一点:明确能力边界比夸大能力更重要。我在方案里写"OCR 对印刷体 PPT 准确率约 90%,对手写内容不适用"——客户知道边界,就不会拿手写白板的录像来要求同样效果。期望管理是技术交付的一部分,这句话我在这个项目里又一次验证了。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部