做验证码识别这个方向,大多数人的第一反应是“这玩意儿早过时了,随便一个深度学习模型不都能到99%”。但真正上手干一次就知道,尤其是中英文混合、带干扰、字符粘连的验证码,想稳定跑在线上的批量流程里,坑远比你想象的要多。我最近在做一个发票查验自动化项目时,就需要把一批历史票据批量录入查验系统,而卡在入口的就是一个4位混合验证码,中文、英文、数字混在一起,背景还有噪点和干扰线。我把这套识别方案完整做了下来,最终整串识别率稳定在95.2%。这篇文章不吹不黑,把设计思路、数据准备、模型选型、工程化部署和踩过的坑全部复盘一遍。
如果你也在做RPA机器人、批量发票验真,或者正好在研究验证码识别技术,这篇文章里的经验可以直接拿去做参照。我不建议你照抄代码,但思路和避坑点绝对能帮你少走弯路。
1. 项目背景与整体设计思路
1.1 业务场景驱动的技术选型
先说清楚业务背景。发票查验在很多企业里是一项高频、重复、量大的操作,尤其是财务共享中心,每天要处理几千张报销单据,每一张单据都需要核对发票号、金额、开票日期等信息。很多企业会采购OCR识别服务先把纸质发票转成结构化数据,但这一步通常只能覆盖票面信息,最终的“真伪核验”还是要落到查验平台里。而查验平台在返回结果前,几乎都会要求输入验证码。
这个环节如果靠人肉输入,一是效率低,二是容易疲劳出错。我算过一笔账:单张验证码人工识别加输入平均需要3到5秒,处理1000张就是1小时左右,如果遇到验证码渲染得特别模糊,重试几次之后时间成本直接翻倍。我接到的需求就是把这个环节自动化,让程序自动识别验证码并回填到自动化脚本里。
技术上的难点在于,目标验证码不是单纯的数字验证码,而是中英文混合。常见形态是4个字符,可能是数字、小写英文、大写英文、中文字符的组合,背景带彩色噪点和干扰线,字符之间有时粘连,偶尔还有轻微旋转。这样的验证码,传统OCR库基本束手无策,必须针对目标样式去定制识别方案。
1.2 中英文混合验证码的识别难点
中英文混合验证码比纯数字或纯英文验证码难在几个地方。
第一是字符类别数量暴涨。纯数字只有10个类,纯英文大小写加数字也就62个类。但一旦加入中文,类别数量可能飙到几百。字符类别越多,分类器的最后一层输出维度越大,训练难度和所需数据量都会成倍增长。
第二是相似字符混淆问题。数字“0”和英文大写“O”、小写“o”在图片上几乎一模一样;数字“1”跟小写“l”、大写“I”也高度相似;英文“8”和数字“8”更是无法区分。中文里“已/己/巳”、“日/曰”、“人/入”这样的形近字,人眼都容易看走眼,模型配合后处理规则都未必能完全兜住。
第三是字符粘连和形变。验证码为了让OCR失败,通常会在字符间距上做文章:有的字符贴得很近,有的旋转一定角度,有的还会叠加干扰线把字符笔画连起来。这种样式对传统字符分割算法非常不友好,分割环节一旦出错,后面识别再准也没用。
我在项目前期用Tesseract做过一个快速测试,在30张真实样本上跑了一遍,最终只对了9张,准确率30%。不是说Tesseract不行,而是它本身面向的是自然场景文本或打印体文字,对验证码这种刻意“对抗”的场景几乎没有抵抗力。所以从第一版开始,我就没有把Tesseract列为可选方案。
1.3 两条技术路线怎么选
验证码识别的主流技术路线有两条:分割单字加分类模型,以及端到端序列识别模型。
分割加分类的思路比较直观。先把整张验证码图片切分成单个字符的小图,然后每个小图送进一个CNN分类器,识别出单个字符,最后按顺序拼接成完整结果。这条路线的优点是逻辑清晰,每步都好调试,某个字符识别错了一眼就能看出来。数据也好造,只要把字符位置标出来,裁剪成小图就能训练分类器。缺点是分割环节太脆弱,遇到字符粘连、旋转角度大、中文宽度变化大的情况,很容易切错。
端到端序列识别,常见实现是CRNN加CTC。输入整张验证码图片,网络自动学习字符之间的时序关系,直接输出整串字符。优点是不需要单独做字符切分,对粘连和变形更加鲁棒;缺点是需要更多训练数据,训练时间更长,模型文件也更大。尤其当字符集里包含中文时,数据需求量会进一步上升。
我最终选择的是分割加分类为主,针对难分割样本再做后处理兜底。为什么这么选?因为目标验证码虽然带干扰,但字符主体并没有粘连到看不清的程度,通过连通域分析和垂直投影可以解决八成以上的分割问题。剩下的二成,可以用数据增强和识别阶段的反馈校正来兜底。在训练数据只有5万张的情况下,这个方案的综合准确率反而更好控制。
这里我也想给你一个判断标准:如果你要处理的验证码是任意长度、字符随意旋转、背景极度复杂,别犹豫,直接上CRNN那套端到端方案。如果你的验证码长度基本固定,字符间距只是“偶尔贴在一起”,那么分割加分类是性价比更高的选择。
1.4 为什么说分割+分类在大多数情况下够用
很多人一说验证码识别就默认要上CRNN、Transformer,觉得越“重”越好。但我做了那么多OCR项目之后发现,工程上最重要的是在数据量和业务约束下找到最可控的方案。分割加分类这套老派做法,优势在于每一步的置信度都可解释、可度量。
比如分割结束之后,我可以拿到每个字符候选框的宽度、高度、左右间距,这些信息都能用来判断当前切分是否可靠。如果某个字符候选框的宽度只有平均宽度的三分之一,基本可以断定是误切,需要合并相邻候选框重新识别。这样的错误反馈机制,在端到端模型里就很难实现。
另外,分割加分类方案的模型非常轻量,CPU上单张图片推理不超过20毫秒,模型文件也就几MB。在批量处理场景下,这种轻量方案可以直接跑在普通服务器上,不需要上GPU,部署成本低很多。
2. 数据准备与预处理实战
2.1 样本怎么来
在验证码识别项目里,数据问题是先于模型问题的。最理想的情况是直接从目标平台拿到大量历史验证码图片,但在实际操作中,这种做法既不合规也不现实。平台方通常有访问频率限制,短时间内大量抓取很容易触发风控,还可能影响正常业务访问。
我采取的是“少量真实样本分析加合成数据训练”的组合方案。项目启动时,业务方提供了3000张历史真实验证码图片用于分析,这些图片是从合作渠道拿到的授权样本。我先用它们做统计分析,搞清楚字符集、字符数量、干扰线风格、背景颜色分布等关键信息,然后再写一个合成数据生成器,把分析出来的特征全部参数化,批量生成训练数据。
这里必须强调一个原则:任何数据使用都要以授权和合规为前提。如果业务场景不允许获得真实样本,那就只能通过仔细分析截图,人工归纳视觉特征来生成合成数据。但合成数据与实际数据之间的差距,最终一定会反映在准确率上,所以尽可能争取合法授权的真实样本做验证集,是很有价值的。
2.2 图像预处理步骤
拿到原始验证码图片后,我不会直接送进模型,而是先做几步标准化预处理。
第一步是灰度化。验证码的背景和字符通常颜色差异明显,灰度化之后,字符和背景的亮度对比就凸显出来了。标准公式是Gray等于0.299乘R加0.587乘G加0.114乘B,OpenCV里用cvtColor一行就能搞定。
第二步是二值化。我试了固定阈值和大津法,最终固定用大津法。大津法会自动计算一个最优阈值,让前景和背景的类间方差最大,对验证码这种前景背景对比明显的图片非常有效。如果遇到光照不均的情况,大津法效果会变差,这时候可以改用自适应阈值。
第三步是去噪。验证码上经常有孤立的噪点,我的做法是先做一次中值滤波,把细颗粒噪点抹掉,再做一次形态学开运算,把干扰线断开成碎段。这一步需要控制强度,滤波核太大或开运算迭代次数太多,会把字符的细笔画也腐蚀掉。我调参下来,中值滤波核大小设为3,开运算用3乘3矩形结构元素迭代1次,效果最稳。超过1次,部分中文字体的横竖笔画就开始断裂,反而增加了识别难度。
第四步是倾斜校正。如果整张图片存在全局倾斜,可以用最小外接矩形或霍夫变换估算倾斜角度,再做仿射变换。我遇到的目标验证码整体倾斜不严重,更多是单个字符微转,所以这一步没有在全局做,而是放在切分后的单字上做局部校正。
2.3 字符切分经验
字符切分是整个项目里最磨人的环节,没有之一。我用的是“两步走”策略:先用连通域分析和垂直投影做初步切分,再对异常候选块做二次精细切分。
连通域方法适合字符之间有清晰间隔的情况。用OpenCV的findContours拿到每个连通区域的外接矩形,然后根据字符的宽度和高度过滤掉明显太小或太大的块。但中文字符有个麻烦,笔画经常断成多个连通域,比如“口”字可能被干扰线隔断成几块。所以我会加一个合并逻辑:如果两个连通域在垂直方向上有足够大的重叠,而且水平距离很近,就把它们合并成同一个字符候选块。
垂直投影方法则是把二值图按列求和,连续的非零段就对应一个字符。这个方法对单个字符投影连续的情况很有效,但遇到字符笔画在垂直方向上有断裂,或者两个字符贴得太近,就会出错。我的做法是把两种方法的结果交叉验证:先用连通域找到候选块,再用垂直投影做参考,如果两者结论不一致,就进入二次精细切分流程。
二次精细切分主要处理两类情况:一是候选块宽度远超平均宽度,可能包含了两个字符;二是候选块宽度过窄,可能是某个字符被拦腰截断。遇到宽度过宽的块,我会尝试在垂直投影的“鞍点”位置寻找切割线;遇到宽度过窄的块,我会去检查它左右相邻的候选块,判断是否需要合并。这套逻辑写起来不复杂,但非常依赖对目标验证码字符宽度分布的统计,所以前期切分参数的统计工作一定要做扎实。
2.4 合成数据生成技巧
合成数据生成器的核心是“还原真实感”。我一开始生成的合成图太过干净,模型在真实样本上表现惨不忍睹,后来才意识到问题出在数据分布不匹配上。
合成器里的随机项包括:背景色块的填充范围、字符旋转角度、字体种类、字符间距、干扰线的数量和颜色、噪点的密度。我参考真实样本统计,把旋转角设置为负15度到正15度之间均匀采样,字符间距设置成“偶尔贴近、偶尔分开”的分布,干扰线控制在1到3条,颜色选择与背景相近但又有细微差别。中文字符从一个常用汉字字形库中随机抽取,英文和数字按真实样本中的频率分布抽样。
另一个关键点是字符位置标注。合成器除了输出图片,还输出一个JSON文件,记录整串字符结果以及每个字符在像素坐标中的位置框。有了位置框,训练分割模型或评估分割效果就非常方便。我可以直接根据位置框把真实字符区域裁剪出来做单字分类器训练,不需要额外的人工标注。
训练集最终生成了5万张合成图片,验证集则混合了真实授权样本和合成样本。这个配比下,模型在合成集上的整串准确率能达到95.2%,在真实样本上的准确率初期只有92.8%,之后我往训练集里加了10%的真实样本做finetune,真实样本准确率才提升到95.5%。
3. 识别模型实现与调优
3.1 字符分类模型设计
切分完成后,每个字符小图被统一缩放到32乘32像素的灰度图,然后送入分类器。字符类别集合包含数字0到9、英文大小写共52类,再加上目标验证码中出现过的中文字符,最后类别总数是362类。模型输出层是362个神经元的Softmax分布。
模型结构走的是轻量路线。第一个卷积层32个3乘3卷积核,ReLU激活,接2乘2最大池化;第二个卷积层64个3乘3卷积核,ReLU激活,再接2乘2最大池化;然后展平,接128个神经元的全连接层,加了0.5的Dropout防止过拟合;最后是362个神经元的输出层。整个模型参数量不大,CPU推理速度非常快。
训练时用交叉熵损失,Adam优化器,初始学习率0.001。我设置了一个学习率调度策略:每5个epoch如果验证集loss没有下降,学习率就乘以0.5。训练到第20轮左右loss基本收敛,单字符识别准确率在99%上下。
这里有个经验值得说:比起模型结构,输入字符图的归一化方式对准确率影响更大。如果直接把字符小图拉伸成32乘32,会改变原有的长宽比,中文和一些窄字符就会变形。更稳的做法是先把字符按原比例缩放到宽高不超过32像素,然后填充到32乘32的灰色画布中央。这个细节帮我提升了约1个百分点的整串准确率。
3.2 端到端方案的对比实验
为了确认分割加分类方案确实是最佳选择,我也跑了一版CRNN加CTC作为对照组。CRNN的特征提取部分用了类似VGG16的前几层卷积,时序部分接双向LSTM,最后用CTC做序列对齐。图片宽度输入固定为160像素,高度按比例缩放。
在5万张合成数据上训练,CRNN的整串准确率到94%左右,比分割加分类方案略低一点。原因主要还是中文字符的类别数量多,每个中文字符分到的训练样本相对稀缺。如果数据量扩充到20万张,CRNN大概率能反超,因为端到端模型对字符间时序关系的建模能力更强。
但工程上线不只看准确率。CRNN训练时间大约是分割方案的三倍,模型文件大一个量级,CPU推理速度也慢不少。在批量发票查验的场景里,我需要在普通服务器上并发处理图片,分割加分类方案的推理延迟优势很明显。所以最终上线用的是分割加分类方案,CRNN作为后续数据量充足时的备选升级方案。
3.3 打破95%瓶颈的调优点
从90%整串准确率提升到95.2%,我没有改模型结构,而是靠三个细节的叠加。
第一是数据增强。合成数据一开始太“干净”,模型在含噪真实样本上总是掉点。后来加入了高斯模糊、透视畸变、随机亮度扰动、随机擦除笔画等策略,测试集准确率直接涨了2.5个百分点。数据增强对验证码识别项目来说,永远是性价比最高的投入。
第二是后处理规则。我根据字符混淆矩阵建了一张映射表。模型输出“0”“O”“o”且上下文都是数字时,统一映射成“0”。模型输出“1”“l”“I”时,如果该位置在目标验证码中只可能出现数字,就优先映射成“1”。类似的规则还可以灵活配置,每次验证码样式改版,更新映射表就行,不用重新训练模型。
第三是切分置信度反馈。让切分模块输出每个字符候选框的置信度,如果某一段置信度很低,就把它和相邻段合并,重新送进识别器。这一步能让“切分错误”不至于直接“一票否决”,显著提升了整串容错率。
3.4 测试与评估口径
在公布准确率之前,必须先把口径说清楚。这个项目里的95.2%,指的是整串验证码完全识别正确的比例,也就是一张图片全部字符都识别对才算正确,任何一个字符错了都算整张失败。如果按单字符识别准确率算,362类上的准确率是98.7%。这两个数字差距很大的原因在于,整串准确率是单字符准确率的乘积式累积,4个字符的整串准确率约等于单字符准确率的四次方,所以单字符哪怕到99%,整串也只是96%左右。
我在测试集上放了5000张合成图片和3000张真实授权样本,合成集上整串准确率95.2%,真实样本上92.8%。后续用10%真实样本finetune之后,真实样本准确率提升到95.5%并保持稳定。建议你在做类似项目时,也把合成测试集和真实测试集分开评估,毕竟真实场景的数据分布才真正决定上线效果。
4. 工程化部署与问题排查
4.1 把模型封装成可调用服务
模型训好之后,要变成稳定的服务才能被自动化流程调用。我用Flask封装了一个HTTP接口,输入base64编码的图片,输出识别结果字符串。接口内部按顺序执行:图片解码、预处理、字符切分、单字识别、后处理、结果返回。
服务部署有一个容易踩的坑:多个线程共享同一个模型session时,会出现推理结果不一致甚至进程崩溃的问题。我采用的方式是服务启动时预加载模型,每个worker进程持有独立的session,用gunicorn启动4个worker进程。单张图片平均响应时间35毫秒,200并发压测下P99在150毫秒以内,满足业务需求。
如果你不想引入Flask,也可以把模型转成ONNX格式,在Python脚本里直接用ONNX Runtime推理。ONNX Runtime对CPU推理做了很多优化,比PyTorch原生模式快不少。模型转ONNX时要注意固定输入尺寸和动态轴参数,否则转换出来的模型在输入尺寸变化时会报错。
4.2 关于“按键精灵”类自动化工具的使用边界
关键词里提到“按键精灵手机版验证码识别点击”,这个我必须专门说几句。按键精灵这类自动化工具,本质上是模拟屏幕坐标点击和键盘输入,在很多RPA场景里确实方便,用来做验证码识别后的自动提交也算常见做法。但在国内做自动化,业务合规性永远是第一位的。
验证码设置的初衷就是区分人和机器,任何绕过验证码的自动化操作,都应该先确认自己是否获得了平台方的授权。企业内部的发票查验,如果已经和查验服务方建立了合作关系,通常可以通过官方接口完成数据交换,压根不需要模拟点击。模拟点击的稳定性也很差,窗口位置、分辨率、DPI缩放都会导致坐标偏移,我见过太多因为分辨率不同导致点击失效的案例。
如果确实需要用到验证码识别加自动化点击,建议把识别服务化,用脚本调用识别接口拿到结果,再通过可配置的点击框架去执行,而不是把识别逻辑直接写死在按键精灵脚本里。同时一定要控制请求频率,设置合理的随机间隔,避免对目标服务造成压力。
4.3 常见问题排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 整串识别率骤降 | 验证码样式改版 | 重新收集真实样本,更新数据生成参数 |
| 中文汉字频繁识别错 | 该类字符训练样本不足 | 增加该字符在合成数据中的采样权重 |
| 图片重影、笔画糊掉 | 预处理增强过度 | 调小中值滤波核,开运算迭代次数降回1 |
| 字符切得过多或过少 | 投影/连通域对粘连失效 | 融合两种切分结果,增加滑窗重切逻辑 |
| 模型推理延迟高 | 模型过大或CPU性能不足 | 转ONNX,使用4bit量化或改用更轻量网络 |
| 并发请求时服务崩溃 | 多线程共享模型session | 改为多进程部署,每进程独立session |
| 真实样本准确率低于测试 | 合成数据与真实分布偏差过大 | 在训练集中加入10%真实样本finetune |
4.4 两个容易忽略的细节
第一个细节是失败样本的记录。我在上线流程里加了一个日志模块,每次识别失败都会把原始图片、识别结果、每个字符的置信度一起存下来。这些失败样本是优化模型最宝贵的生产资料,每周做一次错误分类分析,往往比盲目调参更快见效。常见的失败模式包括某类中文字符系统性出错、某种干扰线样式导致切分错乱等,针对性地补数据比随机加数据高效得多。
第二个细节是模型和参数的版本管理。模型文件、预处理参数、字符映射表、后处理规则、合成数据生成器的版本,全都要纳入版本管理。每次实验打上标签,记录实验目的、数据配比、最终指标。模型迭代时用A/B对比的方式上线,不要让新模型直接覆盖旧模型。否则过阵子发现线上效果回退,你连“上一版为什么好用”都查不出来。
5. 一些真心话
如果让我重做一遍这个项目,我会把更多时间放在数据生产和切分边界处理上,而不是在模型结构上反复折腾。验证码识别的核心不是网络有多深,而是数据处理有多贴近目标场景。模型结构即便换到ResNet、EfficientNet,在数据分布不匹配的情况下,收益也很有限。
合规这件事必须拎清。不管技术多有挑战性,数据来源和使用方式都必须符合规则。自动化本身没有错,但用在哪里、怎么用,边界要明白。我这次是从合作渠道拿到授权样本,训练和测试都在合规范围内进行。
还有一个小技巧,测量字符位置时,最好把每个字符的最小外接旋转矩形也一并存储下来。后面无论是做旋转校正、画错误分析可视化图,还是训练分割网络,都会频繁用到。这个细节我是做到项目后期才补上的,补数据的过程浪费了不少时间,希望你一开始就把这个字段设计进去。
验证码识别这个方向,说起来不算新鲜,但真正把一套方案稳定跑进业务流程,考验的是数据、切分、模型、工程的全链路功底。希望这篇文章能帮你少踩几个坑。