基于YOLOv8与CNN的中文算数验证码识别实战方案

基于YOLOv8与CNN的中文算数验证码识别实战方案 1. 项目概述与核心挑战最近在做一个内部系统的自动化工具遇到了一个挺有意思的验证码它不再是简单的数字或字母而是中文的算数题比如“三加五等于”、“七减二等于”运算符用的是“加”、“减”、“乘”这三个中文汉字。目标很明确就是让程序能自动识别出这道题算出答案并提交。这看起来像是OCR光学字符识别的典型应用但实际操作起来发现坑点不少远不是调用一个通用OCR接口就能解决的。通用OCR引擎比如Tesseract或者一些云服务API对规整的印刷体中文识别效果尚可但面对这种验证码场景往往力不从心。验证码图片通常带有复杂的背景噪声、扭曲的字体、粘连的字符目的就是增加机器识别的难度。更重要的是我们需要的是一个端到端的解决方案输入一张图片直接输出计算结果。这要求我们不仅要识别出字符还要理解字符之间的数学关系哪个是数字哪个是运算符它们的顺序是什么最后进行运算。这个项目的核心可以拆解为两个主要阶段首先是“字符分割与识别”将图片中的“三”、“加”、“五”、“等于”这几个独立的字符区域准确地框选出来并识别出它们分别是什么其次是“语义理解与计算”根据识别出的字符序列解析出运算式例如识别出“三”、“加”、“五”然后执行相应的数学运算。整个过程涉及到图像预处理、字符定位、模型训练与推理、逻辑解析等多个环节。下面我就把自己从零搭建这个识别系统的完整过程、踩过的坑以及最终稳定的方案分享出来。2. 整体技术方案设计与选型面对这个“中文算数验证码识别”的需求我评估了几种不同的技术路线。最简单的想法是直接用现成的OCR服务但经过测试对于抗干扰能力弱的验证码识别率不稳定且无法直接输出结构化算式。另一种思路是采用目标检测Object Detection加分类Classification的 pipeline 方式这也是我最终采用并验证有效的方案。2.1 方案对比与最终选择端到端OCR规则解析思路使用PaddleOCR、EasyOCR等框架直接识别整张图片中的所有文字然后通过字符串匹配规则如查找“加”、“减”、“乘”、“等于”等关键词来提取数字和运算符。优点实现快速无需训练。缺点在验证码字体扭曲、背景复杂时OCR容易将字符识别错误或合并识别如把“三”和“加”识别成一个无意义的词组导致后续规则解析完全失败。鲁棒性差。目标检测字符分类思路第一步使用目标检测模型如YOLOv8定位出图片中每一个独立的字符包括数字和运算符的边界框。第二步将每一个裁剪出的字符小图送入一个专门训练的分类模型可以是CNN如ResNet、MobileNet中判断它具体是哪个字符0-9的数字或“加”、“减”、“乘”、“等于”。优点将“在哪”和“是什么”两个问题解耦。检测模型负责应对字符位置、排列的不确定性分类模型专注于单个字符的精细识别准确率高。流程清晰可分别优化。缺点需要自己制作数据集并训练两个模型前期工作量较大。我的选择我选择了方案二。因为验证码的核心就是增加机器识别难度方案一的脆弱性在实战中会被放大。方案二虽然步骤多但每个环节可控通过精心准备的数据集可以达到接近100%的识别率更适合生产环境。2.2 技术栈确定基于方案二我确定了以下技术栈编程语言Python。在AI和图像处理领域生态最完善。深度学习框架PyTorch。灵活性强社区活跃从YOLOv5/v8到自定义CNN模型都支持良好。目标检测模型YOLOv8。在精度和速度上取得了很好的平衡且Ultralytics官方提供的API非常易用便于快速训练和部署。字符分类模型自定义的轻量级CNN卷积神经网络。因为需要识别的类别很少10个数字3个运算符1个“等于”共14类一个几层的CNN足以取得很好效果推理速度也极快。图像处理库OpenCV。用于图像的读取、预处理灰度化、二值化、去噪、以及后处理中的绘图等操作。开发环境Jupyter Notebook / VSCode 用于实验和调试最终脚本化。注意这里没有选择Tesseract作为核心OCR引擎是因为我们的字符集是特定的、字体可能是扭曲的通用OCR引擎在此场景下不是最优解。我们的分类模型是专门为这个验证码字体“定制”的效果更好。3. 数据集准备从零到一的构建过程模型训练的好坏七分靠数据。对于这个项目我们需要准备两种数据集用于YOLOv8训练的目标检测数据集标注每个字符的位置和类别以及用于CNN训练的分类数据集裁剪好的单个字符图片及其标签。3.1 验证码图片生成与收集首先我们需要大量的验证码图片作为原料。方案A模拟生成如果目标验证码的生成逻辑已知或可以模拟这是最佳选择。我用Python的PIL库生成了数万张图片。关键步骤包括随机选择1-2位数字如“三”、“十二”。随机选择运算符“加”、“减”、“乘”。随机选择1-2位数字作为第二个运算数。使用一种或多种中文字体如宋体、黑体并尝试加入轻微扭曲、旋转、缩放将文本“数字1 运算符 数字2 ?”绘制到图片上。添加噪声点、干扰线、背景纹理等常见验证码干扰手段。from PIL import Image, ImageDraw, ImageFont, ImageFilter import random import os def generate_captcha(text, font_path, width200, height80): # 创建图片 image Image.new(RGB, (width, height), (255, 255, 255)) draw ImageDraw.Draw(image) # 加载字体可随机大小和轻微扭曲 font_size random.randint(40, 50) font ImageFont.truetype(font_path, font_size) # 计算文本位置 bbox draw.textbbox((0, 0), text, fontfont) text_width bbox[2] - bbox[0] text_height bbox[3] - bbox[1] x (width - text_width) / 2 y (height - text_height) / 2 # 绘制文本可随机颜色 draw.text((x, y), text, fontfont, fill(random.randint(0, 100), random.randint(0, 100), random.randint(0, 100))) # 添加干扰 # 1. 噪声点 for _ in range(random.randint(50, 100)): draw.point((random.randint(0, width), random.randint(0, height)), fill(random.randint(150, 255), random.randint(150, 255), random.randint(150, 255))) # 2. 干扰线 for _ in range(random.randint(3, 5)): start (random.randint(0, width), random.randint(0, height)) end (random.randint(0, width), random.randint(0, height)) draw.line([start, end], fill(random.randint(150, 220), random.randint(150, 220), random.randint(150, 220)), width1) # 3. 轻微扭曲 (通过位移映射模拟) # ... 此处省略具体扭曲代码可使用PIL的ImageFilter或自定义算法 return image # 示例生成“五加三”的图片 chars [零,一,二,三,四,五,六,七,八,九,十,加,减,乘,等于] # 生成逻辑略...方案B真实采集如果验证码来自真实网站则需要编写爬虫程序进行批量采集。这里必须严格遵守网站的robots.txt协议并控制请求频率避免对对方服务器造成压力。采集到的图片需要手动或半自动地清洗去除无效样本。我采用了方案A为主方案B少量补充的策略首先生成了约5000张模拟图片用于模型初训再用爬虫采集了几百张真实图片加入训练集以提升模型在真实场景下的泛化能力。3.2 数据标注精细活决定上限1. 目标检测数据集标注我们需要用标注工具如LabelImg、Roboflow在每张验证码图片上框出每一个字符包括“等于”和“”并打上标签。标签名就是字符本身如“三”、“加”、“五”、“等于”、“问号”。标注格式YOLOv8通常使用YOLO格式归一化的中心点坐标和宽高。一张图片对应一个.txt文件。关键点“等于”是一个运算符但在这里它和“”一样是算式的一部分需要被检测出来但不参与计算。我们检测它是为了帮助确定算式的边界。2. 字符分类数据集标注将上一步检测框裁剪出来的单个字符小图保存为独立的图片文件并根据其内容放入以标签命名的文件夹中。例如dataset_classify/ ├── 三/ │ ├── 001.jpg │ ├── 002.jpg │ └── ... ├── 加/ │ ├── 001.jpg │ └── ... ├── 减/ ├── 乘/ ├── 等于/ └── 问号/这个过程可以通过脚本自动化完成用初步训练的检测模型在训练集上跑一遍自动裁剪并保存然后人工进行抽查和纠错效率比完全手动标注高很多。3.3 数据增强与预处理为了提升模型鲁棒性必须在训练前对数据进行增强。检测数据集增强在图片层面进行包括随机旋转小角度、亮度对比度调整、高斯噪声、模糊、缩放和平移等。这些增强可以模拟验证码生成时的各种变化。分类数据集增强在字符小图层面进行包括随机旋转±15度以内、弹性形变、添加椒盐噪声等。注意增强幅度不宜过大要保证字符不会变得无法辨认。预处理对于分类模型输入的字符小图需要统一缩放到固定尺寸如28x28或64x64并归一化像素值到[0, 1]区间。4. 模型训练YOLOv8检测与CNN分类4.1 YOLOv8字符检测模型训练我使用Ultralytics框架来训练YOLOv8模型非常方便。环境安装pip install ultralytics数据准备按照YOLOv8要求的目录结构组织数据和配置文件data.yaml。# data.yaml path: /path/to/dataset_detect train: images/train val: images/val test: images/test names: 0: 零 1: 一 2: 二 3: 三 4: 四 5: 五 6: 六 7: 七 8: 八 9: 九 10: 十 11: 加 12: 减 13: 乘 14: 等于 15: 问号训练命令yolo taskdetect modetrain modelyolov8n.pt datadata.yaml epochs100 imgsz640 batch16yolov8n.pt是预训练模型使用它能加速收敛。imgsz根据你的验证码图片大小调整。训练过程可以在终端看到损失下降和精度提升。训练完成后最佳模型会保存在runs/detect/train/weights/best.pt。实操心得一开始我用的是yolov8s.pt发现对于简单的字符检测有点“杀鸡用牛刀”而且模型稍大。换成yolov8n.pt纳米级后训练更快推理速度也更快在验证集上的mAP0.5依然能达到99%以上完全够用。模型选型不是越大越好合适最重要。4.2 CNN字符分类模型训练分类模型我选择自己搭建一个简单的CNN使用PyTorch实现。import torch import torch.nn as nn import torch.nn.functional as F class CharCNN(nn.Module): def __init__(self, num_classes14): # 14个类别 super(CharCNN, self).__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) # 输入单通道灰度图 self.pool nn.MaxPool2d(2, 2) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.conv3 nn.Conv2d(64, 128, kernel_size3, padding1) # 假设输入图像是64x64经过三次pooling(2,2)后为8x8 self.fc1 nn.Linear(128 * 8 * 8, 512) self.dropout nn.Dropout(0.5) # 防止过拟合 self.fc2 nn.Linear(512, num_classes) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x self.pool(F.relu(self.conv3(x))) x torch.flatten(x, 1) # 展平 x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x # 训练循环示例简化 model CharCNN() criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) for epoch in range(50): for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() # 每个epoch后在验证集上测试准确率...训练时将裁剪好的字符图片转为灰度图归一化然后送入网络。大约训练20-30个epoch后在测试集上的准确率就能达到99.5%以上。注意事项分类数据集的类别一定要均衡。如果某个字符如“乘”的样本数量远少于其他字符模型可能会偏向于忽略这个类别。可以通过数据增强或过采样如复制样本来解决。5. 推理流程整合与后处理逻辑训练好两个模型后就需要将它们串联起来形成完整的识别流水线。5.1 完整推理步骤图像预处理使用OpenCV读取验证码图片可能转换为灰度图并进行简单的去噪或二值化根据实际情况决定有时彩色信息也有用。import cv2 import numpy as np def preprocess_image(image_path): img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 可选二值化自适应阈值对光照不均更有效 # binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) return img, gray # 返回原图和灰度图备用字符检测加载训练好的YOLOv8模型对预处理后的图片进行推理。from ultralytics import YOLO detect_model YOLO(path/to/best_detect.pt) results detect_model(img) # 输入可以是原图 boxes results[0].boxes.xyxy.cpu().numpy() # 获取检测框坐标 [x1, y1, x2, y2] classes results[0].boxes.cls.cpu().numpy().astype(int) # 获取类别ID confidences results[0].boxes.conf.cpu().numpy() # 获取置信度框排序与过滤检测到的框可能是乱序的。我们需要按照阅读顺序从左到右对它们进行排序这是正确解析算式的前提。同时可以根据置信度过滤掉一些误检的框置信度阈值如0.6。# 根据框的中心点x坐标排序 def sort_boxes(boxes, classes, confs, conf_threshold0.6): filtered_boxes [] filtered_classes [] for box, cls, conf in zip(boxes, classes, confs): if conf conf_threshold: continue filtered_boxes.append(box) filtered_classes.append(cls) if not filtered_boxes: return [], [] # 计算每个框的中心x坐标 centers [(box[0] box[2]) / 2 for box in filtered_boxes] # 根据中心x坐标排序 sorted_indices np.argsort(centers) sorted_boxes [filtered_boxes[i] for i in sorted_indices] sorted_classes [filtered_classes[i] for i in sorted_indices] return sorted_boxes, sorted_classes字符分类遍历排序后的每一个检测框从原图或灰度图上裁剪出对应的ROI区域进行预处理缩放、归一化然后送入CNN分类模型得到具体的字符标签。import torch from torchvision import transforms classify_model CharCNN() classify_model.load_state_dict(torch.load(path/to/best_classify.pth)) classify_model.eval() transform transforms.Compose([ transforms.ToPILImage(), transforms.Grayscale(), transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) ]) id_to_char {0:零, 1:一, ..., 11:加, 12:减, 13:乘, 14:等于, 15:问号} # 与训练时一致 recognized_chars [] for box in sorted_boxes: x1, y1, x2, y2 map(int, box) char_roi gray[y1:y2, x1:x2] # 使用灰度图区域 # 处理ROI可能为空的情况 if char_roi.size 0: continue char_tensor transform(char_roi).unsqueeze(0) # 增加batch维度 with torch.no_grad(): output classify_model(char_tensor) predicted_id torch.argmax(output, dim1).item() recognized_chars.append(id_to_char[predicted_id])语义解析与计算现在recognized_chars是一个有序的字符列表例如[五, 加, 三, 等于, 问号]。我们需要解析这个列表。步骤1找到“等于”的位置它通常标志着算式部分的结束。“等于”之后可能是“问号”或其他内容我们只关心“等于”之前的部分。步骤2将“等于”之前的字符序列拼接成字符串如五加三。步骤3编写一个解析函数将中文数字和运算符转换为计算机可执行的表达式。def chinese_to_math(expr_str): # 中文数字映射 num_map {零:0, 一:1, 二:2, 三:3, 四:4, 五:5, 六:6, 七:7, 八:8, 九:9, 十:10} op_map {加: , 减: -, 乘: *} # 简单解析假设格式是 [数字] [运算符] [数字] # 更复杂的解析可能需要处理两位数如‘十二’ # 这里以一位数为例 if len(expr_str) ! 3: # 例如‘五加三’ raise ValueError(f无法解析的表达式: {expr_str}) num1_char, op_char, num2_char expr_str[0], expr_str[1], expr_str[2] num1 num_map.get(num1_char) num2 num_map.get(num2_char) op op_map.get(op_char) if None in (num1, num2, op): raise ValueError(f包含无法识别的字符: {expr_str}) # 计算 if op : return num1 num2 elif op -: return num1 - num2 elif op *: return num1 * num2步骤4执行计算得到结果。例如chinese_to_math(五加三)返回8。5.2 后处理中的关键技巧处理粘连字符有时YOLO可能会把两个靠得很近的字符检测成一个框。可以在检测后加入一个逻辑如果某个检测框的宽高比异常比如特别宽可以尝试用垂直投影法在这个框内进行二次分割但这种情况在生成数据时如果注意了字符间距通常不会发生。处理识别置信度分类模型会输出每个类别的概率。可以设置一个阈值如果最高概率低于阈值如0.9则认为识别不确定可以将该样本记录下来用于后续模型优化。多位数处理上述解析函数只处理了一位数的运算。如果验证码包含“十二加五”这种情况就需要更复杂的解析器。可以基于规则连续的数字字符组合成一个多位数直到遇到运算符为止。这要求检测和分类必须非常准确。6. 部署优化与性能提升当整个流程跑通后就要考虑如何让它更快、更稳定地运行。模型轻量化检测模型YOLOv8n已经足够轻量。还可以尝试使用YOLOv8 Nano的ONNX格式并利用ONNX Runtime进行推理速度会有提升。分类模型我们的CNN本身很小。可以考虑使用MobileNetV2或ShuffleNet的预训练模型进行微调它们在保持精度的同时计算量更小。或者使用PyTorch的torch.jit.trace将模型转换为TorchScript也能优化推理速度。图片预处理优化不是所有操作都是必要的。通过实验确定最有效的预处理流水线。例如可能发现直接使用原图进行检测和分类效果最好那就可以省去灰度化和二值化步骤节省时间。批量推理如果需要处理大量验证码可以对检测和分类都进行批量处理。YOLOv8支持批量输入。对于分类可以将裁剪出的所有字符ROI堆叠成一个batch再送入模型比循环单个处理快得多。错误重试与降级策略在自动化系统中如果一次识别失败如解析出错可以设计重试机制如重新请求一张新验证码。或者当模型置信度很低时可以触发降级策略比如记录日志并转为人工处理。持续学习将线上识别错误或置信度低的样本自动收集起来定期加入训练集进行模型迭代训练可以让系统越来越“聪明”。7. 常见问题与排查实录在实际开发和测试中我遇到了不少问题这里记录下最典型的几个及其解决方法。7.1 检测模型漏检或误检现象YOLO模型有时会漏掉某个字符或者把背景噪声误检成字符。排查与解决检查数据集首先回顾标注数据。是不是有些字符的标注框不准确或者某些难例样本如字符特别模糊、与背景颜色相近没有包含在训练集中补充标注这些难例样本。调整检测阈值推理时使用的置信度阈值conf可能不合适。阈值太高会导致漏检太低会导致误检。需要通过验证集找到一个平衡点如0.5-0.7。数据增强增加训练时的数据增强强度特别是模拟验证码的各种扭曲和噪声提升模型鲁棒性。模型尺寸如果使用的是yolov8n漏检严重可以尝试稍大的yolov8s但要注意速度 trade-off。7.2 分类模型将不同字符混淆现象CNN总是把“八”和“六”分不清或者把“加”和“减”认错。排查与解决可视化错误样本将分类错误的样本拿出来看。是不是这两个字符在训练图片中本身就长得像比如某种字体下如果是生成数据可以调整字体或增加字形差异。类别不平衡检查训练集中每个类别的图片数量是否大致相等。如果“乘”的样本只有其他字符的十分之一模型自然学不好。通过过采样或数据增强为该类生成更多样本。输入图像尺寸尝试调整输入分类网络的图片大小。太小会丢失细节太大可能引入更多噪声且增加计算量。28x28, 32x32, 64x64都是常见选择。网络容量如果混淆严重可能是网络太简单无法学习到细微特征。可以稍微增加网络深度或宽度如增加卷积核数量。7.3 推理结果解析错误现象字符检测和分类都对了但最后算出的答案是错的。比如“五加三”算成了2。排查与解决框排序错误这是最常见的原因。打印出排序前后的框坐标和类别检查是否真的按从左到右正确排序了。有时因为字符“等于”的框在竖直方向上偏移较大按中心点x坐标排序可能会乱序。可以考虑先按y坐标进行粗略的行分组如果有多行再在每组内按x坐标排序。解析逻辑bug仔细检查chinese_to_math函数。它是否能处理两位数运算符识别映射对吗用大量测试用例去验证这个函数。字符识别错误连锁反应可能某个字符识别错了但置信度很高导致解析的字符串本身就有问题。需要结合分类置信度进行判断对低置信度的识别结果进行特殊处理如标记为未知尝试多种可能。7.4 处理速度慢现象识别一张图片需要几百毫秒甚至上秒无法满足实时需求。排查与解决模型量化将训练好的PyTorch模型转换为INT8量化模型可以大幅减少模型体积和提升推理速度精度损失很小。可以使用PyTorch的量化工具。使用ONNX Runtime将模型导出为ONNX格式并用ONNX Runtime在CPU上推理通常比原生PyTorch快。GPU加速如果服务器有GPU确保PyTorch和CUDA版本匹配并且推理时数据在GPU上。预处理优化检查OpenCV操作的效率。例如cv2.cvtColor和cv2.resize都是比较耗时的操作确保只做必要的处理。这个项目从需求分析到最终部署走完了一个完整的机器学习Pipeline。最大的体会是对于特定的、定义清晰的识别任务定制化的解决方案检测分类远比试图用一个通用模型去套用要可靠和高效。过程中最花时间的往往是数据准备和问题排查模型训练本身由于任务简单反而比较顺利。最终的系统在测试集上达到了99.8%的识别准确率单张图片推理时间在CPU上也能控制在100毫秒以内完全满足了自动化需求。