简介:面向计算机视觉与 Python 开发者的 YOLOv9 验证码识别项目,完整覆盖图片预处理、数据集组织、模型训练、结果预测与精度校验全流程,依托 YOLOv9 实时目标检测能力对验证码中的字符目标进行定位与分类,适合有目标检测基础、希望快速落地验证码识别方案的工程师学习。压缩包共 2000 个文件,约 186.29MB,其中 1986 个 txt 文件为数据标注与说明文档,10 个 py 文件实现数据加载、训练、验证与预测主逻辑,并提供多种训练与验证入口,4 个 yaml 文件用于配置模型结构、训练超参与数据集路径,目录结构清晰,便于按模块二次开发。已有 313 人学习下载。资源内包含可直接运行的训练与预测脚本,并配备与图片对应的完整标注文本,省去繁琐的数据整理环节;按标准流程即可完成从模型训练到单张验证码识别的完整链路,还能通过灵活调整参数适应不同数据集,是搭建验证码识别系统的高质量参考实现。
1. 为什么验证码识别会用到 YOLOv9
把验证码识别当成 OCR 来做,是很多人踩进去的第一个坑。传统 OCR 管线对字符提取、二值化、分割的依赖很重,一旦验证码里出现旋转字符、粘连笔画、干扰线或者背景噪点,分割这一步就出错,后面的识别率直接崩掉。而把验证码里的每个字符当成一个独立目标来检测,问题就变成了“图里有什么、在哪个位置”,这正是 YOLO 系列擅长的任务。YOLOv9 在这种场景下的优势在于,它的梯度流设计对重叠、小目标和模糊边缘的语义保留更好,字符级检测比之前的版本更稳。
这篇文章围绕“基于 Python 的 YOLOv9 验证码识别”展开,从任务边界、数据管线、训练参数到推理解码和误识别排查,给出一套可复现的做法。适合两类人:一类是刚接触目标检测、想拿验证码练手的 Python 开发者,另一类是做爬虫、自动化测试、风控对抗,需要落地一个字符型验证码识别服务的工程师。滑块验证码和点选验证码不在本文讨论范围内,前者属于轨迹模拟问题,后者要加目标匹配逻辑,完全是另一条路。
2. YOLOv9 做验证码识别的可行性边界与数据形态设计
2.1 字符检测不是文字识别:任务边界先划清楚
验证码识别这个需求,拆开来看有三种实现路线。第一种是全图分类,把整个验证码图片直接扔给 CNN,输出一个字符串,但字符数量一变就得重新训练,完全不灵活。第二种是先把图片切成单个字符,再逐个做分类,切割点一旦偏了,后面全错。第三种就是把字符当目标框检测出来,再做序列排序,输出字符串。YOLOv9 走的就是第三条路,它天然输出位置和类别,适合字符数量不固定、排列带倾斜角度的验证码。
我一般会把这类任务定义为“细粒度目标检测”:验证码字符通常只有几十像素宽,整张图也不大,模型要同时抓位置和类别。YOLOv9 在模型结构上比 YOLOv5/YOLOv8 更重,但也带来了更好的梯度传播,字符边缘不清晰或透明度低的时候,特征保留更完整。代价是推理速度稍慢,不过验证码图片小,一次推理也就是十几毫秒,在实际场景里完全可以接受。
2.2 YOLOv9 的梯度设计对细粒度字符有什么实际帮助
YOLOv9 的核心创新是 PGI(可编程梯度信息)和 GELAN 结构。PGI 的核心思路是让浅层分支也拿到足够清晰的梯度信号,这解决了深层网络在反向传播时信息衰减的问题。对验证码这种目标小的场景,直观感受就是字符的轮廓更不容易糊,尤其当背景有干扰线、字符和干扰线颜色接近时,模型不会把干扰线当目标。
GELAN 是 ELAN 的改进版,通过不同感受野的卷积分支组合,让模型在保持轻量的同时提升精度。对验证码来说,这意味着同一个字符在尺度变化、轻微拉伸、旋转 10 度以内的变形下,检测稳定性比 YOLOv5 好。实际训练时能看到一个明显现象:验证集 mAP 可能只比 YOLOv5 高零点几个点,但推理到真实业务样本上时,错位和漏检明显减少。
不过要说明一点,YOLOv9 不是银弹。如果字符粘连严重到人眼都难以分辨,检测模型也会漏检或误检。常见的处理是在数据里加入字符间距随机化,而不是强行追求模型能力。
2.3 把原始验证码整理成 YOLO 标注格式
YOLO 的标注格式是每个 txt 文件对应一张图片,每行内容为:类别 id 归一化中心 x、中心 y、宽度 w、高度 h。假设你已经从某个登录页采集了一批验证码,或者自己生成了合成验证码,下一步就是把这些图片转成 YOLO 格式。这里给出一个从 JSON 标注到 YOLO 格式的转换脚本:
import json import os def json_to_yolo(json_path, img_w, img_h, out_txt_path): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) lines = [] for box in data["boxes"]: cls_id = box["class_id"] x1, y1, x2, y2 = box["x1"], box["y1"], box["x2"], box["y2"] cx = ((x1 + x2) / 2) / img_w cy = ((y1 + y2) / 2) / img_h bw = (x2 - x1) / img_w bh = (y2 - y1) / img_h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") with open(out_txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) # 用法:json_to_yolo("captcha_001.json", 160, 60, "captcha_001.txt")坐标转换的逻辑很简单,把左上角和右下角坐标换算成中心点坐标,再做归一化。注意img_w和img_h必须是原始图片的尺寸,不是缩放后的尺寸。很多人在这一步出错,导致训练时标注框全偏。建议转换完成后随便挑几张图,用 OpenCV 画框叠加到原图上人工检查一遍。
2.4 数据类别的组织方式
验证码字符集一般分三类:纯数字、小写字母、数字加小写字母。类别定义直接决定模型的输出维度,建议把字符类别作为全局常量管理:
import string charset = "0123456789abcdefghijklmnopqrstuvwxyz" char_to_id = {c: i for i, c in enumerate(charset)} id_to_char = {i: c for i, c in enumerate(charset)}| 字符集类型 | 类别数量 | 适用场景 |
|---|---|---|
| 纯数字 | 10 | 简单业务验证码,训练量小 |
| 数字 + 小写字母 | 36 | 常见于中大型平台,覆盖率高 |
| 数字 + 大小写字母 | 62 | 高安全性验证码,容易混淆 I/l/1,需加大样本 |
类别数量多的时候,容易混淆的字符对要重点加样本,比如0/O、1/l/I、z/Z。YOLOv9 的类别损失对样本不均衡是敏感的,我一般在配置文件的loss里不做特殊处理,而是直接在数据采样上保证每个类别至少 300 个实例,效果比调损失函数更直接。
3. 用 Python 搭出 YOLOv9 验证码识别的数据管线
3.1 环境准备:Python 虚拟环境与依赖安装
YOLOv9 的代码基于 PyTorch 实现,环境隔离很关键。常见做法是先创建独立的虚拟环境,再安装依赖:
python -m venv yolov9_env source yolov9_env/bin/activate # Windows 下执行 yolov9_env\Scripts\activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install opencv-python pillow numpy tqdm pyyamltorch 的安装版本建议先查一下 CUDA 版本,nvidia-smi显示的是什么版本就装对应预编译包。不想用 GPU 训练的话,纯 CPU 环境也能跑通流程,只是训练速度会慢很多,合成数据量不大的时候勉强能接受。训练和推理必须用同一套 Python 环境和依赖版本,否则容易出现模型权重文件加载后输出异常的问题。
YOLOv9 的项目结构一般是train.py、detect.py、models/、data/这样的组织方式。首次跑通之前,不要急着换数据集,先用自带的预训练权重跑一次检测 demo,确认环境链路是通的。
3.2 用 Pillow 批量生成带标签的合成验证码
真实验证码数据采集成本高,而且涉及合规风险。常见做法是先合成数据把模型跑通,再用真实样本做二次训练。合成数据的好处是标签天然准确,不需要人工标注。下面这段脚本生成 4 个字符的验证码,背景加噪点,字符加轻微旋转:
from PIL import Image, ImageDraw, ImageFont import random import string charset = "0123456789abcdefghijklmnopqrstuvwxyz" W, H = 160, 60 def random_color(): return (random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)) def gen_captcha(save_dir, num_samples=1000): font = ImageFont.truetype("arial.ttf", 36) for i in range(num_samples): code = "".join(random.choices(charset, k=4)) img = Image.new("RGB", (W, H), (255, 255, 255)) draw = ImageDraw.Draw(img) # 干扰线 for _ in range(5): draw.line([(random.randint(0, W), random.randint(0, H)), (random.randint(0, W), random.randint(0, H))], fill=random_color(), width=2) # 噪点 for _ in range(200): draw.point((random.randint(0, W-1), random.randint(0, H-1)), fill=random_color()) # 每个字符独立画,记录坐标 boxes = [] x_cursor = random.randint(10, 20) for ch in code: ch_img = Image.new("RGBA", (50, 50), (255, 255, 255, 0)) ch_draw = ImageDraw.Draw(ch_img) ch_draw.text((5, 5), ch, fill=(0, 0, 0, 255), font=font) ch_img = ch_img.rotate(random.randint(-25, 25), expand=True) y_offset = random.randint(0, 10) img.paste(ch_img, (x_cursor, y_offset), ch_img) boxes.append((x_cursor, y_offset, x_cursor + ch_img.width, y_offset + ch_img.height)) x_cursor += ch_img.width + random.randint(0, 5) # 写标注文件 with open(f"{save_dir}/{i}.txt", "w") as f: for (x1, y1, x2, y2) in boxes: cx = (x1 + x2) / 2 / W cy = (y1 + y2) / 2 / H bw = (x2 - x1) / W bh = (y2 - y1) / H f.write(f"{char_to_id[ch]} {cx:.4f} {cy:.4f} {bw:.4f} {bh:.4f}\n") img.save(f"{save_dir}/{i}.png")合成数据的核心参数有三个。旋转角度范围建议在 ±30 度以内,超过这个范围字符之间容易重叠,反而增加训练难度。字符间距随机化很重要,固定间距会让模型学到“间距均匀”这个不存在的特征。字体选择上,不要只用一种字体,多个字体混用能明显提升泛化能力。
3.3 人工标注补数据时的格式转换
合成数据跑通后,真实数据才是提升上限的关键。人工标注时我一般用 labelImg 或者 labelme,导出格式不是 YOLO 的 txt,而是 Pascal VOC 的 XML 或 JSON。第 2.3 节的转换脚本假设输入是 JSON,实际用 labelme 标注时字段名略有不同,按需调整即可。
注意一个细节:标注盒子的边界要贴着字符的实际边缘,不要留太多白边。YOLO 的 anchor 匹配机制里,标注框的白边会影响正样本挑选。字符周围的空白区域大时,框的中心点可能落在背景区域,导致该目标没有匹配到合适的 anchor。
3.4 数据校验:训练前先看一遍标签图
训练跑起来之前,花几分钟把标签画到图上检查。用 OpenCV 读标注文件并绘制矩形框:
import cv2 import numpy as np def draw_boxes(img_path, txt_path): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: for line in f: cls_id, cx, cy, bw, bh = map(float, line.split()) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 1) cv2.imshow("check", img) cv2.waitKey(0)这一遍看下来,能发现的典型问题有:框偏移导致字符只框住一半、框过大把干扰线包含进去、旋转后的字符框变成了倾斜矩形但 YOLOv9 不支持旋转框标注,只能用正矩形近似。这些问题越早发现越省时间。
4. YOLOv9 训练配置:命令行、超参与收敛排查
4.1 数据配置文件和最小训练命令
YOLOv9 的数据配置文件是 YAML 格式,指向训练集和验证集的图片路径,以及类别名列表:
# captcha.yaml path: ./datasets/captcha train: images/train val: images/val nc: 36 names: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']训练命令用项目里的train.py:
python train.py \ --data captcha.yaml \ --cfg models/detect/yolov9-c.yaml \ --weights yolov9-c.pt \ --batch-size 32 \ --epochs 200 \ --imgsz 640 \ --device 0--cfg指定模型结构,yolov9-c是轻量版本,验证码这种小图上够用。--weights加载预训练权重,能显著加快收敛,但要注意预训练权重是在 COCO 上训的,类别数不同,加载时只复用 backbone 参数。--imgsz默认是 640,验证码图片很小时建议直接设成原图尺寸的倍数,或者关闭多尺度训练。
4.2 影响字符检测的 5 个训练参数
| 参数 | 建议值 | 影响说明 |
|---|---|---|
--batch-size | 32 或 64 | 显存不足时优先降 batch,不要降 imgsz |
--epochs | 150~300 | 合成数据 150 轮左右可收敛,真实数据要 300 轮 |
--imgsz | 416 或 640 | 图片小(如 160x60)时,640 会放大模糊,建议 416 |
--mosaic | 1.0(默认开启) | 验证码字符太小,mosaic 混合时标签容易错位,建议在真实数据上关掉 |
--label-smoothing | 0.1 | 类别多时防过拟合有效 |
字符检测场景里最容易被忽略的是--mosaic。mosaic 增强把四张图拼在一起,对一般物体检测效果好,但验证码图片里的字符本来就稀疏,拼接后目标位置和尺度分布变化过大,训练容易不稳定。我一般合成数据阶段用 mosaic,切到真实数据阶段把 mosaic 关掉,或者把它从默认的 1.0 降到 0.2。
--imgsz也很关键。验证码原图可能只有 160x60,直接放大到 640 会引入大量插值模糊,字符边缘损失严重。把--imgsz设为 416 或 320,反而让模型看到更真实的纹理。检测框坐标在输出时是按输入尺寸归一化的,所以输入尺寸改变不会影响最终坐标换算。
4.3 Loss 不降、全部漏检时的排查顺序
训练时 loss 曲线不降,最优先检查的不是超参数,而是数据。
第一步检查标签文件路径是否配对。路径写错时模型看到的全是空白图,loss 不降是正常的。第二步看类别 id 是否从 0 开始连续编号,YOLO 的损失函数里类别是 one-hot 计算的,id 跳号会直接报错或训练异常。第三步看正样本数量:验证码每张图只有 3~5 个目标,合成数据还能应对,真实数据量少时建议先用预训练权重做冻层训练,只训练检测头几轮,再全量微调。
全部漏检还有一种情况:验证码字符是浅色背景深色文字,但模型是在 COCO 上预训练的,COCO 里浅色背景的小目标占比不高。这时可以在训练配置里增加hsv_h、hsv_s的色彩增强强度,让模型更适应白底黑字的输入分布。
5. 推理解码:把 YOLOv9 的检测框还原成验证码字符串
5.1 单帧推理与结果过滤
训练完成后,直接用detect.py能出可视化的检测图,但接入业务系统时要自己写推理逻辑。下面这段代码用 YOLOv9 的models.experimental.attempt_load加载权重并做单图推理:
import cv2 import torch import numpy as np device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = attempt_load("runs/train/exp/weights/best.pt", device=device) model.eval() img0 = cv2.imread("captcha_test.png") # 原始 BGR 图 img = letterbox(img0, 640, stride=32)[0] img = img[:, :, ::-1].transpose(2, 0, 1) # BGR -> RGB, HWC -> CHW img = np.ascontiguousarray(img) img_tensor = torch.from_numpy(img).to(device).float() / 255.0 img_tensor = img_tensor.unsqueeze(0) with torch.no_grad(): pred = model(img_tensor)[0] pred = non_max_suppression(pred, conf_thres=0.45, iou_thres=0.3)[0]letterbox是 YOLO 系列常用的缩放方式,保持宽高比不变,多余的边补灰边。推理得到的pred是未解码的原始输出,必须经过non_max_suppression过滤。置信度阈值这里给的是 0.45,实际使用中要根据字符和背景的对比度调整:对比度高的验证码可以放宽到 0.3,背景复杂的要提高到 0.6。iou_thres设 0.3 即可,字符之间本来就不该有重叠框。
5.2 检测框排序与字符序列还原
NMS 输出的每个框包含[x1, y1, x2, y2, conf, cls_id]。验证码的字符顺序是按照从左到右读取的,所以要先按x1坐标排序。但当字符存在倾斜或垂直方向错位时,单纯按x1排序会出错,需要分两步:先用聚类思路把字符按 y 中心分组,组内按 x 排序,最终按组的 y 中心从上到下拼接。
boxes = pred[:, :4].cpu().numpy() confs = pred[:, 4].cpu().numpy() clses = pred[:, 5].cpu().numpy() cy_vals = (boxes[:, 1] + boxes[:, 3]) / 2 order = np.argsort(cy_vals) boxes, confs, clses = boxes[order], confs[order], clses[order] decoded = "" for box, conf, cls_id in zip(boxes, confs, clses): x1, y1, x2, y2 = box.astype(int) char = id_to_char[int(cls_id)] decoded += char上面的代码只做了 y 排序,实际场景中水平错位更常见,改成两行结构的验证码才需要先分行。单行字符直接按x1 + (x2 - x1) / 2排序即可。解码结果优先交给业务侧使用,检测框的坐标信息也不要丢掉,可以用作人工审核的可视化凭证。
5.3 用置信度做二次校验
验证码识别不追求单次 100% 正确,而是允许一次失败后刷新重试。结合置信度能明显提升整体成功率:识别结果中某个字符的置信度低于 0.4,或者整个序列的平均置信度低于 0.5,直接丢弃本次结果,要求重新拉取验证码,而不是把错误结果提交给业务方。后端加一层异常重试逻辑后,单字符误识别的负面影响会被稀释。
6. 把 YOLOv9 验证码识别包装成服务并排查误识别
6.1 保存错误样本回灌训练集
识别错误是验证码方案里逃不开的事,关键在于把错误样本变成训练数据。在服务里加一个保存逻辑:当业务方上报识别失败,或系统判定置信度过低时,把原始图片连同模型预测的类别存下来,每天定时人工抽一批修正标注,合并进训练集做增量训练。这个闭环用起来之后,模型在真实流量上的表现会持续变好。
6.2 面向动态验证码的缓存处理
很多业务的验证码是动态刷新的,同一张图片短时间内可能被请求多次。服务端加一个基于图片哈希的缓存,把识别结果缓存 10~30 秒,能显著降低重复识别压力。动态验证码还会出现图片本身带噪点但验证码文本不变的情况,哈希缓存会失效,建议用感知哈希(pHash)而不是 MD5,类似但微小的图片差异也能命中缓存。
6.3 最后一招:字符长度固定时的后处理纠错
如果目标验证码的字符长度固定,比如恒为 4 位,可以在后处理阶段利用这个约束。当模型检出 3 个框,说明有字符漏检;检出 5 个框,说明有背景误检,这时不要直接输出,而是强制取置信度最高的 4 个框,并检查每个框的宽度是否明显偏离均值,偏离超过 40% 的框大概率是误检。这个后处理规则虽然简单,在字符粘连场景里能挽回不少错误样本。结合字符类别间的常见混淆对做二次校正:识别结果是0o3x的时候,优先把第二个字符修正为o而不是0,因为o出现的概率更高。
本文还有配套的精品资源,点击获取