简介:面向需要处理网站验证码识别的Python开发者,这份源代码提供了一套完整的OCR高精准识别方案,覆盖图像预处理、字符定位、Tesseract与PaddleOCR识别引擎接入以及深度学习模型优化等环节,适合对图像识别与自动化安全验证感兴趣的初中级开发者。资源共76个文件,以47个py源码为主,辅以12个运行库、PaddleOCR模型文件、界面文件、可执行程序和配置说明,压缩包大小约62MB,目录结构清晰便于按模块查阅。已有155人学习下载。通过研究源码可掌握常用图像处理库在二值化、降噪和字符分割上的应用,理解PaddleOCR在验证码场景的调用方式,还可利用打包脚本将识别逻辑封装为独立工具,并参考环境依赖和使用文档快速搭建开发环境,实现从模型训练到实际部署的完整流程,实践价值较高。 验证码识别这件事,凡是做自动化开发的,早晚都会撞上。接口测试被登录验证码卡住、数据采集流程被图片验证码拦截、内部系统需要定时登录却总差一步人工输入,解法无非三条路:接付费云OCR、调开源的tesseract、或者自己训练一套专用模型。前两条路上手快,可验证码一旦加了干扰线、噪点、字符扭曲,识别率就断崖式下跌,免费方案更是几乎没法用。我前前后后折腾了将近两个月,最终用Python完整实现了从数据生成、模型训练到目录批量识别的一整套验证码OCR方案,在目标验证码上做到98%以上的字符准确率。这篇文章不聊虚的,把我这套“Python验证码高精准OCR模型源代码”的选型逻辑、踩坑过程和能直接复用的代码骨架全部放出来,适合手里有真实自动化需求、想绕开云服务自己做本地识别的开发者参考。
1. 验证码识别的破局点:为什么“切字”这条路走不通
1.1 字符分割的连环坑
大部分人的第一直觉是:先把验证码里的字符一个个切出来,再丢给普通OCR或分类器识别。这个思路放在扫描文档上没问题,放到验证码上就处处碰壁。验证码的设计目标本身就是阻止机器自动处理,而破坏字符分割恰恰是最低成本的手段。
我实际测试中遇到的典型问题包括:
- 字符粘连,前一个字符的笔画直接和后一个连在一起,投影法找分割点切出来全是歪七扭八的残片;
- 字符旋转、倾斜、缩放不统一,切出来的每个图块尺寸差异很大,分类器输入规范化后形变严重;
- 干扰线贯穿字符间隙,会把一条弯曲的干扰线误判成有效字符区域;
- 验证码位数不固定时,切割逻辑更难写,切多了、切少了都没法处理。
更要命的是,切割本身是一个额外引入误差的环节。即使每个字符都切准了,从“整图”到“多个子图”的转换也会丢失上下文信息;一旦切错一次,后续分类再准也没有意义。这种级联放大误差的结构,从一开始就决定了它很难做到高精准。所以我在项目初期就明确砍掉了“先分割后识别”的路线,转向端到端的序列识别方案。
1.2 “高精准”的正确打开方式:先定义边界,再谈准确率
再说一个容易被忽略的问题:什么叫“高精准”?
我做这个项目时给自己定的目标非常具体——对某一类验证码,例如4到6位数字和字母组合、带1到3条随机干扰线、有轻微扭曲变形的样式,在真实截图样本上达到95%以上的整串识别率,字符级准确率争取超过98%。这里“整串准确率”和“字符准确率”是两个完全不同的指标。字符准确率只要单字对就算对,一串有4个字符的验证码,只要错1个字符,整串就是错的。所以当别人说自己的模型准确率99%时,你一定要追问一句:是字符级还是整串级?两个口径差出来的可能就是10个百分点。
先定义边界还有一个实际好处:训练数据的规模和采样范围都变得可控。如果一开始就想做一个“识别所有验证码”的通用引擎,训练集可能需要覆盖几十种样式,数据采集和标注的成本直接爆炸,效果也未必理想。下面是不同验证码类型的难度分级和我踩过的可行性评估,供参考:
| 验证码类型 | 难度 | 主要干扰 | 方案可行性 |
|---|---|---|---|
| 纯数字、白底 | 低 | 几乎没有 | tesseract或小型CNN即可 |
| 数字+简单噪点 | 中低 | 椒盐噪声、细线 | 小型CNN即可 |
| 字母数字+扭曲 | 中高 | 旋转、干扰线、粘连 | CRNN+CTC,效果稳定 |
| 复杂纹理+强扭曲 | 高 | 背景纹理、笔画变形 | 需要大量真实样本,成本高 |
| 滑块/点选/拼图 | 极高 | 行为特征,非纯OCR问题 | 本方案的模型不适用 |
我的经验是:先收集目标验证码的真实样本,把样式特征逐项记录下来,再按这个规格去设计生成器和训练集。把边界定义清楚,后面的工作才谈得上效率。
2. 模型选型的完整推演:CRNN+CTC为什么是主路线
2.1 三种候选方案的实际对比
模型选型阶段,我认真对比过三条技术路线,也分别做了小规模实验。
第一条是纯CNN多标签分类。把验证码当作一个固定长度的多标签分类任务,比如4位验证码就接4个softmax分类头。这个方案训练速度快、代码简单,但有两个硬伤:一是位数固定,换个长度不一样的验证码就得改网络结构;二是它对字符位置和宽度的变化非常敏感,字符稍微错位或者被拉伸,分类头的对齐就乱了。实际测试下来,遇到字符位置随机偏移的验证码,准确率掉得很快。
第二条是目标检测加字符分类。先用一个检测模型框出每个字符的位置,再把每个框裁下来做分类。思路听起来更“智能”,但代价是训练时要标注每个字符的边界框,标注成本极高。更麻烦的是,很多验证码字符是粘连的,检测框本身就切不干净,后续分类再强也会被错误输入拖累。标注成本高、误差传播链路长,这个方案也被我淘汰了。
第三条就是我最终采用的CRNN+CTC。它把整张图片作为输入,卷积网络负责提取视觉特征,双向LSTM负责建模字符之间的时序关系,CTC负责把不定长的序列输出对齐到最终的字符串标签。整条链路是端到端训练的,不需要标注任何字符位置,也不需要指定每个字符对应哪个时间步。位数可变、字符粘连、位置偏移,这些在CRNN看来都是模型自己需要学会处理的变化,而不是需要靠外部规则解决的难题。
2.2 CTC Loss在验证码场景下到底解决了什么
CTC能成为序列识别任务的主力方案,核心在于它解决了“模型输出序列长度和实际字符长度不一致”的问题。
验证码图片输入后,经过卷积和LSTM,会输出一个按时间步分布的字符概率序列。比如一张宽160像素的图片,经过三次池化后宽度方向压缩到20个时间步,而验证码只有4到6个字符。这20个时间步如何和4到6个字符对应?CTC引入了一个空白符号(blank),用它在序列中隔开重复字符,同时允许某些时间步输出空白。训练时,CTC对“所有可能的对齐路径”的概率求和,再计算负对数似然作为损失。它并不要求人为指定“第几个时间步对应第几个字符”,模型会在训练中自己学会“什么时候输出字符、什么时候输出空白”。
这个机制在验证码场景下特别友好。验证码字符之间往往有粘连和重叠,手动对齐几乎不可能,CTC把这个问题从数据处理层面彻底解放掉了。推理时用贪心解码就足够:沿时间轴取每个位置概率最大的符号,合并连续相同字符,再去掉空白符号,剩下的就是预测文本。
这里必须提醒一个常见坑:用PyTorch的CTCLoss时,默认blank=0,所以字符映射必须从1开始编号,否则训练loss会直接乱掉。我第一次跑通时就在这里卡了半天,损失函数一直不收敛,后来才发现是字符索引和blank索引冲突了。
3. 数据生产线:合成生成器、增强策略与真实样本的混合配比
3.1 参数化合成生成器怎么写
模型方案定了之后,紧接着就是数据。真实验证码样本当然最理想,但收集和标注成本高,而且很多网站的验证码样式会不定期更换,旧样本可能很快就失效。所以我的策略是“合成数据打底,真实样本校准”。
合成生成器的核心是参数化,把目标验证码的视觉风格拆解成一个一个可调的变量:
- 字符集:数字、大写字母、小写字母,按需求剔除易混淆字符;
- 字体:加载本机多个不同的字体文件,包括衬线、非衬线、等宽等各种风格;
- 背景类型:纯白、渐变、网格纹理、轻微噪声纹理;
- 干扰元素:随机数量干扰线、噪点、细小颗粒,全部参数可控;
- 形变:随机仿射变换、轻微透视、弹性畸变,模拟真实验证码的扭曲效果。
用Python的PIL库大约200行左右就能写出这套生成器。下面是字符绘制的骨架代码,重点看“随机字体、随机间距、随机位置”是怎么实现的:
from PIL import Image, ImageDraw, ImageFont import random def generate_sample(text, font_paths, width=160, height=48): img = Image.new('RGB', (width, height), (255, 255, 255)) draw = ImageDraw.Draw(img) render_chars = [] total_w = 0 for ch in text: font = ImageFont.truetype(random.choice(font_paths), random.randint(20, 28)) bbox = draw.textbbox((0, 0), ch, font=font) w = bbox[2] - bbox[0] h = bbox[3] - bbox[1] render_chars.append((ch, font, w, h)) total_w += w + random.randint(1, 4) x = (width - total_w) // 2 for ch, font, w, h in render_chars: y = random.randint(4, height - h - 4) draw.text((x, y), ch, font=font, fill=(random.randint(0, 80),) * 3) x += w + random.randint(1, 4) # 这里再叠加随机干扰线和噪点 for _ in range(random.randint(1, 3)): x1, y1 = random.randint(0, width), random.randint(0, height) x2, y2 = random.randint(0, width), random.randint(0, height) draw.line((x1, y1, x2, y2), fill=(random.randint(0, 100),) * 3, width=random.randint(1, 2)) return img生成器写好后,数据成本就变成了一件“按需打印”的事。纯数字4位验证码,我生成2万张就够模型跑出不错的基线;字母数字混合的话,我建议直接生成5万张以上做主力训练集。
3.2 增强不是多多益善,定量调参才是正路
在把图片送进模型之前,我加了一套在线增强流程,每张图都会做随机变换:
- 亮度、对比度扰动,模拟真实截图时的不同光线和色差;
- 随机旋转正负5度,模拟字符位置的小幅偏移;
- 叠加高斯噪声和椒盐噪声,模拟图片压缩产生的伪影;
- 随机遮盖一个细长矩形区域,模拟干扰线穿过字符的效果;
- 弹性畸变,模拟字符笔画本身的扭曲。
增强的目的很明确:让模型在特征层面学到“字符才是稳定信号,噪声是可变的干扰”,而不是把背景和噪点的纹理也当成判断依据。这相当于用更少的原始样本达到更强的泛化能力。
但这里有一个反向教训:增强强度不是越大越好。字符扭曲得太过分,人眼都看不出来原本是什么字符,模型接收到的就是错误标签,等于在训练集里故意灌毒。我在弹性畸变上调过一组参数,sigma取到3.0、alpha取到50,结果验证集准确率不升反降,后来把sigma调回2.0、alpha调到30,效果才恢复正常。增强策略的正确使用方式是“定量观察、小步调整”,每加一种增强手段,都要在验证集上观察准确率趋势,而不是无脑堆叠。
3.3 真实样本混入比例的实验结果
合成样本虽然量大,但和真实场景之间始终存在域差距。真实截图里的背景纹理、压缩失真、字体渲染方式,合成器很难100%复现。
我在训练到中期做了一个对照实验:一组只用5万张合成样本,另一组在合成样本基础上混入6000张人工标注的真实样本,约占总量的11%。结果显示,混入真实样本的那组在真实测试集上的整串准确率比纯合成组高出了约6个百分点。这个提升相当可观,说明所谓“高精准”最终还是要靠真实样本兜底。
我的建议是:如果合成样本和真实样本来自同一个验证码样式,真实样本占比控制在10%到20%之间比较合适;比例太低,域差距消除得不彻底;比例太高,训练集规模被真实标注成本拖累。
4. 核心代码实现:预处理、模型、训练与批量识别工具
4.1 标准化的预处理管线
模型输入尺寸我定成了160乘48像素。大多数目标验证码的宽高比在3比1到4比1之间,这个尺寸既能保留笔画细节,又不会让LSTM的序列长度过长。如果验证码字符特别多,可以放宽到200乘48,但训练时间和显存占用会同步上升。
预处理时有个容易忽略的细节:不能直接暴力拉伸,而要按高度等比缩放后居中放到底图里。直接拉宽会把字符横向拉伸变形,干扰模型学习。我的实现如下:
import cv2 import numpy as np def resize_keep_ratio(img, target_w=160, target_h=48): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) h, w = gray.shape scale = target_h / h new_w = int(w * scale) resized = cv2.resize(gray, (new_w, target_h), interpolation=cv2.INTER_AREA) canvas = np.full((target_h, target_w), 255, dtype=np.uint8) if new_w <= target_w: x_offset = (target_w - new_w) // 2 canvas[:, x_offset:x_offset + new_w] = resized else: start = (new_w - target_w) // 2 canvas = resized[:, start:start + target_w] return canvas白底画布比黑底画布更合适,因为验证码字符通常是深色,浅色背景在归一化后接近纯色分布,不会引入额外的干扰信息。
4.2 CRNN模型结构源码
模型结构是经典CRNN的轻量版,用PyTorch实现。参数设计上我没有刻意堆大模型,因为验证码不像长文本那样有海量字符类别,模型过大反而容易过拟合。
import torch import torch.nn as nn class CaptchaCRNN(nn.Module): def __init__(self, num_classes, hidden_size=256, dropout=0.3): super().__init__() self.cnn_stack = nn.Sequential( nn.Conv2d(1, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), # 80x24 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), # 40x12 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)) # 20x12 ) self.lstm1 = nn.LSTM(256 * 12, hidden_size, bidirectional=True, batch_first=True) self.lstm2 = nn.LSTM(hidden_size * 2, hidden_size, bidirectional=True, batch_first=True) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): x = self.cnn_stack(x) # batch, 256, 20, 12 b, c, h, w = x.shape x = x.permute(0, 3, 1, 2).reshape(b, w, c * h) x, _ = self.lstm1(x) x, _ = self.lstm2(x) x = self.dropout(x) return self.fc(x) # batch, 20, num_classes一个关键的细节在最后一个池化层:核大小设置成(2,1),意味着宽度方向不下采样、高度方向压缩。目的是尽量保留特征图在水平方向的分辨率,让LSTM拿到更多的“时间步”。20这个时间步数量,对4到6位验证码来说充裕且高效。
4.3 训练循环中的三个易错点
训练代码本身不复杂,但CTC相关操作的三个细节特别容易写错,值得单独说明。
第一个是target的编码方式。CTC需要的是把所有样本的真实标签拼接成一个一维张量,然后通过target_lengths告诉它每个样本占多长。如果直接把不同长度的标签堆成二维张量,会报维度不匹配的错。
def encode_text(text, char_map): return torch.tensor([char_map[c] for c in text], dtype=torch.long) def train_step(model, optimizer, batch_imgs, batch_texts, device): img_tensor = torch.from_numpy(np.stack(batch_imgs)).float().to(device) img_tensor = img_tensor.unsqueeze(1) / 255.0 # 归一化到0~1 targets = torch.cat([encode_text(t, char_map) for t in batch_texts]) target_lengths = torch.tensor([len(t) for t in batch_texts], dtype=torch.long) seq_len = 20 input_lengths = torch.full((len(batch_texts),), seq_len, dtype=torch.long) logits = model(img_tensor) # batch, seq_len, num_classes log_probs = torch.log_softmax(logits, dim=-1) log_probs = log_probs.permute(1, 0, 2) # CTC需要(T, batch, C) loss = nn.CTCLoss(blank=0, reduction='mean')( log_probs, targets, input_lengths, target_lengths ) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()第二个易错点是log_probs的维度顺序。CTCLoss要求输入是(时间步, batch, 类别数),而模型输出的是(batch, 时间步, 类别数),必须先做permute,否则loss计算出来的数值完全不对。
第三个易错点是input_lengths必须和模型实际输出的时间步完全一致。如果你改了输入尺寸或者池化层,时间步变了,input_lengths也要跟着改。我看到很多人在这里偷懒用固定值,结果模型换了输入尺寸之后,loss神秘地开始报错。
训练超参方面,我用了Adam优化器,初始学习率0.001,配合ReduceLROnPlateau调度器,在验证集准确率停滞时自动衰减为原来的0.5。batch_size设成32,单张消费级显卡上跑50个epoch,5万张左右的训练集大概需要几个小时,完全在可接受范围内。
4.4 目录批量识别与JSON输出
训练完的模型最终要落到工具里。我封装了一个批量识别函数,核心能力是扫描指定目录及其所有子目录下的图片,逐张识别后把结果统一写进JSON文件。这个功能在处理大量历史截图时非常好用,不需要一张张手动丢进脚本。
from pathlib import Path import json import os def scan_and_ocr(root_dir, output_json, model, decode_fn, extensions=('.png', '.jpg', '.jpeg', '.bmp')): root = Path(root_dir) if not root.exists(): print(f"[WARN] 目录不存在: {root_dir}") return [] results = [] for img_path in root.rglob('*'): if img_path.is_dir(): continue if img_path.suffix.lower() not in extensions: continue # 二次防御:排除符号链接等特殊非文件对象 if not os.path.isfile(img_path): continue img = cv2.imread(str(img_path)) if img is None: results.append({'path': str(img_path), 'error': 'decode_failed'}) continue processed = resize_keep_ratio(img) text, conf = predict_single(model, processed) results.append({ 'path': str(img_path), 'text': text, 'confidence': round(conf, 4) }) with open(output_json, 'w', encoding='utf-8') as f: json.dump(results, f, ensure_ascii=False, indent=2) return results这里有两个设计上的细节。一是用Path对象的rglob而不是手写递归,代码更简洁,语义也更清晰;二是用os.path.isfile做二次判断,把目录项和特殊文件对象排除干净。在线API识别方案也可以复用这套扫描框架,只是把predict_single换成HTTP请求,然后把返回的JSON响应解析成同样的结构,上层逻辑完全不用改。
5. 实测调优记录:从92%到98%的完整路径
5.1 第一轮卡在92%,我做了哪些修正
第一版模型在验证集上的字符准确率大约92%,看着还行,但整串准确率只有80%上下,完全达不到可用状态。我花了大量时间做错误样本归因,最后锁定了三个主要问题。
首先是预处理。真实截图的背景里有浅灰色的纹理,我一开始用了硬二值化,结果把背景纹理变成了密密麻麻的小黑点,严重干扰特征提取。换成对比度归一化之后,保留更多灰度信息,字符和背景的区分反而更清晰,这一步大概提升了1.5个百分点的准确率。
然后是数据配比。第一版合成样本占比超过95%,真实样本少得可怜,模型对真实截图里的模糊和亮度不均几乎没有抵抗能力。把真实样本比例提到15%之后,整串准确率直接跳了3个百分点。
最后是模型容量。LSTM隐藏单元从128提高到256,双向拼接后的特征维度更大,模型对相似字符的区分能力明显增强。这三项修正叠加起来,字符准确率稳定到了96%左右,整串准确率也达到了91%,可以投入实际使用。
5.2 相似字符混淆的专项攻坚
字符准确率到96%之后,剩下的大头集中在相似字符混淆上。我专门导出了一张混淆矩阵,发现错误高度集中在“O和0”、“I和1”、“Z和2”这三组字符上,其中O和0占了一半以上。
这个问题不能靠简单地从字符集里删字解决,因为目标验证码里确实会出现这些字符。我最后做了三件事:
- 在生成器里用多种字体分别渲染O和0,刻意拉大两种字符在形态上的差异,让模型多看到两个类别的极端版本;
- 训练时对包含O或0的样本做加权,让这些难样本在每轮迭代中贡献更大的损失;
- 推理端加了置信度阈值,识别结果置信度低于0.65时,在JSON输出里标记requires_review字段,交给人工兜底。
三件事做完,O和0相关的误判降低了大约六成,整串准确率也顺利突破95%,逼近98%。
5.3 批量落地时必须处理的边界情况
代码写完之后,真正在批量场景里跑,还会遇到一堆模型之外的问题。这些问题不处理好,工具就谈不上好用。
第一是输入目录不存在。很多脚本拿到一个不存在的路径就直接抛异常,整个任务中断。我要求scan_and_ocr函数在目录不存在时打印警告并返回空列表,宁可让上层任务拿到空结果,也不能让流程挂死。
第二是图片文件损坏。批量文件里总会有几张下载不全或者编码损坏的图片,cv2.imread会返回None。代码里我单独加了一个error字段,把这些异常图片逐条记录,既不会中断流程,也能事后追踪是哪几张出了问题。这个策略在处理大批量时尤其重要。
第三是非ASCII文件名。Windows环境下如果文件名带中文,json.dump时如果不指定ensure_ascii=False和encoding='utf-8',输出文件里全是转义后的\uXXXX,人工复核很不方便。这个细节虽然小,体验差别却很大。
第四是图片尺寸异常。有些截图特别大,有些特别小。统一走“按高度等比缩放、居中放进标准画布”的策略后,小图放大变模糊的问题模型能够容忍,拉伸变形的问题被彻底避免。
整套方案跑通之后,我的核心体会是:验证码识别这种任务的模型结构说到底是成熟套路,真正拉开差距的地方全在数据质量和工程细节。生成器能不能还原目标验证码的画风、增强参数有没有调到甜点、批量工具对异常输入有没有兜底,这些才是把一个模型从能用做到好用的分水岭。如果照着这篇文章的思路动手,建议你先花时间把数据环节打磨到位,再拿几千张样本验证模型可以收敛,再逐步扩大规模。路线是明确的,剩下的就是耐心和那句老话——好模型都是喂出来的。
本文还有配套的精品资源,点击获取