视频字幕提取这件事,我前后折腾过好几轮,最早只会用 ffmpeg 敲一条命令把内嵌字幕流拉出来,后来遇到一堆“没有字幕流”的片源才发现,真正麻烦的是烧在画面里的硬字幕。这篇就围绕“用 ffmpeg 提取字幕和音频,ffmpeg 认不出字幕时用 OCR 兜底”这条主线,把整条流水线拆开讲清楚:从怎么判断一个字幕到底是软字幕还是硬字幕,到 ffprobe 探测、字幕流与音轨的提取参数,再到抽帧、区域裁剪、图像预处理、OCR 识别、时间轴重建和 SRT 输出的完整实现。内容偏实操,适合手里有一批视频需要批量转文字稿、做字幕归档、或者给视频做二次创作的朋友,新手能照着跑通,有经验的也能从参数取舍和踩坑记录里拿到点东西。
1. 先分清软字幕和硬字幕,方案才不会走错
1.1 为什么 ffmpeg 有时候“看不见”字幕
很多人第一次用 ffmpeg 提字幕,命令敲下去报“Stream map '0:s:0' matches no streams”,第一反应是命令写错了,其实大概率是这条视频压根没有字幕流。字幕在视频文件里有两种存在方式,差别非常大。
第一种是软字幕,也叫内挂字幕。它是独立于画面的一个数据流,躺在 MP4、MKV 这类容器里,和视频流、音频流并列。常见的编码格式有 subrip(SRT)、ass、webvtt,还有一些图形字幕格式比如 hdmv_pgs_subtitle、dvd_subtitle。软字幕的特点是文字是字符数据,ffmpeg 能直接读、直接转、直接抽出来,甚至能改字体改位置。
第二种是硬字幕,也叫烧录字幕。它是渲染进画面像素里的,对播放器来说那就是图像的一部分。ffmpeg 看到的只是一帧一帧的图片,里面有没有字、写的是什么,它完全不关心。这时候你说“提取字幕”,工具层面根本没有“字幕”这个概念可提,只能走图像识别。
所以整条流水线的第一个判断点就是:这条视频有没有可用的字幕流。有,走 ffmpeg 直接提取,几秒钟搞定;没有,才进入 OCR 分支。这个判断不做,后面全是白费功夫。
1.2 图形字幕流是个容易被忽略的中间态
还有一种情况特别容易让人误判:视频里确实有字幕流,但编码格式是 PGS 或者 VobSub。这类流在 ffprobe 里能看见,codec_type 也确实是 subtitle,但它存的是一张张位图,不是文字。你直接-c:s srt转,ffmpeg 要么报错,要么转出一堆空行。
这属于“有字幕流但仍然是图形”的情况,处理思路上更接近硬字幕 OCR,只不过素材从整帧画面变成了单独的字幕图片。我一般会先把这类流原样抽出来(.sup或.idx/.sub),再交给支持图形字幕的 OCR 工具处理,比直接对整帧画面做 OCR 干净得多,因为背景是透明的,没有画面干扰。
判断方法很简单,看 codec_name 就行:
| codec_name | 类型 | 处理方式 |
|---|---|---|
| subrip / ass / webvtt / mov_text | 文本字幕 | ffmpeg 直接转 SRT |
| hdmv_pgs_subtitle | 图形字幕 | 抽出 .sup 后 OCR |
| dvd_subtitle | 图形字幕 | 抽出 idx/sub 后 OCR |
| 无字幕流 | 硬字幕或无字幕 | 抽帧 + OCR |
| 画面内有文字但无字幕流 | 硬字幕 | 抽帧 + OCR |
1.3 OCR 兜底不是万能药,先想清楚代价
OCR 这条路的成本要提前有心理准备。一条两小时的视频,按每秒抽 2 帧算就是一万四千多张图,哪怕每张只花 0.1 秒,也是二十多分钟起步,还没算上误识别带来的人工校对。所以我的原则是:能用字幕流解决的,绝不走 OCR;必须走 OCR 的,先用抽帧和区域裁剪把工作量砍到最小。
另外 OCR 的准确率受很多东西影响:字幕字体、描边粗细、背景明暗、有没有半透明底、画面有没有剧烈运动、视频分辨率够不够。同一套参数在这部片子上识别率 95%,换一部可能掉到 70%。这也是为什么后面我会强调“预处理”和“后处理”,很多时候提升识别率的不是换更强的模型,而是把输入图像喂得更干净。
2. 整体流水线设计:四个阶段各管一段
2.1 从探测到输出的完整链路
我把整件事拆成四段,每段职责单一,出问题好定位:
- 探测阶段:用 ffprobe 摸清容器里有哪些流,重点看字幕流和音轨,顺便拿到分辨率、帧率、时长这些后面要用的参数。
- 提取阶段:有文本字幕流就直接转 SRT;有音轨就抽音频,按后续用途决定是原样拷贝还是转成 16kHz 单声道 WAV。
- OCR 兜底阶段:没有可用字幕流时,按固定帧率抽帧、裁出字幕区域、预处理、识别、按相似度合并成带时间轴的文本。
- 校验与输出阶段:统一 SRT 时间格式,清洗文本,做一遍基础纠错,输出 UTF-8 编码的
.srt,同时保留一份纯文本稿方便检索。
这四段之间是解耦的,每一段都能单独重跑。比如 OCR 识别率不理想,我只需要调第三段的参数重跑,前面的探测和音频提取结果直接复用,不用从头再来。这个设计在批量处理时特别省时间。
2.2 工具选型的几个实际考量
ffmpeg 和 ffprobe 没什么好犹豫的,视频处理的底座就是它,跨平台、命令行友好、能塞进脚本。OCR 引擎的选择就有讲究了:
- PaddleOCR:中文识别效果好,对横排字幕这种场景很合适,模型体积适中,CPU 也能跑,缺点是首次加载模型稍慢。
- Tesseract:老牌工具,装起来方便,英文识别稳,中文需要额外下语言包,对低对比度、带描边的字幕不如前者。
- EasyOCR:上手最快,几行代码就能跑,但依赖较重,推理速度一般。
我的建议是:中文内容优先用 PaddleOCR,纯英文或对部署体积敏感的场景用 Tesseract。没必要一上来就追求最强模型,字幕这种“黑底白字、字体规整、位置固定”的场景,其实对模型要求没那么高,预处理做得好,普通模型也能跑到 90% 以上。
还有一个容易被忽略的点:OCR 引擎的输入最好是单行文本、高度 30 像素以上、对比度高的图。如果你直接把整帧 1080p 画面丢进去,模型要自己找文字、自己切行,速度和准确率都会打折。所以裁剪和预处理不是可选项,是必选项。
3. ffmpeg 实测:探测、提字幕、抽音频
3.1 用 ffprobe 把容器里的东西看清楚
动手之前先看一眼文件里到底有什么,这一步能省掉后面 80% 的无效尝试。最直接的命令是:
ffprobe -v error -show_entries stream=index,codec_type,codec_name,width,height,r_frame_rate,duration -of json input.mp4输出是一段 JSON,重点看codec_type为subtitle的条目。如果一条都没有,说明没有字幕流,直接准备走 OCR。如果有,记下它的index和codec_name,下一步要用。
只想快速看字幕流的话,用这条更省事:
ffprobe -v error -select_streams s -show_entries stream=index,codec_name:stream_tags=language,title -of csv=p=0 input.mkv输出的第一列是流索引,第二列是编码格式,后面可能跟着语言和标题。多语言字幕的片子,靠language字段就能挑出你要的那条,不用一个个试。
注意:流索引和字幕流的序号不是一回事。
-map 0:s:0里的0是输入文件序号,s表示字幕类型,最后的0才是字幕流在“字幕类型内”的排序。如果文件里同时有0:v:0、0:a:0、0:s:0,它们的绝对索引可能是 0、1、2,混用会报错。
3.2 文本字幕提取与格式转换
确认是文本字幕后,提取就是一条命令的事:
ffmpeg -i input.mkv -map 0:s:0 -c:s srt output.srt这条命令的意思是:从输入文件里选第 0 条字幕流,用 SRT 编码器输出。ASS 转 SRT 会丢掉样式信息(字体、颜色、位置),但文本内容和时间轴是完整的,做文字稿够用。
如果要一次把所有字幕流都抽出来,把-map 0:s:0换成-map 0:s,输出文件名里带上流序号,否则会互相覆盖:
ffmpeg -i input.mkv -map 0:s -c:s srt output_%d.srt有个细节值得说:-c:s srt是重新编码,如果原字幕本身就是 SRT,可以直接-c:s copy原样拷贝,速度更快且不会有任何格式损失。判断方法是看 ffprobe 输出的codec_name是不是subrip。
还有一种情况是字幕流存在但内容为空,或者只有几条时间轴。转出来发现 SRT 里没几行字,那基本可以判定这条流是废的,老老实实走 OCR。
3.3 音频提取:用途决定参数
音频提取的参数完全取决于你拿它干什么。如果只是存档,原样拷贝最省事:
ffmpeg -i input.mp4 -vn -map 0:a:0 -c:a copy output.m4a-vn表示不要视频,-c:a copy表示音频流直接拷贝不重编码,速度极快。但注意,copy出来的格式跟原音轨一致,AAC 出来就是 m4a,AC3 出来就是 ac3,后续如果要做语音识别,很多工具对格式有要求。
要做语音转写或者音频分析,统一转成 16kHz、单声道、16 位 PCM 是最稳妥的:
ffmpeg -i input.mp4 -vn -map 0:a:0 -ac 1 -ar 16000 -c:a pcm_s16le output.wav为什么是 16kHz?语音识别模型训练时普遍用这个采样率,人声的主要能量集中在 300Hz 到 3400Hz,16kHz 采样已经能覆盖到 8kHz 的频带,足够还原语音特征,再高就是浪费算力和存储。为什么单声道?人声在立体声里通常是居中的,downmix 成单声道不会丢信息,反而能减少一半数据量。
提示:多音轨的视频记得先看清楚哪条是原声、哪条是配音。用
-map 0:a:1选第二条,别默认抓第一条,我吃过这个亏,抓了一条导演评论音轨,转出来全是闲聊。
3.4 图形字幕流的处理方式
PGS 字幕的处理要绕一下。先把它原样抽出来:
ffmpeg -i input.mkv -map 0:s:0 -c:s copy output.sup得到.sup文件后,每一帧字幕都是一张带透明通道的图。这时候有两种做法:一是用专门的图形字幕 OCR 工具直接处理.sup;二是用 ffmpeg 把它转成图片序列,自己走通用 OCR。
转图片序列的命令大概是:
ffmpeg -i output.sup -vsync 0 pgs/%06d.png出来的 PNG 是带 alpha 通道的,OCR 之前要把透明背景合成到黑色或白色底上,否则识别引擎会读出一堆乱码。合成命令:
ffmpeg -i pgs/%06d.png -vf "format=rgba,color=black:s=1920x1080[bg];[bg][0]overlay=format=auto" -frames:v 1 merged.png这条命令对单张图有效,批量处理建议写在脚本里循环。合成之后再做灰度、二值化,识别率会明显好于直接喂透明图。
4. OCR 兜底:把画面里的字“抠”出来
4.1 抽帧频率怎么定,时间轴才不飘
OCR 出来的文本要变成 SRT,关键在于时间轴。时间轴精度取决于抽帧频率:每秒抽 1 帧,时间误差最大 1 秒;每秒抽 2 帧,误差 0.5 秒;每秒抽 5 帧,误差 0.2 秒。
字幕的正常停留时间一般在 1 到 3 秒,人眼对 0.5 秒以内的偏差不敏感,所以每秒 2 帧是个性价比很高的起点。如果片源语速快、字幕切换频繁,可以提到 3 到 4 帧;如果是慢节奏的纪录片,1 帧也够。
抽帧命令:
ffmpeg -i input.mp4 -vf "fps=2,crop=1920:270:0:810" -q:v 2 frames/%06d.jpg这里crop=宽:高:x:y把画面底部 270 像素裁出来,对应 1080p 视频底部 25% 的区域。-q:v 2是 JPEG 质量参数,2 已经是高质量,再高对 OCR 没帮助,只会让文件变大。
裁剪区域不是拍脑袋定的,先用一两帧确认字幕位置:
ffmpeg -ss 00:05:00 -i input.mp4 -frames:v 1 -q:v 2 sample.jpg打开 sample.jpg 看一眼字幕在哪个高度、有没有上下黑边。如果视频有黑边,先用 cropdetect 把黑边去掉,否则你裁的区域可能一半是黑的:
ffmpeg -i input.mp4 -vf cropdetect=24:16:0 -f null - 2>&1 | tail -5输出的crop=1920:800:0:140这类信息就是有效画面区域,把它作为后续裁剪的基础。
注意:不同片源的字幕位置可能不一样,有的在底部居中,有的带双语上下两行,有的甚至放在画面顶部。批量处理前一定抽查几帧,别拿一套参数硬套所有文件。
4.2 图像预处理:识别率的分水岭
原始帧直接丢给 OCR,识别率通常不理想,原因有三个:背景太花、字幕对比度不够、文字高度太小。预处理就是针对这三点做文章。
第一步是灰度化,把三通道压成单通道,减少干扰:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)第二步是二值化。字幕通常是白字黑边,用 Otsu 自适应阈值效果不错:
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)但如果背景是亮色(比如雪景、白墙),白字会被背景吞掉,这时候要做反相判断:统计二值图里白色像素占比,如果超过 80%,说明背景被当成了前景,把图反相再识别。
第三步是放大。OCR 模型对文字高度有要求,一般 30 像素以上比较稳。如果原视频是 720p,裁出来的字幕高度可能只有 20 像素,用cv2.resize放大 2 倍,配合INTER_CUBIC插值,识别率会有肉眼可见的提升:
scale = 2.0 resized = cv2.resize(binary, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)第四步是去噪和形态学处理。字幕描边有时候会被识别成杂点,用一次开运算(先腐蚀后膨胀)能把孤立的小白点去掉:
kernel = np.ones((2, 2), np.uint8) cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)这套流程跑下来,同样是 PaddleOCR,识别率能从 70% 出头提到 90% 左右。预处理的价值就在这,它不改变模型,只是把输入变得更“像训练数据”。
4.3 相邻帧去重与时间轴合并
抽帧抽出来的是“每一帧上有什么字”,而 SRT 需要的是“从第几秒到第几秒显示什么字”。中间这层转换靠的是相似度合并。
思路很直接:维护一个“当前字幕块”,记录文本、起始帧号、结束帧号。每识别一帧,就跟当前块的文本比一下相似度,超过阈值(我一般用 0.85)就认为是同一条字幕,只更新结束帧号;低于阈值就说明字幕换了,把当前块收尾,开一个新块。
相似度算法用difflib.SequenceMatcher就够,不用上复杂的模型:
from difflib import SequenceMatcher def similar(a, b): return SequenceMatcher(None, a, b).ratio()这里有个坑:OCR 对同一行字幕的识别结果不会完全一致,可能这帧识别成“我们今天去哪里”,下一帧识别成“我们今天去哪里?”,标点差异会让相似度掉到 0.9 以下。所以比较之前要先做归一化:去掉标点、统一全半角、去掉首尾空白。
还有一个坑是字幕闪烁。有些片源的字幕中间会有一两帧空白(转场、特效导致),如果严格按“空帧就结束”处理,一条字幕会被切成两三条。解决办法是设一个容忍窗口,连续空帧不超过 2 帧就认为字幕还在,把中间的空洞补上。
合并逻辑用伪代码表达大概是这样:
for each frame: text = ocr(frame) if text is empty: empty_count += 1 if empty_count > 2: close_current_block() continue empty_count = 0 if current_block is None: current_block = new_block(text, frame_idx) elif similar(normalize(text), normalize(current_block.text)) > 0.85: current_block.end = frame_idx current_block.text = vote([...]) # 多帧投票取最优 else: close_current_block() current_block = new_block(text, frame_idx)多帧投票这一步值得单独说。同一条字幕可能被识别成好几个版本,与其用最后一帧的结果,不如把所有版本收集起来,选出现次数最多的那个,或者选长度最接近中位数的那个。实测下来,投票比“取最后一帧”准确率高一截,因为单帧的偶然误差被平均掉了。
5. 完整实现:一套能直接跑的脚本
5.1 目录结构与依赖
我习惯把这类任务按“输入/中间产物/输出”分目录,重跑的时候只清中间目录,不碰原始文件:
project/ ├── input/ # 原始视频 ├── frames/ # 抽出来的帧 ├── audio/ # 提取的音频 ├── subtitle/ # 直接提取的字幕 ├── output/ # 最终 SRT 和文本稿 └── pipeline.py依赖就三个:ffmpeg(系统安装)、opencv-python、paddleocr或pytesseract。装的时候注意版本,OpenCV 4.x 和 PaddleOCR 的兼容性总体没问题,但如果你用的是很老的 Python 版本,可能会卡在依赖上,建议 3.9 以上。
5.2 主流程脚本
下面这套是简化版,把探测、分支、提取、OCR 串起来了,实际用的时候按需补异常处理:
import subprocess, json, os, cv2, re from difflib import SequenceMatcher from paddleocr import PaddleOCR def probe(path): cmd = ["ffprobe", "-v", "error", "-show_entries", "stream=index,codec_type,codec_name", "-of", "json", path] out = subprocess.check_output(cmd).decode("utf-8") return json.loads(out)["streams"] def extract_text_subtitle(path, idx, out_path): cmd = ["ffmpeg", "-y", "-i", path, "-map", f"0:s:{idx}", "-c:s", "srt", out_path] subprocess.run(cmd, check=True) def extract_audio(path, out_path): cmd = ["ffmpeg", "-y", "-i", path, "-vn", "-map", "0:a:0", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", out_path] subprocess.run(cmd, check=True) def extract_frames(path, out_dir, fps=2, crop="1920:270:0:810"): os.makedirs(out_dir, exist_ok=True) cmd = ["ffmpeg", "-y", "-i", path, "-vf", f"fps={fps},crop={crop}", "-q:v", "2", f"{out_dir}/%06d.jpg"] subprocess.run(cmd, check=True) def preprocess(img_path): img = cv2.imread(img_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) if binary.mean() > 200: binary = cv2.bitwise_not(binary) binary = cv2.resize(binary, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) return binary def normalize(text): text = re.sub(r"[^\w\u4e00-\u9fa5]", "", text) return text.strip() def ocr_frames(frame_dir, ocr, fps): files = sorted(os.listdir(frame_dir)) blocks, cur, empty = [], None, 0 for i, f in enumerate(files): res = ocr.ocr(preprocess(os.path.join(frame_dir, f)), cls=False) text = "" if res and res[0]: text = " ".join([line[1][0] for line in res[0]]) t = i / fps if not text.strip(): empty += 1 if empty > 2 and cur: blocks.append(cur); cur = None continue empty = 0 if cur is None: cur = {"start": t, "end": t, "texts": [text]} elif SequenceMatcher(None, normalize(text), normalize(cur["texts"][-1])).ratio() > 0.85: cur["end"] = t cur["texts"].append(text) else: blocks.append(cur) cur = {"start": t, "end": t, "texts": [text]} if cur: blocks.append(cur) return blocks def to_srt(blocks, fps, out_path): def fmt(s): h, m = int(s // 3600), int(s % 3600 // 60) sec, ms = int(s % 60), int((s % 1) * 1000) return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}" lines = [] for i, b in enumerate(blocks, 1): text = max(set(b["texts"]), key=b["texts"].count) end = b["end"] + 1.0 / fps lines.append(f"{i}\n{fmt(b['start'])} --> {fmt(end)}\n{text}\n") with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) if __name__ == "__main__": video = "input/demo.mp4" streams = probe(video) subs = [s for s in streams if s["codec_type"] == "subtitle"] text_subs = [i for i, s in enumerate(subs) if s["codec_name"] in ("subrip", "ass", "webvtt", "mov_text")] if text_subs: extract_text_subtitle(video, text_subs[0], "subtitle/out.srt") else: extract_audio(video, "audio/out.wav") extract_frames(video, "frames") ocr = PaddleOCR(use_angle_cls=False, lang="ch", show_log=False) blocks = ocr_frames("frames", ocr, fps=2) to_srt(blocks, 2, "output/out.srt")这段代码不是拿来即用的成品,但骨架是完整的,缺的是异常处理、进度打印和断点续跑。真正跑批量任务的时候,我会在每个阶段加一个状态文件,记录已经处理到第几帧,中断了能接着跑,不用从头 OCR。
5.3 结果校验的几个动作
SRT 生成之后别急着交差,至少做三件事:
第一,看时间轴是否单调递增。合并逻辑写错的时候会出现后一条的开始时间早于前一条的结束时间,播放器会显示错乱。写个小脚本扫一遍,发现异常就报警。
第二,看有没有超长行。一条字幕超过 30 个字基本可以判定是两行没切开,或者把画面里的其他文字(比如台标、水印)也识别进去了。
第三,抽查首尾和中间各十秒。OCR 的准确率在片头片尾往往最差,片头有标题、片尾有演职员表,这些都不是字幕,容易混进来。抽几段对着原片看,比跑任何自动指标都直观。
6. 常见问题与排查速查表
6.1 提取阶段的典型故障
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 报错 matches no streams | 没有对应类型的流 | 先用 ffprobe 确认流信息 |
| 转出的 SRT 是空文件 | 字幕流是图形格式 | 改走 PGS/VobSub OCR 分支 |
| 字幕乱码 | 编码不是 UTF-8 | 加-sub_charenc GBK指定源编码 |
| 只提取到部分字幕 | 选了错误的流索引 | 用-map 0:s全部导出再挑 |
| 音轨提取出来是静音 | 选到了评论音轨 | 用 ffprobe 看音轨语言和标题 |
| 抽帧速度极慢 | 用了高分辨率 + PNG | 换 JPG、降-q:v、加-threads |
编码问题值得单独说一下。老一些的 MKV 字幕是 GBK 编码,ffmpeg 默认按 UTF-8 解,出来就是乱码。正确的做法是在输入前指定字符集:
ffmpeg -sub_charenc GBK -i input.mkv -map 0:s:0 -c:s srt out.srt如果字幕本身编码搞不清楚,可以用ffprobe -show_entries stream_tags=encoding看看有没有标注,没有的话就只能试,GBK 和 UTF-8 各试一次,哪个不乱用哪个。
6.2 OCR 阶段的高频坑
坑一:字幕区域裁错了。表现是识别出来全是画面里的招牌、路牌,或者一行字被切成上下两半。原因是裁剪的 y 坐标偏了。解决办法是抽一帧画个框看看,或者用行投影自动找字幕行:把裁剪区域做水平投影,统计每一行的白色像素数,字幕行会有明显的峰值。
坑二:背景太亮导致白字消失。前面提过,Otsu 二值化在亮背景下会把背景当前景。除了反相判断,还可以先做一次自适应阈值,对局部对比度更敏感:
binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)坑三:双语字幕只识别到一行。上下两行字幕在裁剪区域里都包含,但 OCR 有时候只返回一行。这时候要么把裁剪区域按高度一分为二分别识别,要么把图放大后让引擎自己分行。前者更稳,代价是要多跑一次识别。
坑四:识别结果里混进标点噪声。字幕描边、画面噪点会被识别成·、|、_这类字符。后处理阶段用正则清一遍就行,但别清太狠,中文的顿号、引号该留还得留。
6.3 时间轴与合并的疑难杂症
最常见的是字幕粘连:两条相邻字幕被合并成一条,原因是相似度阈值定太高,或者两条字幕文本确实很像(比如“你好”“你好啊”)。阈值调到 0.85 到 0.9 之间比较平衡,太低会切碎,太高会粘连。遇到特别短的对话,可以加一个规则:如果当前块持续时间超过 5 秒还在延续,强制切一刀。
第二个是时间轴整体偏移。抽帧时用了-ss做 seek,如果放在-i前面是快速定位,可能不准;放在后面是精确解码,但慢。批量处理时建议不用-ss,直接从 0 开始抽,时间轴按帧号算就不会偏。
第三个是结尾字幕被截断。最后一条字幕的结束时间只加了一个帧间隔,实际字幕可能还停留了一两秒。简单处理是给最后一条统一加 1.5 秒,或者按全片字幕平均时长补。
7. 实操心得:速度、准确率和人工成本的三角平衡
7.1 把 OCR 调用量砍下来的两个技巧
第一个是帧差预筛。相邻两帧如果画面几乎没变化,那字幕大概率也没变,没必要重复 OCR。做法是把当前帧和上一帧的字幕区域做灰度差,平均差值低于阈值就直接沿用上一帧的识别结果,只更新时间轴。实测在对话类视频上,这一步能省掉 70% 到 80% 的 OCR 调用。
diff = cv2.absdiff(prev_gray, cur_gray).mean() if diff < 3.0: reuse_last_result() else: run_ocr()阈值 3.0 是个经验值,画面有轻微压缩噪声时也能扛住,但遇到淡入淡出的转场会误判,所以最好再加一个“连续复用不超过 N 帧”的保护。
第二个是降分辨率但不降字高。OCR 的速度和输入图尺寸强相关,把 1080p 的帧缩到 720p 能快不少,但前提是字幕高度还在 30 像素以上。如果原片字幕本来就小,宁可放大也不要缩小,识别率比速度重要。
7.2 人工校验怎么安排最省力
全自动跑完直接发布,风险很大。我的做法是分层校验:自动生成一份“低置信度清单”,把识别结果长度异常、相似度波动大、出现罕见字符的字幕条目标出来,人工只看这部分,通常占总量的 10% 到 20%。剩下的高置信度内容抽查即可。
另外,如果视频本身有对应的音频,可以用语音识别结果做交叉验证。OCR 出来是“我们在明天出发”,语音识别出来是“我们在明天出发”,两边一致就基本可信;不一致的挑出来人工听一遍。这个方法听着麻烦,但对重要内容来说,比逐条看字幕快得多。
7.3 什么情况下该放弃 OCR
说句实在话,不是所有视频都值得走 OCR。如果字幕字体花哨、描边极粗、背景剧烈运动,识别率可能只有 60% 出头,校对成本比重新打一遍还高。这种情况我会先评估:这条视频的文字稿到底有多大价值?如果只是内部参考,低准确率也能用;如果要对发布,那还不如找现成的字幕文件,或者干脆人工听打。
还有一个边界:画面里的文字不一定是字幕。新闻滚动条、体育比分、游戏 UI、MV 歌词特效,这些东西 OCR 都会识别出来,但它们不是对白字幕。批量处理前先明确目标,别把整段画面文字都当成字幕导出来,后面清洗起来很痛苦。
我个人在实际操作中的体会是,这条流水线真正的价值不在于“全自动”,而在于把重复劳动压缩到一个可接受的范围内。ffmpeg 负责确定性强的部分,OCR 负责模糊的部分,人负责判断的部分,三者各干各的,别指望任何一环做到 100%。参数调优的时候也别追求极致,识别率从 88% 提到 91% 可能要花掉一倍的时间,那点提升还不如留给人工校对。