最近做一个内部单据识别工具,客户那边有几千张系统截图,需要批量提取单号和金额,数据完全不能出内网。最开始想接在线OCR,被网络审批和成本劝退后,干脆用易语言做了个本地离线的OCR识别模块,专门处理验证码这类小尺寸图片以及中英文数字混合的文本。模块跑了大半个月,识别率稳定在可用范围内。这篇文章把整个实现思路、源码结构、集成步骤和调试中踩过的坑完整梳理一遍,给同样需要在易语言里做离线识别的朋友一个参考。
1. 为什么非要做成“本地离线”不可
1.1 在线识别让人头疼的三个点
一开始我也觉得接在线OCR最省事,毕竟识别能力现成,调用也简单。但真正落到项目里,三个问题立刻暴露出来。
第一个是延迟。单据截图批量跑的时候,一张图从上传到返回结果往往要几百毫秒甚至一秒以上,碰上网络波动还要重试。几千张图跑下来,时间成本完全不可控。而且很多识别请求是同步等待的,界面卡住、进度条不动,体验非常差。
第二个是费用。按次计费的模式,少量调用还好,一旦跑批就是几万次请求,成本直线上升。如果是长期维护的工具,每个月都要为这部分账单操心,实在不划算。
第三个是数据安全。单据截图里可能有单号、金额、客户编号,甚至联系方式。这些数据发到第三方接口,即使只是临时处理,也很难跟客户交代。有些内网环境根本不允许外发请求,在线方案直接不可行。
所以“本地离线运行”不是锦上添花,而是这类场景的硬性前提。数据不出本机,识别在自己机器上完成,没有网络依赖,没有按次计费,这才是做工具该有的样子。
1.2 易语言做本地OCR,技术上是可行的
很多人在易语言里一听到OCR就觉得要调C++、要搞机器学习,心里发怵。其实易语言本质上是Windows平台上的应用层语言,调用本地DLL、操作位图、处理字节集这些能力都有,完全可以把一个离线识别引擎封装成模块来用。
我的做法是:用本地识别库提供核心识别能力,通过DLL接口暴露给易语言,易语言负责图片加载、预处理、参数组织、结果后处理。识别引擎在C/C++侧运行,加载模型文件后就在本机CPU上推理,不涉及任何网络请求。
这个方案的好处是分工明确:重活交给识别引擎,易语言只管界面、流程和图像处理。模块封装好后,其他项目也能直接复用。后面章节我会把DLL接口设计和易语言侧调用方式写清楚,照着做就能跑起来。
2. 先搞清楚识别边界:验证码、中英文数字能到什么程度
2.1 验证码识别的正确打开方式
每次提到验证码,总会被误解成“什么都能破”。我先把话说清楚:这类OCR模块针对的是标准的小尺寸字符图片,比如自己测试系统里的图形验证码、内部单据里的数字串、截图里的验证码存档。它解决的是“图片里的文字变成文本”这个问题,不是用来绕过别人系统的安全防护。
实际识别能力取决于输入形态,我按难度排个序:
- 纯数字、清晰无干扰:最容易,识别率可以做到很高
- 数字加大写字母、有轻微扭曲:需要预处理配合,识别率可接受
- 带干扰线、背景纹理复杂:必须加强降噪和分割,识别率会下降
- 严重粘连、字符旋转、字体随机且模糊:不能保证稳定,谨慎使用
所以做这个模块时,我把目标定在“验证码/中英文数字识别”这个合理范围内,不追求解决所有图片。模块对输入有基本要求,预处理环节会尽量把图像处理成干净的形态,但如果原始图片质量太差,任何OCR都很难有稳定表现。
2.2 中文、英文、数字混排时的识别策略
中英文数字混合和纯英文数字不一样。英文字符结构简单,字符间距相对清晰;中文笔画多、结构复杂,和英文数字混排时,切分和识别都更容易出错。
我的处理策略是分两类走。一类是整体识别:把整行文字作为一个区域直接交给识别引擎,适合排版整齐的印刷体。另一类是分块识别:先按连通域或投影把文字切成小块,再逐块识别。分块的好处是能让引擎专注在小区域上,减少混排带来的上下文干扰,坏处是切错了整行都完蛋,需要依赖预处理质量。
实际上,大多数验证码类的输入不包含中文,主要是数字和大写字母。真正需要中文识别的是单据截图、截图标题、按钮文字这类内容。所以模块里我做了一个可选参数:允许用户指定候选字符集合。比如纯数字验证码就传“0123456789”,引擎只在里面选;包含中文的截图就放开到完整字典。限定候选集对准确率提升非常明显,后面调优部分会详细讲。
2.3 用一张表说清楚模块的适用范围
| 输入类型 | 识别效果 | 处理要点 |
|---|---|---|
| 标准印刷体数字 | 极高 | 几乎不需要额外处理 |
| 数字+大写字母验证码 | 高 | 二值化+去噪,必要时放大 |
| 带干扰线/噪声的验证码 | 中高 | 先降噪,再二值化,避免字符断裂 |
| 中文字符+数字混排 | 中 | 分块识别,保留候选集 |
| 手写体、严重艺术字体 | 低 | 不建议使用传统OCR硬扛 |
| 点选、滑块类交互验证码 | 不支持 | 属于目标检测和语义理解范畴 |
这张表也给开发中的预期管理提了个醒:模块能覆盖的输入类型要先明确,不然用户拿艺术字手写体来测,结果必然不理想。
3. 从图片到文字的完整链路:预处理、引擎对接与源码结构
3.1 图像预处理:一半以上的准确率都是在这里决定的
识别引擎对输入图像是有要求的,不是说随便丢一张JPG进去就能直接出结果。实际项目中,大量识别失败并不是引擎不行,而是图片没有处理好。
预处理的核心步骤有四个。
灰度化。验证码图片常有彩色背景、彩色字符,RGB可以直接加权转成灰度,常见权重是0.299R+0.587G+0.114B。为什么不是简单取平均?因为人眼对绿色最敏感,加权灰度能更好保留字符和背景的对比度,后续二值化才不容易把字符和背景混在一起。
二值化。灰度图变成纯黑白图,固定阈值128是最简单的做法,但背景不均匀时效果差。背景相对简单的场景用OTSU自适应阈值更稳,它会根据整张图的像素分布自动算出一个分割阈值,保证前景和背景类间方差最大。这一步做得好,字符轮廓一下就出来了。
降噪。验证码常带干扰线、噪点。我比较推荐孤立噪点去除:扫描每个前景像素,如果它周围8个邻域里前景像素很少,判定为噪点并移除。干扰线更粗的话,可以用中值滤波,但要注意滤波过度会让字符变糊,笔画粘连更严重,所以参数要保守。
分割。整行文字识别前,先按垂直投影找字符的左右边界,再按水平投影找上下边界,把每个字符切成独立区域。如果字符粘连,投影法会失效,这时可以尝试形态学开运算,把细粘连点断开。这一步很依赖前两步的质量,前面二值化做脏了,后面分割必然歪。
我在调试时习惯把预处理后的中间结果保存成图片,直接看二值化和分割后的样子。如果中间图看起来都很乱,那识别结果必然不行,问题基本出在预处理参数而不是识别引擎上。
3.2 识别引擎对接:DLL接口设计与易语言声明方式
识别引擎封装成DLL后,接口设计直接决定易语言侧调用方不方便。我当时定了三个核心接口,尽量贴近“初始化、识别、释放”的经典流程。
第一个接口是初始化,传入模型目录路径,返回值是引擎句柄。模型加载到内存有开销,所以句柄要保存起来,多次识别复用,不能识别一张图就初始化一次。
第二个接口是识别,参数包括引擎句柄、图像像素数据、宽、高、行字节数(stride)、输出缓冲区和缓冲长度。这里有个容易忽略的细节:像素数据不能传易语言的“图片资源”对象,要传解码后的裸像素数组,并且要把每行多少字节也告诉底层。不同位图格式的行字节数不一样,不传对会导致底层解析时整张图像错位。
第三个接口是释放,识别完把句柄释放掉,避免内存泄漏。
易语言侧DLL命令声明大致这样:
.DLL命令 OCR_初始化, 整数型, "ocr_engine.dll", "OCR_Init" .参数 模型路径, 文本型 .DLL命令 OCR_识别, 整数型, "ocr_engine.dll", "OCR_Recognize" .参数 引擎句柄, 整数型 .参数 图像数据, 字节集 .参数 图像宽度, 整数型 .参数 图像高度, 整数型 .参数 行字节数, 整数型 .参数 结果缓冲, 字节集 .参数 缓冲长度, 整数型 .DLL命令 OCR_释放, 整数型, "ocr_engine.dll", "OCR_Release" .参数 引擎句柄, 整数型句柄本质上是C++侧模型对象的内存地址,易语言侧不需要关心具体内容,只把它当一个整数保存即可。识别结果通过字节集缓冲返回,易语言侧申请足够大的缓冲区,调用后按返回值取实际字符串长度。
3.3 源码模块怎么拆,才是“附完整源码”的正确姿势
源码模块如果全部堆在一个文件里,后续维护会很难受。我按职责拆成了几块:
- 图像加载模块:负责从文件、字节集、窗口截图等来源加载图像,统一转成内部位图格式
- 预处理模块:灰度化、二值化、降噪、分割,输出干净的字符图片
- 识别封装模块:负责DLL调用、参数组织、错误码处理
- 后处理模块:对识别结果做常见混淆纠正,比如O/0、I/1
- 示例程序:用按钮选图、显示预览、调用识别、输出结果
目录结构上,模型文件和DLL统一放在程序目录下的指定子目录,比如“ocr/engine.dll”“ocr/model”,这样源码和运行依赖分得清楚。模块和示例程序分开,方便其他项目引用来直接用。
4. 集成到自己项目里:步骤、示例代码和踩过的坑
4.1 环境准备与目录约定
先做好两件事:把本地识别引擎依赖的C++运行库装上,再把模型目录和DLL文件放到程序运行目录下。
目录我建议这样放:
项目目录/ ocr/ ocr_engine.dll model/ 模型配置文件.txt 模型权重文件.bin 识别示例.e OCR模块.e特别注意两个问题。一是不要把运行库放到系统目录里,容易因为权限问题安装失败,放程序同目录下最省事。二是模型路径不要写死成绝对路径,要用取运行目录()拼接相对路径,否则程序拷贝到其他机器上就会初始化失败。
我还踩过中文路径的坑。DLL侧如果按UTF-8解析路径,而易语言传的是系统本地编码(简体中文环境一般是ANSI/GBK),路径就会乱码,初始化直接失败。解决办法是把模型目录放到英文路径下,或者底层DLL统一改成宽字符接收入口。没有按宽字符处理前,英文路径是最稳妥的。
4.2 核心调用代码:初始化、识别、释放三件套
调用流程看起来很简单,但顺序不能乱。第一次使用前初始化,识别过程尽量复用句柄,程序退出或不再使用时释放。
变量 引擎句柄, 整数型 变量 图像数据, 字节集 变量 结果文本, 文本型 变量 输出缓冲, 字节集 ' 第一步:初始化,返回句柄 引擎句柄 = OCR_初始化 (取运行目录 () + “\ocr\model”) ' 第二步:申请足够大的输出缓冲,建议至少1024字节 输出缓冲 = 取空白字节集 (1024) ' 第三步:识别,传入解码后的图像数据、宽、高、行字节数 OCR_识别 (引擎句柄, 图像数据, 图片宽度, 图片高度, 图片行字节数, 输出缓冲, 1024) ' 结果缓冲是字节集,需要把有效部分转成文本 结果文本 = 到文本 (取字节集左边 (输出缓冲, 识别返回长度)) ' 第四步:释放句柄 OCR_释放 (引擎句柄)其中图像数据必须是从图片文件解码出来的位图原始像素,不能直接传JPG文件字节集。易语言里常用的做法是先载入图片,再通过字节集操作拿到像素数组。如果调用后一直返回空结果,优先检查这一步是不是没做对。
还有一点:释放句柄不要放在频繁调用的循环里。我见过有人每识别一张图就初始化一次、释放一次,速度慢得出奇。初始化一次可以连续识别成百上千张,只有程序退出或模型文件被替换时才需要重新初始化。
4.3 五个真实踩坑案例与排查过程
第一个坑是位图行字节对齐问题。早期传像素数据只传了宽和高,没传stride,结果识别结果完全错乱。原因是Windows位图每行字节数要按4字节对齐,实际一行字节数不等于“宽度×每像素字节数”,而是这个乘积向上对齐到4的倍数。易语言侧如果按对齐前的方式拼字节集,底层按对齐后方式解析,整张图就斜掉了。解决方式很具体:从位图信息头里读取真实的biSizeImage,或者干脆把行字节数显式传出来。
第二个坑是DPI缩放导致截图区域错位。在系统显示缩放为125%或150%的机器上,窗口截图或区域截图的尺寸会被系统偷偷放大,识别出来的区域和鼠标框选的位置对不上。解决方法是程序启动时声明进程DPI感知,或者把坐标换算到实际像素。这个坑特别隐蔽,因为只在高分屏机器上出现。
第三个坑是小尺寸验证码直接识别率低。很多验证码图片只有20×60甚至更小,字符放大后全是锯齿。我的做法是在预处理里加一步放大,用双线性插值放大到3倍,再做锐化和二值化。字符边缘会平滑很多,识别率提升明显。但不要放大得过分,放得太大会让噪点也被放大,反而干扰识别。
第四个坑是相似字符混淆。数字0和大写字母O、数字1和小写字母l和大写字母I,在低分辨率下几乎分不清。后处理模块专门解决这个问题:如果调用方限制了候选集只有数字,就把识别结果里的O纠正为0;如果候选集只有大写字母,就把0纠正为O。这类规则简单有效,能挽回一部分错误结果。
第五个坑是结果缓冲长度不够。一开始我申请64字节,遇到较长的中文识别结果直接被截断,返回的错误码提示缓冲区不足。排查过程很简单,不断加大缓冲长度测试,最后统一用1024字节,覆盖所有场景。如果调用方有超长文本需求,可以根据返回值动态扩容,第一次返回所需长度,第二次再取完整结果。
4.4 识别失败的兜底方案
识别不可能永远百分之百成功,模块里必须留好兜底。我的做法是:函数返回错误码时,把原始图片保存到指定目录的“识别失败”文件夹里,方便事后排查。这样即使批量跑完,也能快速挑出哪些图片识别失败,再单独处理。
另一个兜底是置信度阈值。引擎识别结果如果带置信度,模块层可以设置一个最低阈值,比如0.6以下的结果直接标记为“无法识别”,而不是硬着头皮给用户一个错误文本。宁可不给结果,也不要给错结果,这个原则在处理账务、单号这类数据时尤其重要。
5. 准确率与性能调优:亲测有效的经验
5.1 准确率提升三板斧:预处理、候选字集、后处理
第一板斧是预处理参数。固定阈值和OTSU各自的适用场景不一样,背景均匀的验证码用固定阈值反而更快更稳,背景复杂的用OTSU更有优势。我建议模块开放一个参数,让调用方可以根据实际图片微调。实际调试时,保存预处理中间图,反复看二值化效果,比盲调代码效率高得多。
第二板斧是候选字集。这是提升准确率最直接的方法。纯数字验证码就明确告诉引擎只从“0123456789”里选,数字字母混合的验证码就传“0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ”。候选集越小,引擎的搜索空间越小,误识率越低。我在模块里把候选集作为初始化参数的一部分,不同项目可以传不同集合。
第三板斧是后处理规则。上文提到的O/0、I/1混淆,用规则映射就能处理大部分情况。还有一类情况是识别结果里有明显的非法字符,比如纯数字场景下出现了“S”,可以按相似度映射成5。这些规则写起来不复杂,但对最终结果的影响非常大。
另外,同一批图片如果来自同一个系统、同一种字体和背景,可以先拿几十张样本调好预处理参数,再批量跑。批量场景下预处理参数的一致性,往往比单个图片自适应更重要。
5.2 批量识别场景下的性能优化
批量识别时,最容易犯的错误是在循环里反复初始化引擎、反复申请缓冲。正确做法是:引擎只初始化一次,输出缓冲只申请一次并复用,图片解码后直接进识别,不要中间反复转格式。
如果还需要进一步提升速度,可以按字符区域裁剪后再识别,只处理有效区域,不浪费算力在空白背景上。另一个思路是控制图片尺寸,过大的图先等比缩放到合适分辨率,识别速度和准确率都能兼顾。
并发这块我也提一下。引擎句柄并不是线程安全的,多线程同时调用同一个句柄会崩溃。如果需要多线程批量识别,必须是每个线程一个引擎句柄,线程之间通过任务队列分发图片。易语言的线程操作相对麻烦,但可以用系统线程库封装一层。如果业务量不大,单线程顺序识别其实完全够用。
实测数据给大家参考:处理一张300×80的验证码图片,从加载到识别完成大约几十毫秒,一千张图片几分钟就能跑完,离线批量场景完全没压力。
5.3 几条合规红线,说清楚再使用
本地离线识别不改变使用的合规边界。这个模块适合处理自己有权限的数据:自己的测试系统的验证码自动回归、内部单据归档、截图文字提取、本地资料的数字化,这些都是正当高效的用途。
反过来说,不要把它用来批量绕过别人的验证码保护,比如批量注册、撞库、抢票这类对抗行为。这类行为不是技术问题,而是合规问题,会带来法律责任风险。识别能力本身是中性的,用在哪里决定了它的性质。
这个道理我一般会在交付代码时跟使用者反复强调,也在示例程序的说明文件里写清楚。希望大家拿到源码后,把它用在能真正提高效率的正规场景里。
模块目前已经稳定运行在几个内网工具中,后续我还想加两个能力:一是按固定区域自动裁剪识别,方便处理表格类的固定模板截图;二是把识别结果按字段导出成结构化文本或表格,减少人工整理。如果你在易语言里做离线识别也遇到了类似问题,可以顺着这个思路去改,短时间内就能搭出能用的版本。