
1. 先搞清楚验证码识别到底在解决什么问题之前接手了一个内部自动化项目对方给了一批带数字验证码的历史单据图片要求批量识别并归档。第一反应是这不就是OCR嘛简单。等真正把图片放大看才发现这些看起来只有四位数字的小图其实每一环都藏着坑。我做了一遍完整的“数字验证码识别”流程从图像预处理、字符分割到识别模型踩了不少雷也沉淀了一套可以复用的方法写出来供大家参考。1.1 从“读图片”到“可计算数据”的转化链路一张带数字的验证码图片本质上就是一个二维像素矩阵机器根本不知道里面写的是“3”还是“8”。所谓数字验证码识别就是把这种像素矩阵转换成计算机可以直接处理的字符串比如“0358”。整个转化链条可以分成三步图像预处理、字符分割、字符识别。预处理负责把彩色噪乱的图片净化成“干净”的黑白图分割负责把每个数字从图片里切出来识别负责判断每个切片到底是哪个数字。这个链路还有一个容易被忽视的特性准确率是逐级累计的。预处理做得不好分割就会错分割错一个字符识别环节再强也白搭三个环节各自只有80%的准确率时整体准确率就是0.8的三次方只剩大概一半。很多新手一上来就盯着识别模型调参结果发现整体准确率上不去问题多半出在前面两个环节。1.2 先说清楚使用边界聊技术之前先把边界说清楚。验证码识别这类技术正当的应用场景主要有三个自动化测试中自动填充验证码、历史纸质单据的批量数字化录入、以及图像处理与字符识别学习的练手项目。反过来说任何试图绕过他人验证机制、批量注册、恶意采集的行为都是我不讨论也不鼓励的。技术本身是中立的但用在什么地方是每个开发者自己的选择。1.3 数字验证码的常见干扰类型从实际遇到的情况来看纯数字验证码虽然比复杂验证码简单但干扰方式也是五花八门。我做了一个简单的归类纯色字体加细干扰线最简单的形态字符本身没有变形只是有一两条线穿过。随机噪点加多种字体背景有椒盐噪点同一个验证码里可能出现宋体、黑体等多种字体。字符扭曲、旋转、轻微粘连比前两类难很多字符本身发生了几何形变。背景纹理或干扰色块图片里叠加了网格纹理、渐变背景字符和背景的灰度范围有重叠。我这篇文章的主线是前两类比较常见的数字验证码也就是“干扰线 噪点 多种字体”这种组合。后面我会专门讲到字符扭曲和粘连时应该怎么升级方案但那是另一个层级的问题了。2. 图像预处理把验证码从“人看”变成“机器读”预处理是整个识别链路的地基。我见过不少人直接拿彩色原图去做模板匹配结果十个样本错八个。原因很简单彩色图片里包含大量无关信息字体颜色、背景颜色、抗锯齿边缘这些都会干扰后续的分割和识别。预处理的任务就是把一张彩色验证码图净化成只有“黑色背景 白色字符”的二值图。2.1 灰度化为什么不用三通道直接算平均值灰度化是把RGB三通道合并成单通道亮度图。使用OpenCV时cv2.cvtColor的COLOR_BGR2GRAY内部采用加权公式gray 0.299 * R 0.587 * G 0.114 * B这个加权系数对应人眼对不同颜色的敏感程度绿色最敏感红色其次蓝色最低。有些教程会说直接用三通道取平均就行我实际测试下来加权灰度的边缘信息更干净字符和背景的对比度更稳定。唯一的例外是验证码里字符颜色本身就是红色或者蓝色背景颜色也很极端时取平均反而能拉平二者的差异。所以经验是先用加权灰度跑一遍效果不好再试平均值哪个在肉眼检查时边缘更清晰就用哪个。2.2 二值化三种阈值选择策略的适用场景灰度图每个像素是0到255的整数二值化就是定一个阈值把像素分成前景和背景两类。很多教程直接告诉你“用127做二值化”这是大坑。因为不同验证码图片的亮暗分布不一样固定阈值必然翻车。全局固定阈值最简单最快但只适合“背景全白、字符全黑”这种理想情况。Otsu大津法自动遍历所有可能阈值选出让“类内方差最小、类间方差最大”的那个。大部分情况下它都能自己找到合适的分界点不需要人工调参。自适应阈值对图片的不同区域取不同阈值适合光照不均匀的场景但速度慢而且会把背景纹理切成碎片。我的做法是先用Otsu如果不理想再考虑自适应。对于大多数数字验证码Otsu的表现已经足够好了。import cv2 img cv2.imread(captcha.png) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU)注意这里的THRESH_BINARY_INV它表示“灰度值大于阈值时置0小于阈值时置255”。验证码通常是深色字符、浅色背景反转之后字符变成白色、背景变成黑色这正是后面连通域分析和投影分割需要的前景背景关系。2.3 常见的两类噪声和对应的降噪手段二值化之后图里通常还残留两种噪声。第一种是椒盐噪声就是二值图里随机出现的小白点和小黑点一般用中值滤波处理。中值滤波的原理是把每个像素替换成它邻域内所有像素的中间值孤立噪点在排序时会被自然滤掉但字符边缘基本不受伤。第二种是高斯噪声亮度整体随机波动用高斯滤波处理本质上是对邻域做加权平均适合平滑这种连续性的噪声。干扰线是另一种常见的杂质。在二值图上干扰线通常是一根或几根贯穿字符的细线比字符笔画要细。此时可以用形态学开运算也就是先腐蚀后膨胀kernel cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) cleaned cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)开运算会先把细的干扰线腐蚀掉再把被腐蚀的字符边缘膨胀回来。它的核心逻辑是干扰线宽度小于核尺寸时会被抹除而字符笔画宽度大于核尺寸时能够保留。核大小需要根据实际图片调试我用2x2的核处理大部分验证码都没问题。2.4 习惯性保存中间结果比看代码更容易发现问题从灰度图到二值图再到降噪图我习惯把每一步的中间结果都存成PNG文件拼成一张对比图看。有人觉得多此一举但我踩过的大坑几乎都是靠这一步发现的。最典型的一次是验证码字符是浅色、背景是深色用了THRESH_BINARY而不是THRESH_BINARY_INV二值图直接黑白颠倒后面的分割全乱。这种问题光靠看代码很难发现把中间图输出出来一眼就明白了。3. 字符分割整个识别链路上最容易翻车的一环预处理完成后下一步是把整张验证码图片切成单个字符。这一环节在很多人眼中“不值得研究”但实际上它恰恰是整个识别管线里最脆弱的部分。原因很简单分割依赖的是像素几何特征而验证码生成器最擅长破坏的正是这些几何特征。3.1 投影分割法原理与实现投影分割法是最基础、也最好理解的字符切分方式。对二值图统计每一列中前景像素的总数会得到一个数组也就是垂直投影。字符所在列的投影值通常很高而字符间隙的投影值接近0。从左到右扫描这个数组遇到“投影从0变为正数”标记起始位置遇到“投影从正数变为0”标记结束位置就得到了每个字符的左右边界。import numpy as np def segment_by_projection(binary): h, w binary.shape column_sums binary.sum(axis0) // 255 segments [] in_char False start 0 for x in range(w): if column_sums[x] 0 and not in_char: in_char True start x elif column_sums[x] 0 and in_char: in_char False segments.append((start, x)) if in_char: segments.append((start, w - 1)) return segments这段代码逻辑很简单但实际使用时有一个非常经典的问题字符断裂。比如数字“4”的斜杠和竖杠之间如果因为二值化阈值设置不当产生断裂投影曲线会在字符中间出现一个低谷甚至直接掉到0导致一个字符被切成两半。后续识别时半个“4”可能被认成“1”或其他数字而且还会连带导致字符总数变多整串验证码错判。3.2 连通域分析更稳的兜底方案当字符之间没有粘连时连通域分析比投影法可靠得多。它的思路是把二值图上所有彼此连通的白色像素块找出来每个块就是一个独立的字符候选。OpenCV提供了现成接口num_labels, labels, stats, centroids cv2.connectedComponentsWithStats(binary)返回的stats里包含每个连通域的起始坐标、宽高和面积。我一般会额外加两层过滤面积太小的块直接丢弃多半是残余噪点宽高比明显不合理的块重点观察可能是字符断裂或干扰线残留。连通域分析能处理字符倾斜、高低错落的情况只要字符本身是连通的它就能正确切出来。3.3 粘连字符的切分思路验证码一旦生成字符粘连情况就复杂了。粘连的本质是多个字符在投影方向上有重叠区域投影法会把它们当成一个整体。这里有两个常见思路。第一种叫投影波谷切分对一个粘连块重新计算它的垂直投影找到投影值局部最低的位置作为切点。这个思路适合“轻微粘连”比如两个字符只有一两列像素贴在一起投影曲线还能看出明显的波谷。第二种叫滴水算法模拟水珠从字符顶部沿着像素轮廓往下滴在字符之间的“瓶颈”区域自然形成一条分割路径。滴水算法对倾斜粘连更有效但实现复杂度高而且参数敏感。说句实在话如果验证码本身就允许字符大面积粘连靠散装分割去硬切准确率天花板很低。与其在分割上死磕不如换端到端识别的思路这部分我在后面的踩坑总结里会专门展开。3.4 分割后为什么要归一化切出来的字符图尺寸各不相同有的是20x30有的是18x28直接送进分类器肯定不行。归一化有两种做法一种是直接拉伸把所有图用cv2.resize统一到比如32x32快是快但会把“1”拉得又矮又胖和“7”混淆另一种是填充分割先把字符贴到正方形的空白画布中间再缩放到统一尺寸这样既能保持宽高比又不会引入太多形变。对于数字识别我用后者字符“1”的细长形态能完整保留。4. 识别环节从模板匹配到轻量级卷积网络分割做完之后手里就是一张张单独的字符图接下来要做的事是判断每张图到底是0到9中的哪一个。这一步可选方案很多从最简单的模板匹配到传统机器学习再到深度学习各有各的适用场景。我建议的做法是先跑通简单的再用更复杂的方案去提升天花板。4.1 模板匹配适合当基线模板匹配的思路很朴素给每个数字准备若干张标准样本图把待识别字符图和所有模板逐一比对选相似度最高的那个作为结果。OpenCV的matchTemplate配合TM_CCOEFF_NORMED是比较常用的做法它计算的是归一化相关系数对亮度缩放不敏感。这种方案有一个前提验证码的字体必须是固定的且字符没有形变。如果验证码每次都用同样的字体生成模板匹配能轻松到85%以上。但换个字体或者字符有轻微旋转识别率立刻跳水。所以我把模板匹配定位成“基线方案”它训练成本为零能帮你快速验证预处理和分割环节是否正常。如果模板匹配整体准确率都不到80%问题几乎肯定出在前面两个环节而不是识别。4.2 HOG特征加SVM传统机器学习的稳妥路线比模板匹配高一个档次的方案是特征提取加分类器。简单说就是把字符图像转换成一组特征向量喂给线性SVM做分类。HOG方向梯度直方图是其中很适合字符识别的特征它统计图像局部区域内梯度方向分布能捕捉字符的轮廓形状信息对轻微的笔划变形比原始像素鲁棒得多。当年行人检测领域能靠HOGSVM大杀四方靠的就是对形状信息的强描述能力。实际用法上OpenCV已经封装好了HOGDescriptor提取特征后拼成训练矩阵再用SVM训练即可。这一路线的优势是训练成本低几百张样本就能跑出不错的效果劣势是对扭曲、粘连严重的字符HOG的特征表达能力开始跟不上。4.3 轻量级卷积网络简单结构也能打如果验证码带变色、扭曲、干扰线叠加传统方法就很难办了。卷积网络通过多层卷积核自动学习字符的局部纹理和整体结构特征鲁棒性明显更强。对于纯数字识别网络不需要很大我用的是一个非常精简的结构输入32x32灰度图经过两层卷积加池化再经过一层全连接最后softmax输出10个类别的概率。训练时用交叉熵损失Adam优化器学习率1e-3数据集400个样本左右就能开始训练。如果样本量不足可以用随机旋转、平移、缩放、加噪等方式做数据增强。实测下来训练15个epoch左右单字符准确率就能到98%以上。这个表现几乎是模板匹配和SVM望尘莫及的代价是需要准备带标签的数据集。4.4 三种方案怎么选三种方案的适用场景差别很大我用一张表总结方案适用条件训练成本典型单字符准确率推理速度模板匹配字体固定无扭曲变形几乎为零85%左右极快HOG SVM字体变化不大少量干扰低几百样本即可92%左右很快轻量级CNN字体变化大有干扰或扭曲中需构建带标签数据集98%以上快至于选哪种我的原则是先用模板匹配跑通全流程确认预处理和分割没有大问题然后根据准确率瓶颈决定是否升级到SVM或者CNN。千万别一开始就上复杂模型模型越复杂排查问题成本越高。5. 实测记录一套纯数字验证码识别的完整跑通过程理论和代码看再多都不如自己跑一遍。为了定量评估整个流程我用程序生成了一批模拟验证码字体随机选三种背景加噪点再随机画几根干扰线。这批样本的特点是字体有变化但字符不扭曲、不粘连属于入门偏常见的类型。5.1 测试样本的生成方式用程序生成带标签的验证码样本是评估识别流程最推荐的方式。因为真实网站的验证码收集麻烦、标注也累而且很难保证多样性。我自己写了个脚本用三种字体、随机数字组合生成1200张图片其中1000张做训练集200张做测试集。每张图片尺寸统一为120x40加高斯噪声和一到三根随机干扰线。这样整个环节的变量是可控的预处理、分割哪个环节准确率不达标都能精确定位。5.2 预处理和分割的实测效果先跑预处理部分把彩色图转为灰度、Otsu二值化、中值滤波去噪再用3.1的投影分割法切字符。整体效果在预期之内大部分样本的分割是正确的字符边界干净。但测试集里有个别字符因为阈值原因产生了断裂比如一个“0”被切成了两段投影曲线中间出现了接近0的波谷分割结果变成了三段这种样本在后面的识别环节直接被判错。另有几个样本因为干扰线和字符有重叠面积形态学开运算没有完全清干净导致字符边缘多了一小块白但还不至于影响分割。5.3 三种识别方案的实测准确率对比在这一批模拟样本上我逐一跑完了三种识别方案结果整理如下方案单字符准确率整串准确率4位全对主要错误类型模板匹配84.5%51.0%“0”和“8”混淆“1”和“7”混淆HOG SVM91.7%69.3%“5”和“6”混淆边缘断裂字符判错轻量级CNN98.2%92.8%字体差异大的情况下个别误判这里有一个很重要的数学关系值得展开讲。整串准确率并不等于单字符准确率的简单累加而是所有字符都预测正确的联合概率。如果验证码有4位单字符准确率是98.2%假设各字符独立整串准确率就是98.2%的四次方约等于92.8%。实测数值和这个估算基本吻合。换句话说不要只盯着单字符准确率看真正影响用户体验的是整串准确率。5.4 失败样本归因我把识别失败的样本打出来逐一检查结果很能说明问题三分之二的失败来自分割环节比如字符断裂产生的多余切片、干扰线残留造成的边界污染只有三分之一是识别模型本身的误判。这个发现让我把精力重新投向了预处理和分割而不是继续堆模型复杂度。如果你的项目准确率卡在90%上不去建议先把错误样本全部打印出来归因而不是盲目换更大的模型。6. 踩坑总结这些细节决定了识别率的天花板做完整个流程后我把最有价值的经验整理成几条每条都是真金白银踩出来的。6.1 先定位瓶颈再决定要不要上深度学习很多开发者一提到识别就想到深度学习把所有希望都押在模型上。但实际项目中预处理和分割出错导致的失败远远多于识别模型出错。我的判断方式是先用模板匹配跑通全流程把每张失败样本的中间结果图打出来看是预处理的问题、分割的问题还是识别的问题。大多数时候瓶颈在分割跟模型一点关系都没有。6.2 二值化阈值永远不要写死固定阈值只适合理想场景真实验证码图片的亮度、对比度差异很大。Otsu能自动适应大部分情况遇到背景有渐变或纹理的验证码再考虑自适应阈值。阈值方向也容易踩坑字符是深色还是浅色决定了用THRESH_BINARY还是THRESH_BINARY_INV方向反了对后续分割是毁灭性的。6.3 每步中间结果都要可视化这是最高效的调试手段训练流程里我习惯把灰度图、二值图、降噪图、分割结果图、识别结果图全部输出到同一个debug目录按样本ID命名。排查问题时从最后一环往回看识别错了看分割对不对分割错了看二值图干不干净二值图不干净回头调预处理参数。一个肉眼只要半秒就能发现的低级错误光靠打印数组可能几个小时都定位不出来。6.4 面对扭曲和严重粘连考虑端到端识别如果验证码字符扭曲变形或者大面积粘连散装的三段式管线会有天然瓶颈分割一旦出错后面全错。这时候不要硬刚分割算法而是考虑端到端识别方案具体就是CNN加RNN再加CTC的思路。简单理解CNN负责提取整张图的视觉特征RNN负责捕捉序列顺序关系CTC负责把特征序列映射成最终字符串全程不需要显式计算字符边界。这种方案训练门槛高一些但鲁棒性远强于三段式管线是处理复杂验证码的正解。6.5 图片格式选择PNG而不是JPEG这是我踩过的一个非常隐蔽的坑。验证码图片保存或传输时如果经过了JPEG有损压缩字符边缘会出现振铃效应和色块污染二值化之后边缘全是毛刺直接影响分割效果。处理验证码图片任何时候都用PNG格式保存PNG是无损压缩能保留干净的字符边界。这个小细节在预处理阶段看不出问题到分割报错时才追悔莫及。最后分享一个实操小习惯我会把整个识别的核心逻辑封装成一个CaptchaSolver类输入图片路径输出识别结果和一张带标注的调试图。开发阶段看调试图上线阶段只看结果。这个习惯让我能把注意力快速集中到错误样本上也是我做任何图像识别项目都会保留的工作方式。这个项目做完我对“验证码识别”这四个字的理解深了不少——它其实是一个把图像处理、几何分析、机器学习串起来的综合练手场每个环节都值得单独研究。