1. 从一次“慢得让人抓狂”的本地识别说起
本地 OCR 这件事,我折腾了差不多两年。最开始的想法很简单:手头有一批扫描件和截图需要批量转文字,丢到在线服务里既担心隐私,又要按量付费,量一大成本就上来了。于是我把目光转向了本地模型,想着一次部署、长期白嫖,多香。
结果第一版跑起来,我盯着屏幕等了半天——一张 A4 大小的文档图,识别出来花了将近 4 秒。4 秒听起来不长,但你要知道我是按批处理的,几百张图排队跑,那就是十几分钟起步。更别提有些图分辨率高一点,直接飙到 6 秒以上。我当时的第一反应是:这玩意儿能用,但离“好用”还差得远。
后来我花了不少时间做优化,从模型加载、推理后端、图像预处理到批处理调度,一层一层往下挖。最终把单张图的识别时间压到了原来的三分之一左右,换算成输出速度,稳定在一秒 400 字以上。这个数字不是实验室里的理论峰值,是我自己机器上反复实测的结果。再后来,这套优化思路被官方注意到并收录进了相关文档,算是意外之喜。
这篇文章我想把整个过程完整拆开讲清楚。本地 OCR 模型加速这件事,核心不在于某个神奇的参数,而在于你能否找到真正的瓶颈在哪里。适合谁来读?如果你正在用本地模型做文字识别,觉得速度不够理想;或者你刚接触这块,想知道从哪些方向入手优化;再或者你只是好奇“为什么同样的模型别人跑得比我快”,那这篇内容应该能给你一些可以直接抄作业的东西。
我下面讲的所有操作,都是基于常见实践和我自己踩过的坑总结出来的,不涉及任何特定厂商的独家秘方,你换一个同类模型也能套用大部分思路。
2. 整体优化思路:先搞清楚时间花在哪了
2.1 为什么不能上来就调参数
很多人一遇到模型慢,第一反应是去翻文档找“加速参数”,或者直接换更小的模型。我一开始也这样,结果折腾半天收效甚微。后来我才意识到一个问题:你连时间花在哪都不知道,怎么知道该优化什么?
本地 OCR 的推理流程,粗略拆开大概是这么几段:
- 图像读取与解码:把图片文件读进内存,解码成像素矩阵。
- 图像预处理:缩放、灰度化、二值化、去噪、倾斜校正等。
- 文本检测:找出图里哪些区域有文字,输出文本框坐标。
- 文本识别:对每个文本框里的内容做字符识别。
- 后处理:拼接结果、置信度过滤、格式化输出。
这五段里,真正吃时间的往往不是你以为的那一段。我实测下来,在小分辨率图片上,预处理和检测可能占了大头;而在高分辨率图片上,识别阶段才是瓶颈。所以第一步永远是做 profiling,把每一段的时间打出来。
2.2 我的优化优先级排序
搞清楚时间分布之后,我给自己定了一个优化优先级,从收益高、改动小的方向开始:
- 第一优先级:推理后端与线程配置。这是改动成本最低、收益往往最明显的部分。很多人默认用的是单线程 CPU 推理,换成多线程或者启用硬件加速后端,速度直接翻倍的情况很常见。
- 第二优先级:图像预处理链路。很多预处理步骤是“惯性操作”,比如无脑把图放大到某个尺寸,其实完全没必要。砍掉冗余步骤,收益很直接。
- 第三优先级:批处理与流水线。单张图的延迟和批量吞吐是两回事。把检测和识别做成流水线,让 CPU 和加速器不闲着,吞吐能提升一大截。
- 第四优先级:模型本身。换更轻量的模型或者做量化,收益大但改动也大,放在最后考虑。
这个排序背后的逻辑很简单:先摘低垂的果实。你不需要一上来就重训模型,先把配置和流程理顺,往往就能拿到 2 到 3 倍的提升。
2.3 一个容易被忽略的前提:你的瓶颈可能不在模型
我踩过最大的一个坑,是有段时间怎么优化模型推理都快不起来,后来发现瓶颈根本不在模型,而在图像解码。当时我用的图片格式解码效率很低,一张几千万像素的图,光解码就花了 1 秒多。换成更高效的解码库之后,整体时间直接少了一大截。
所以我在做任何优化之前,都会先问自己三个问题:
- 图片读取和解码花了多少时间?
- 预处理花了多少时间?
- 真正的模型推理花了多少时间?
这三个问题回答清楚了,优化方向自然就明确了。下面我按这个顺序,把每个环节的具体操作展开讲。
3. 推理后端与线程配置:改动最小、收益最大的第一步
3.1 默认配置为什么慢
大部分本地 OCR 模型在默认情况下,用的是单线程 CPU 推理。这个默认值是为了兼容性——保证在任何机器上都能跑起来。但兼容性好的代价就是性能差。你机器上明明有 8 核 16 线程,结果只用了一个核在干活,剩下的一直在围观,这能不慢吗?
我做过一个对比测试,同一张图、同一个模型,只改线程数:
| 线程配置 | 单张识别耗时 | 相对速度 |
|---|---|---|
| 单线程 | 3.8 秒 | 1.0x |
| 4 线程 | 1.6 秒 | 2.4x |
| 8 线程 | 1.1 秒 | 3.5x |
| 16 线程 | 1.0 秒 | 3.8x |
可以看到,从单线程到 8 线程,提升非常明显;但从 8 线程到 16 线程,边际收益就很小了。这是因为线程数超过物理核心数之后,线程调度的开销开始抵消并行带来的收益。所以我的经验是:线程数设置为物理核心数,通常是最优解,不要盲目拉满。
3.2 线程数到底设多少合适
这里有个细节值得展开说。很多人会把线程数设成逻辑核心数(比如 8 核 16 线程就设 16),觉得越多越好。但实测下来,对于 OCR 这种计算密集型的任务,物理核心数往往比逻辑核心数更合适。
原因在于,超线程带来的逻辑核心,共享同一个物理核心的执行单元。对于浮点运算密集的模型推理来说,两个逻辑核心抢一个物理核心的资源,反而可能互相拖累。我自己的机器是 8 核 16 线程,设 8 的时候最快,设 16 反而慢了大概 5% 到 10%。
当然这不是绝对的,不同模型、不同硬件表现会有差异。我的建议是:从物理核心数开始试,上下浮动 2 到 4 个线程,找到你机器上的最优点。这个测试成本很低,跑几张图就能看出来。
3.3 硬件加速后端的启用与取舍
如果你机器上有独立显卡或者集成显卡,那一定要试试启用硬件加速后端。我用下来,GPU 推理相比 CPU 推理,在批量场景下能有 5 到 10 倍的吞吐提升。
但这里有几个坑要注意:
- 首次加载慢:GPU 后端在第一次推理时要做模型加载和显存分配,可能比 CPU 还慢。但一旦预热完成,后续就飞快。所以测试的时候一定要跑够次数,别被第一次的结果骗了。
- 小批量不划算:如果你一次只识别一张图,GPU 的数据传输开销可能抵消掉计算优势。GPU 的优势在批量场景才明显。
- 显存限制:批量太大可能爆显存,需要根据你的显卡容量调整批大小。
我的做法是:日常单张识别用 CPU 多线程,批量处理用 GPU。两套配置切换着用,各取所长。
提示:启用硬件加速之前,先确认你的模型和后端是否支持。有些模型只支持 CPU 推理,强行配置 GPU 会直接报错。查文档确认清楚再动手。
4. 图像预处理链路:砍掉那些“想当然”的操作
4.1 预处理为什么容易成为隐形瓶颈
图像预处理这一步,很多人是直接从示例代码里抄过来的,里面往往包含了一堆“看起来很有道理”的操作:缩放到固定尺寸、灰度化、高斯模糊、自适应二值化、边缘检测……每一步单独看都花不了多少时间,但加起来就很可观了。
我做过统计,在一张 2000x3000 的图上,完整的预处理链路能占到总耗时的 30% 到 40%。也就是说,如果你能把预处理优化好,整体速度能提升将近一半。
问题的关键在于:很多预处理步骤对最终的识别效果并没有明显帮助,甚至可能帮倒忙。比如无脑把图放大到某个尺寸,对于本来就清晰的图来说纯属浪费;再比如过度二值化,反而可能把一些浅色的文字给弄没了。
4.2 分辨率与识别精度的平衡点
分辨率是预处理里最值得说道的一个参数。图太小,文字看不清,识别率上不去;图太大,计算量暴涨,速度慢下来。找到一个平衡点很关键。
我的经验法则是:让文字的高度落在 30 到 50 像素之间。这个区间是大多数 OCR 模型表现最好的范围。具体怎么操作?
- 先估算图中文字的平均高度。如果图里文字大小比较统一,可以手动量一下;如果不统一,可以先用检测模型跑一遍,看看文本框的平均高度。
- 如果文字高度小于 30 像素,适当放大;如果大于 50 像素,适当缩小。
- 缩放的时候用高质量的插值算法(比如双三次插值),别用最近邻,否则文字边缘会有锯齿,影响识别。
我实测下来,把一张文字高度 15 像素的图放大到 35 像素,识别准确率能从 70% 出头提升到 95% 以上;而把一张文字高度 80 像素的图缩小到 45 像素,识别率几乎不变,但速度快了将近一倍。
4.3 哪些预处理步骤可以安全砍掉
下面这张表是我整理的“预处理步骤取舍清单”,基于我自己的实测:
| 预处理步骤 | 是否保留 | 理由 |
|---|---|---|
| 灰度化 | 视模型而定 | 有些模型内部会做,重复做浪费 |
| 固定尺寸缩放 | 谨慎 | 按文字高度动态缩放更合理 |
| 高斯模糊 | 通常砍掉 | 对清晰图无益,还增加耗时 |
| 自适应二值化 | 视情况 | 光照不均时有用,正常图可砍 |
| 倾斜校正 | 保留 | 对倾斜文档提升明显,耗时可控 |
| 去噪 | 视情况 | 扫描件噪点多时保留,截图可砍 |
这张表不是金科玉律,但思路是通用的:每一步预处理都要问一句“不做会怎样”。如果答案是“好像也没差”,那就果断砍掉。
4.4 图像解码:那个被忽视的时间黑洞
前面提过,图像解码可能是个隐形的时间黑洞。我具体说说怎么优化。
不同图像格式的解码效率差别很大。一般来说,未压缩或轻压缩的格式解码最快,高压缩比的格式解码慢。如果你能控制输入格式,尽量用解码快的格式。
另外,解码库的选择也很重要。有些解码库针对多核做了优化,能并行解码;有些则是单线程的。我换过一个支持并行解码的库之后,大图的解码时间从 1.2 秒降到了 0.3 秒。
还有一个技巧:如果只需要识别图的一部分,就别解码整张图。很多解码库支持区域解码,只把你需要的区域解出来,能省不少时间。
注意:区域解码需要你先知道要识别的区域坐标。如果是全图识别,这条用不上;但如果是固定版式的文档(比如表单),区域解码能带来可观的收益。
5. 批处理与流水线:让硬件别闲着
5.1 单张延迟和批量吞吐是两回事
这是很多人容易混淆的一个概念。单张延迟指的是识别一张图要多久;批量吞吐指的是一段时间内能识别多少张图。这两个指标优化的思路完全不同。
单张延迟的优化,靠的是减少每一步的耗时;而批量吞吐的优化,靠的是让各个硬件单元并行工作,别出现“CPU 在算的时候 GPU 闲着,GPU 在算的时候 CPU 闲着”这种情况。
我一开始只盯着单张延迟优化,后来发现批量处理的时候,整体吞吐还是上不去。原因就是检测和识别是串行的:先检测完所有图,再识别所有图,中间有大量的等待时间。
5.2 检测与识别的流水线设计
解决办法是做成流水线。具体来说:
- 阶段一:图像解码和预处理,可以多线程并行。
- 阶段二:文本检测,可以用 GPU 批量跑。
- 阶段三:文本识别,也可以用 GPU 批量跑。
- 阶段四:后处理,CPU 多线程并行。
关键在于,当阶段二在处理第 N 批图的时候,阶段一已经在准备第 N+1 批图了。这样各个阶段首尾重叠,硬件利用率大幅提升。
我实测下来,同样的硬件,串行处理 100 张图要 180 秒,流水线处理只要 65 秒,吞吐提升了将近 3 倍。这个提升幅度,比单纯调线程数还要大。
5.3 批大小的选择与显存管理
流水线里有个关键参数:批大小。也就是一次送进模型多少张图(或者多少个文本框)。
批大小太小,硬件利用率上不去;批大小太大,可能爆显存或者增加单批延迟。我的经验是:
- 检测阶段:批大小可以设大一点,因为检测模型通常比较轻量。我一般设 8 到 16。
- 识别阶段:批大小要谨慎,因为识别模型计算量大。我一般设 4 到 8,具体看显存。
调整批大小的时候,要盯着显存占用看。如果显存占用接近上限,就往下调;如果显存还有富余,就往上加。找到一个既不爆显存、又能跑满硬件的点。
5.4 一个实测的流水线配置参考
下面是我自己机器上跑得比较稳的一套配置,供参考:
# 流水线配置参考 decode_workers: 4 # 解码线程数 preprocess_workers: 4 # 预处理线程数 detect_batch_size: 12 # 检测批大小 recognize_batch_size: 6 # 识别批大小 postprocess_workers: 4 # 后处理线程数 use_gpu: true # 启用硬件加速 gpu_memory_limit: 0.8 # 显存使用上限比例这套配置在我的机器上,处理 500 张中等分辨率的文档图,总耗时从最初的 25 分钟压到了 8 分钟左右。当然你的硬件不一样,参数需要微调,但思路是通用的。
6. 常见问题与排查技巧实录
6.1 速度上不去的排查清单
优化过程中遇到速度不达预期,我一般按下面这个顺序排查:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 线程数 | 查看配置和实际 CPU 占用 | 默认单线程,或设成了逻辑核心数 |
| 硬件加速 | 查看日志确认后端 | 没启用,或启用了但没生效 |
| 图像解码 | 单独计时 | 解码库效率低,或格式不合适 |
| 预处理 | 逐步注释掉步骤计时 | 有冗余步骤,或缩放尺寸不合理 |
| 批大小 | 查看显存和硬件利用率 | 太小导致硬件闲置,太大导致等待 |
| 流水线 | 查看各阶段是否重叠 | 串行执行,硬件轮流空闲 |
这张表我基本每次优化都会过一遍,大部分问题都能定位到。
6.2 识别准确率下降怎么办
加速的过程中,最容易出现的问题就是准确率下降。我遇到过几次,总结下来原因无非这几个:
- 缩放过度:为了提速把图缩得太小,文字糊了。解决办法是设一个缩放下限,别低于文字高度 25 像素。
- 预处理砍太狠:把有用的步骤也砍了。解决办法是每砍一步都做一次准确率对比测试。
- 批大小影响:某些模型在批大小变化时,数值精度会有细微差异。如果准确率下降明显,试试调小批大小。
- 量化过度:如果用了量化模型,量化等级太高会损失精度。可以在速度和精度之间找个平衡。
我的原则是:速度优化不能以明显牺牲准确率为代价。如果准确率掉了超过 2 个百分点,我就会回退这一步优化,换别的方向。
6.3 内存和显存溢出的处理
批量处理的时候,内存和显存溢出是家常便饭。我的处理经验:
- 控制队列长度:流水线里的待处理队列别设太长,否则内存里堆一堆图,很快就爆了。我一般设成批大小的 2 到 3 倍。
- 及时释放:每批处理完,及时释放不再用的中间结果。Python 里可以用
del加gc.collect(),虽然有点粗暴但有效。 - 监控显存:跑批量任务的时候,开个监控盯着显存曲线。如果看到锯齿状波动,说明释放及时;如果一路往上爬,说明有泄漏。
6.4 几个我踩过的坑
说几个具体的、文档里不会写的坑:
坑一:预热不够。GPU 后端第一次推理特别慢,我一开始测试的时候只跑一张图,看到结果就放弃了,以为 GPU 还不如 CPU。后来跑够 10 张之后才发现,从第 3 张开始就飞快了。所以测试一定要跑够次数。
坑二:图片格式混用。我有一批图里混了不同格式,结果发现整体速度被最慢的那种格式拖累了。后来统一转成解码快的格式,速度稳定了很多。
坑三:线程数设太高。有次我把线程数拉满到 32,结果比 8 线程还慢。原因是线程切换开销太大,而且内存带宽成了瓶颈。线程数不是越多越好,找到最优点就行。
坑四:忽略了磁盘 IO。有段时间怎么优化都快不起来,后来发现图片存在机械硬盘上,读取速度成了瓶颈。换到固态硬盘之后,整体时间少了一大截。所以优化之前,先确认你的存储不是瓶颈。
7. 一些补充的实操心得
7.1 关于模型选择的取舍
如果你试过上面所有优化,速度还是不满意,那可能要考虑换模型了。现在的 OCR 模型大致分几档:
- 轻量级模型:速度快,准确率够用,适合对速度要求高的场景。
- 标准模型:速度和准确率平衡,适合大多数场景。
- 高精度模型:准确率最高,但速度慢,适合对准确率要求极高的场景。
我的建议是:先用标准模型加优化,如果速度达标就别换。换模型意味着重新调参、重新测试准确率,成本不低。只有当优化做到头了还是不够快,才考虑换轻量级模型。
7.2 关于测试方法的建议
优化效果好不好,测试方法很关键。我总结了几条:
- 固定测试集:每次测试用同一批图,否则结果没法对比。
- 跑够次数:至少跑 3 次取平均,排除偶然波动。
- 分开计时:每个阶段单独计时,别只看总时间。
- 记录配置:每次测试的配置都记下来,方便回溯。
我专门建了一个表格记录每次优化的配置和结果,这样能清楚地看到哪一步带来了多少提升,哪一步其实没效果。
7.3 关于官方收录这件事
最后说一句关于被官方收录的事。其实我一开始只是把自己的优化笔记整理了一下发出来,没想到会被注意到。回过头看,能被收录大概是因为几点:一是数据真实,所有数字都是实测的;二是思路通用,不依赖特定环境;三是把“为什么”讲清楚了,而不只是罗列参数。
所以如果你也在做类似的优化,我的建议是:把过程记录下来,把原理讲清楚。单纯贴个配置没什么价值,但如果你能说清楚“为什么这么配”“不这么配会怎样”,那对别人的参考价值就大不一样了。
这套优化思路我后来也用在其他本地模型上,大部分都能套用。核心就一句话:先测量,再优化,别猜。你花在 profiling 上的每一分钟,都会在后面的优化里加倍还回来。