ffmpeg视频字幕提取与硬字幕OCR处理流程 做视频处理这一行久了总会遇到把视频里的文字扒出来这类需求拿到一份素材要整理成文稿、要给老片子补字幕、要做内容检索。很多人的第一反应是打开某个在线工具上传或者直接上 OCR 硬啃画面。前一种方式受限于文件大小和隐私后一种方式则是在拿最贵的手段解决最便宜的问题。我自己的固定套路是先用 ffmpeg 把能直接拿的东西拿到手包括内封字幕轨和独立音频只有当 ffmpeg 明确告诉你这里面没有可读的字幕流时才切换到 OCR 去识别画面上的硬字幕。这条路线看起来朴素但能省掉八成的算力也能避开很多时间轴对齐的麻烦。下面就把这套流程从判断、提取、分流到 OCR 补救的完整链路拆开讲适合手上有批量视频要处理、又不想依赖第三方服务的人参考。1. 先判断字幕的形态再决定要不要动用 OCR动手之前必须先建立一个基本认知视频里的字幕根本不是一个东西而是至少四种完全不同的存在形式它们的处理成本差了不止一个数量级。跳过判断直接上 OCR是这个领域最常见的时间浪费。1.1 软字幕、图形字幕、硬字幕、外挂字幕的本质区别内封文本字幕是最理想的情况。它作为一条独立的流存在于容器里本质是一堆带时间戳的字符串ffmpeg 一条命令就能原样导出零损失、零识别误差。常见的编码有 subrip也就是 SRT、ass/ssa、mov_text、webvtt。内封图形字幕稍微麻烦一点。它同样是独立的流但存的是位图而不是文本每一条字幕都是一张带透明通道的图片附带显示时间。典型代表是蓝光 rip 里常见的 PGS以及老 DVD 里的 VobSub。ffmpeg 能把它完整抽出来但抽出来的是图片序列还得走 OCR只不过这时的 OCR 输入质量极高——纯色背景加锐利边缘识别准确率远高于从画面上抠。硬字幕才是真正烧死在像素里的那种。它不存在于任何流中就是画面的一部分。判断依据很简单用播放器关掉所有字幕轨文字还在那就是硬的。这种情况没有捷径只能抽帧加 OCR。外挂字幕则是与视频同名的独立文件压根不需要 ffmpeg 参与改个文件名对齐就行。合理的判断顺序应该是先看目录里有没有同名外挂文件再用 ffprobe 查内封流确认没有文本流之后再看有没有图形流最后才认定是硬字幕。这个顺序每往下走一步成本大约翻三到五倍。1.2 用 ffprobe 一次性摸清家底不要靠播放器界面上字幕菜单里列了几项来判断那个列表经常把默认轨和强制轨混在一起显示。直接用 ffprobe 输出结构化信息最可靠ffprobe -v error -show_entries \ streamindex,codec_type,codec_name:stream_tagslanguage,title,forced \ -of json input.mkv关键看三件事codec_type为subtitle的流有几条、每条的codec_name是什么、tags里的 language 和 title 标了什么。这里有个老坑不少压制组会把语言标签写成undundeterminedtitle 也留空于是你看到三条字幕流却完全不知道哪条是中文。这时候可以看流的顺序和disposition字段通常第一条是正片字幕后面几条是评论轨或听力障碍轨SDH。更土但有效的办法是每条都抽一小段出来看内容。还有一点值得提醒-show_entries stream...只列流信息不会告诉你容器里有没有附件字体。ASS 字幕如果依赖特定字体提取出来的文本没问题但后期重新渲染时样式会崩需要另外用-map 0:t把附件字体一起抽出来。1.3 编码名字背后对应着什么处理方式把常见的codec_name和处理路线对应起来做成一张表贴在工位上会省很多事codec_name类型能否直接转 SRT处理建议subrip文本可以直接-c:s srt导出ass / ssa文本可以但会丢样式先导 ASS 存档再转 SRT 供检索mov_text文本可以MP4 内常见注意长度截断问题webvtt文本可以网页来源视频常见hdmv_pgs_subtitle图形不可以抽出 .sup 后走图像 OCRdvd_subtitle图形不可以抽出后走图像 OCRdvb_subtitle图形部分可转视 ffmpeg 版本而定特别注意 mov_text。MP4 容器对这条流有长度限制超过一定字节数的单条字幕会被截断表现就是导出的 SRT 里某些长句莫名其妙少了一截。遇到过几次之后我的做法是先用-c:s copy抽成 .m4v 或直接重封装到 MKV再做转换能规避掉大部分截断。2. 软字幕提取map 参数写错的概率比想象中高确认存在文本字幕流之后提取本身只是一条命令的事。但这条命令里的-map参数是整个流程里最容易出错的地方尤其是批量处理时一旦索引错位你会得到一堆内容对不上的字幕文件而且这种错误在单个文件上很难被发现。2.1 流索引与类型索引两套编号别混用ffmpeg 的-map支持两种定位方式。-map 0:s:0里的s表示 subtitle 类型后面的数字是该类型内部的序号从 0 开始-map 0:2里的数字则是全局流索引就是 ffprobe 输出里那个index字段。这两套编号完全不同混用必错。批量处理时我强烈建议用全局流索引。原因是0:s:0依赖第几条字幕流这个相对位置如果某个文件的音频流数量不同虽然不影响字幕相对序号但你很难在日志里直观核对到底抽的是哪条而全局索引是绝对的ffprobe 报index: 3你就-map 0:3出问题时一眼能对回去。命令长这样ffmpeg -v error -i input.mkv -map 0:3 -c:s srt -f srt output.zh.srt-c:s srt是重新编码成 SRT-f srt指定输出格式。如果只是想原样保留 ASS 的样式信息把-c:s srt换成-c:s copy输出扩展名改成 .ass 即可。2.2 文本流和图形流必须走两条完全不同的路这一点是分流的核心。文本流直接转 SRT图形流绝对不能加-c:s srt因为 ffmpeg 没有内置把位图字幕转成文本的能力加了会直接报错退出或者更糟——生成一个空文件然后返回 0批处理脚本以为成功了。图形字幕的正确处理是原样复制出来ffmpeg -v error -i input.mkv -map 0:4 -c:s copy output.supPGS 抽成 .sup 通常没问题。VobSub 直接抽成 .idx/.sub 组合在某些版本上会失败稳妥做法是先-c:s copy重封装成一个只含字幕的 MKV再用专门的工具拆。我在实际项目里更倾向于统一重封装成 MKV因为后续无论接哪种 OCR 工具MKV 里的图形字幕轨都能被直接读取省掉一次格式转换。判断某个文件该走哪条路不要凭经验猜。写脚本时先跑一遍 ffprobe把codec_name读出来用一个映射表决定分支这比在代码里硬编码MKV 一定是文本字幕靠谱得多。2.3 批量任务的目录结构和失败清单单文件处理怎么都行批量处理必须先把目录规范定下来否则跑到一半你就分不清哪个输出对应哪个输入了。我的习惯是按源文件名建子目录work/ ├── 001_source/ │ ├── video.mkv │ ├── sub_00.zh.srt │ ├── sub_01.en.srt │ ├── audio_00.wav │ └── meta.json └── 002_source/ └── ...每个目录里放一份meta.json记录 ffprobe 的原始输出和每一步的处理结果。这份文件在排查问题时价值极高——过两周回头看你根本记不住某个文件当时到底是因为没有字幕流被跳过还是因为提取报错被跳过。失败清单要单独落盘。批处理脚本里最忌讳的是try: ... except: pass出了问题静悄悄。我的做法是捕获 ffmpeg 的返回码非零就写进failed.txt同时记录 stderr 的最后二十行。跑完之后先看这个文件通常能发现一类系统性问题比如某个特定编码格式全军覆没。3. 音频抽取采样率和声道比编码格式更值得较真音频这条线的目的和字幕不同。字幕是为了直接得到文字音频是为了给语音识别模型当输入。既然最终喂给模型那么中间过程的存储格式就没那么重要反而是采样率、声道数、分段方式这些参数会直接影响识别效果和后续时间轴对齐。3.1 先抽成 WAV 再送识别别图省事直接读 MP4有人会问很多语音识别库能直接读 MP4为什么还要多一步抽 WAV两个理由。第一容器里音频编码五花八门AAC、AC3、DTS、Opus不同解码器对同一份数据的输出可能有细微差异抽成 PCM 之后所有下游环节面对的都是同一种东西行为可预期。第二抽 WAV 的同时可以把声道、采样率这些参数一次性归一化避免下游代码里到处写条件判断。命令很短ffmpeg -v error -i input.mkv -map 0:a:0 -vn \ -ac 1 -ar 16000 -c:a pcm_s16le audio_00.wav-vn是保险防止某些文件里音频流被误映射。-map 0:a:0取第一条音频流如果是多语言视频记得先确认哪条是目标语言。3.2 16kHz 单声道不是随手写的数字这个组合有明确的来由。主流语音识别模型在训练阶段普遍使用 16kHz 采样率输入采样率不匹配时要么库内部帮你重采样多一次有损处理要么直接报错。既然最终都要到 16k不如在 ffmpeg 这一步就用高质量重采样器一次做到位。单声道则是因为人声的能量主要集中在中频左右声道的内容在绝大多数影视素材里差异极小降到单声道能直接砍掉一半文件体积和处理时间几乎没有信息损失。但有一个例外如果你后续打算做说话人分离或者素材本身是双人对话分别录在左右声道的某些访谈素材确实这么做那就必须保留立体声否则两个说话人会混成一个人。pcm_s16le是 16 位小端有符号整型兼容性最好。不要用pcm_f32le体积翻倍而识别效果没有任何提升。3.3 长音频必须切段而且切法有讲究现在的识别模型大多对单次输入长度有上限一小时的原声直接丢进去轻则内存爆掉重则内部自动截断且不告诉你截在哪。所以切段是必需的。切段有两个参数要定段长和重叠。段长我一般取 30 秒这个长度既能保证模型有足够的上下文做语言判断又不会让单段处理时间过长。重叠取 0.5 秒目的是避免正好有一个词被切在边界上两边各识别出半个字。重叠段的识别结果在合并时按时间戳去重。用 ffmpeg 的segment复用器可以一条命令切完ffmpeg -v error -i audio_00.wav -f segment -segment_time 30 \ -segment_format wav seg_%04d.wav注意这个方式切出来的段之间没有重叠。要重叠就得手动算时间点逐段切或者用-af asplit之类的滤镜组合操作起来比较绕。我的实际做法是先用 segment 无重叠切分识别完成后再检查相邻段落的边界词如果发现疑似断词单独把那 2 秒范围重切一次补上。这样比全程做重叠要省一半算力。3.4 时间轴偏移是怎么来的切段时如果用-ss参数定位务必把它放在-i之后。放在前面是快速定位会跳到最近的关键帧实际起点和你要求的时间有偏差而且这个偏差会在每一段上累积最后整条字幕轨整体提前或延后。放在-i之后是精确定位代价是解码速度慢一点但对字幕来说这点精度是必须的。另外合并各段识别结果时每段的时间戳都要加上该段的起始偏移量。这个起始偏移一定要用 ffmpeg 实际输出的段时长去累加不要用i * 30这种理想值去算因为 WAV 的采样点对齐会导致每段实际长度有几十毫秒的浮动累积几十段之后误差就很可观了。4. ffmpeg 提不出字幕的三种情形判定方法各不同走到这一步说明文本字幕流没找到。但提不出有很多种原因处理方式完全不同。盲目上 OCR 之前先把原因定位清楚能省下大量无效算力。4.1 压根没有字幕轨最常见也最容易被忽略。ffprobe 输出里codec_type为 subtitle 的条目一条都没有那就是真的没有。这种情况下不要急着 OCR先去源站或者发布页找找有没有外挂字幕很多时候只是没打包进去。找一个现成的 SRT比自己跑一遍 OCR 的准确率高一个数量级。确认方法很简单数一下 json 里 subtitle 类型的流数量ffprobe -v error -select_streams s -show_entries streamindex \ -of csvp0 input.mkv | wc -l输出 0 就是没有。4.2 有流但提取失败这种情况更有迷惑性。ffprobe 明明列出了 subtitle 流但-c:s srt报错。常见原因有三个流是图形字幕前面讲过不能转文本、流的编码 ffmpeg 这个版本不支持、文件本身有损坏导致该流读不出来。区分方法看报错信息。如果提示Subtitle encoding currently only possible from text to text那就是图形字幕走 OCR但是高质量图源 OCR。如果提示Decoder not found或者Invalid data found when processing input那多半是编码不支持或数据损坏可以先试-c:s copy看能不能原样抽出抽不出来就基本没救只能走画面 OCR。还有一种隐蔽情况流存在但内容为空。某些压制工具会生成一条空的字幕轨作为占位ffprobe 看得见抽出来是个 0 字节文件。脚本里必须检查输出文件大小小于 100 字节直接判定失败。4.3 硬字幕唯一的判定标准是关掉字幕还在这个前面提过但值得再强调一次判定方法因为误判的代价是一整轮 OCR。最可靠的判定是抽几帧画面下来直接看。挑视频中间位置的三到五个时间点每个点抽一帧ffmpeg -v error -ss 00:10:00 -i input.mp4 -frames:v 1 check1.jpg抽出来的图看底部有没有文字。这一步不能靠程序判断必须人眼看因为有些画面本身就有台标、滚动条、角标自动化判断很容易误报。如果批量处理量大可以写个半自动的辅助对底部 20% 区域做边缘检测统计高对比度像素的密度密度持续高于阈值就标红送人工确认。这个方法我用了挺久能过滤掉大部分没字幕的文件但阈值定得太灵敏会把带台标的素材全标进去需要按素材来源调。5. 硬字幕 OCR抽帧策略比 OCR 引擎更影响结果真正需要 OCR 的时候决定最终质量的往往不是用了哪个识别引擎而是喂进去的图长什么样、抽了多少张、怎么去重。引擎选型在开源方案里其实差别不大但抽帧策略选错再好的引擎也救不回来。5.1 按固定时间间隔抽帧不要按帧率抽一个 25fps 的十分钟视频如果按帧率抽就是 15000 张图而字幕的切换频率通常是每 1 到 3 秒一次也就是最多 600 条。99% 的图是重复的去重阶段会直接卡死。合理的抽帧频率取决于字幕切换的最短间隔。对话密集的素材里一句短台词可能只停留 0.6 秒那么抽帧间隔必须小于这个值的一半也就是至少 3fps 才能保证每句字幕都至少被抽到一次。但 3fps 对十分钟视频就是 1800 张处理量依然不小。我的折中方案是 2fps 起步同时配合一个变化检测抽出帧之后先比较相邻帧在字幕区域的差异差异小于阈值的直接丢弃只把有变化的帧送去 OCR。这样实际进入 OCR 的帧数能压到总帧数的 10% 到 20%速度提升非常明显。ffmpeg -v error -i input.mp4 -vf fps2,cropiw:ih*0.18:0:ih*0.80 \ -q:v 2 frames/%06d.jpg这里的cropiw:ih*0.18:0:ih*0.80是裁掉除了底部 18% 以外的所有区域。裁剪这一步极其关键——它同时解决了三个问题把输入尺寸降到原来的五分之一OCR 速度快五倍去掉了画面主体内容的干扰避免把人物身上的文字、背景里的招牌也识别进来让后续的去重比较专注于字幕区域。5.2 字幕区域的位置要实测不能套用固定比例上面那个 18% 是经验值但绝不能无脑套用。不同来源的素材字幕位置差别很大电影通常在底部 88% 到 95% 的位置综艺可能在中下部有些纪录片的字幕位置会随画面构图变化。正确的做法是先抽 20 帧散落在不同时间点的完整画面拼成一张缩略图总览用 ffmpeg 的tile滤镜人眼看一遍确认字幕的纵向范围再定 crop 参数。ffmpeg -v error -i input.mp4 -vf fps1/60,scale320:-1,tile4x5 -frames:v 1 overview.jpg如果同一批素材来自同一个来源通常只需测一次。混源批处理就得按来源分组每组单独定参数。5.3 预处理的分寸感该做的和不该做的裁出来的字幕条一般还要过一遍预处理。常见的几步是转灰度、放大两倍、二值化、去噪。灰度化几乎总是有益的能减少三通道不一致带来的干扰。放大两倍对识别小字号字幕帮助明显因为很多 OCR 引擎对小字形的识别率很差。二值化则要看情况——如果字幕是白字黑边画面背景又比较干净二值化效果很好但如果背景是明亮的天空或者雪地字幕本身是半透明的强行二值化会把笔画切断反而更差。我的经验是先不二值化跑一遍看看错字率只有明显是背景干扰导致的错误再引入自适应阈值。用 Otsu 全局阈值在这种场景下往往不如局部自适应阈值因为字幕条内部的亮度分布本身就不均匀。至于字体的颜色和描边白字黑边是最容易识别的组合。遇到黄字或者带阴影的字幕可以先把图转成 HSV只保留亮度通道能去掉大部分颜色干扰。5.4 识别结果的去重与时间轴重建这一步是硬字幕 OCR 最容易被低估的部分。抽了 1800 帧识别出 1800 段文本其中大量重复需要合并成几十条带起止时间的字幕。去重的核心是文本相似度。用编辑距离或者difflib的SequenceMatcher都能算阈值设在 0.85 左右比较合适。超过阈值的相邻帧视为同一条字幕把它们的起止时间合并。这里有个细节OCR 结果里经常混入噪声字符比如把逗号识别成句号、把某个笔画多的字识别成两个字的组合。纯文本相似度比较会因为这些小噪声判成不同导致同一句字幕被拆成两条。解决办法是先把识别结果做一遍清洗去掉所有空白、把全角标点统一成半角、去掉长度小于 2 的孤立结果这些基本是噪点。另一个细节是滚动字幕。片尾的演职员表是持续滚动的相邻帧的文本完全不同去重逻辑会认为每条都是新内容结果生成几百条只有零点几秒的字幕。这种情况建议直接识别出检测到滚动区域后跳过或者把整段的时间范围合并成一条文本标注为[滚动字幕]反正这类内容通常不需要精确到行。6. 时间轴对齐与 SRT 落盘的最后一百米前面所有步骤产出的都是文本 时间区间的中间结构最后一步是把它写成标准 SRT。这一步看起来是纯格式化实际上是最容易出低级错误的地方而且错误往往在播放器里才暴露。6.1 帧序号到时间戳的换算抽帧时生成的%06d.jpg文件名是从 1 开始递增的第 n 张图对应的时间不是 n/fps而是 (n-1)/fps因为第一帧的时间戳是 0。这个 off-by-one 会让整条字幕轨提前或延后一帧单帧的误差在 25fps 下是 40 毫秒肉眼基本看不出来但如果你的抽帧间隔是 2fps也就是 0.5 秒一张一帧的偏差就是 0.5 秒观众能明显感觉到字幕比声音早半拍。更稳妥的做法是让 ffmpeg 把时间戳直接写进文件名ffmpeg -v error -i input.mp4 -vf fps2,cropiw:ih*0.18:0:ih*0.80 \ -frame_pts 1 frames/%010d.jpg-frame_pts 1输出的文件名是帧的 PTS 值以时间基为单位需要再除以时间基换算成秒。这样时间戳来自容器本身不受抽帧方式影响长时间视频也不会有累积漂移。如果嫌 PTS 换算麻烦退而求其次的方案是用固定的抽帧间隔推算但必须在处理开始时记录视频的起始时间有些素材不是从 0 开始的start_time字段会是 0.08 之类的值并在最终时间上加上这个偏移。6.2 SRT 格式的两个致命细节SRT 的格式看起来简单但有两个地方一旦写错播放器就会整个文件读不出来。第一是时间分隔符必须是逗号不是点。00:00:01,500是合法的00:00:01.500在不少播放器上会被判为非法行然后跳过整条。这个错误特别常见因为很多语言的时间格式化默认输出点号。第二是序号必须连续递增且从 1 开始中间不能跳号。去重合并的过程中如果删掉了某些条目序号容易断档。安全的做法是最后统一重新编号而不是在生成时边写边计数。格式化函数我自己一直用这个def fmt_time(sec): ms int(round(sec * 1000)) h, ms divmod(ms, 3600000) m, ms divmod(ms, 60000) s, ms divmod(ms, 1000) return f{h:02d}:{m:02d}:{s:02d},{ms:03d}注意round而不是int直接截断会让所有时间点统一偏早半毫秒以内虽然影响极小但既然是免费的正确性没理由不要。还有一个隐性问题是结束时间必须严格大于开始时间。OCR 合并时如果两条字幕的时间区间完全重合或者顺序颠倒生成的 SRT 会出现负时长的条目某些播放器会直接崩溃。写文件前加一道校验遇到非法区间就强制把结束时间设为开始时间加 0.2 秒。6.3 硬字幕 OCR 结果和音频识别结果怎么合并实际项目里经常两条线都要跑OCR 拿到的是画面上的字幕文本准确率高但有漏快速切换会漏帧音频识别拿到的是完整台词覆盖全但有错字同音字、专业术语。两者合并能显著提升质量。合并的前提是两边的时间轴都对得上。做法是以 OCR 结果为骨架对每一条 OCR 字幕在音频识别结果里找时间重叠度最高的那条如果两者文本相似度低于某个阈值比如 0.6就把音频结果作为候选标注在旁边导出成一份带疑点的复查清单。人工只需要看这份清单工作量比从头校对小得多。这个思路有点像翻译里的对齐语料实际上手动校对字幕最耗时的就是听一遍找位置而位置信息在两条线上都是现成的合并之后直接跳过了这一步。7. 这套流程里我踩过的几个坑聊完主线说几个具体的、文档里不会写的问题。这些都是实际跑批量任务时撞出来的写在这里省得别人再撞一遍。时间基准不一致导致的整体漂移。有一次处理一批三小时的素材OCR 出来的字幕前面一小时对得很准越往后偏移越大到片尾已经晚了十几秒。排查了半天发现是抽帧时用了-r参数强制指定帧率而源素材是可变帧率VFR。强制转成固定帧率后ffmpeg 会复制或丢弃帧来凑数导致帧序号和时间不再线性对应。解决办法是不用-r改用fps滤镜并且配合-frame_pts 1拿真实时间戳。VFR 素材在处理前最好先用 ffprobe 确认一下r_frame_rate和avg_frame_rate是否一致不一致就是 VFR。中英混排的标点处理。识别结果里经常出现中英文标点混用比如中文句子结尾跟了个半角句号或者英文术语被识别成全角字符。这类问题不影响阅读但影响后续做关键词检索——搜GPU搜不到。建议在清洗阶段统一做一遍全角转半角只对英文字母和数字做转换中文标点保留原样。OCR 是绝对瓶颈必须并行。单进程跑 OCR一分钟视频大概要花十几秒一小时素材就是十几分钟批量处理根本受不了。可行的做法是把抽出来的帧按数量切成若干份起多进程并行识别进程数设成 CPU 核心数的一半左右比较合适——设成全部核心会因为内存带宽争抢反而变慢。另外要注意 OCR 库本身不一定是线程安全的用进程池而不是线程池。别一次性把所有帧读进内存。抽帧之后如果直接glob出全部文件路径然后逐个读取对于长视频会产生大量小文件 IO速度很慢。更好的做法是流式处理读一张、处理一张、把结果写进列表、释放图像对象。中间结果只保留文本和时间戳内存占用能控制在几十兆以内。字幕背景是动态画面时的兜底。有些素材字幕直接叠在复杂背景上没有描边也没有色块识别率会掉到六七成。这种情况单靠图像预处理救不回来可以考虑对同一时间点前后各抽一帧做多帧融合——如果字幕文字在三帧内位置固定而背景在动用帧间差分就能把字幕提取出来。这个方法实现起来不算简单但对付画质差的老素材确实有效。最后分享一个实用的小技巧处理完之后不要直接删掉中间产物。抽出来的帧、识别出的原始文本、ffprobe 的 json 输出这些加起来占不了多少空间但当你两周后发现某条字幕错了想回溯原因时它们就是唯一的线索。我一般是把整个工作目录打个包留着等项目彻底验收再清理。