ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python验证码生成与识别:从图像预处理到CNN模型的完整实践

Python验证码生成与识别:从图像预处理到CNN模型的完整实践 验证码这个东西在互联网上存在了二十多年从最早的纯数字、纯字母到后来的扭曲变形、干扰线、点选汉字、滑块滑动再到现在的无感验证本质上就是一场机器与人类判别的攻防战。作为一个写过不少爬虫、也维护过企业登录服务的开发者我这两年最大的感受是验证码的生成和识别其实是同一枚硬币的两面。你把生成端做得越复杂识别端要付出的代价就越大反过来如果你对生成端的细节一无所知那识别端遇到字符粘连、噪点干扰时根本无从下手。这篇文章要分享的就是一套基于Python的登录网站验证码生成与识别系统。它包含完整的源码和配套文档核心覆盖两块服务端如何用Pillow生成一套带扭曲、干扰、噪点、且与Session强关联的图片验证码识别端如何通过图像预处理、字符分割和CNN模型把验证码里的字符自动读出来。适合正在做登录模块的Web开发者、想入门OCR和图像识别的同学以及那些需要处理验证码的自动化测试或爬虫工程师。我会把两端的原理、代码、踩坑经验一起讲透你拿到之后可以直接往自己的项目里搬。1. 为什么生成和识别必须放在同一个系统里很多人会下意识觉得登录网站的验证码是后端画一张图丢给前端识别是爬虫或测试脚本想办法读图这俩完全是两码事。但真正动手做一遍就会发现如果只做生成不认识识别你根本不知道自己的验证码到底安不安全如果只做识别不研究生成你连最基本的预处理参数为什么要这么设都搞不清楚。1.1 验证码的存在价值拦截机器而不是为难人类先说清楚验证码要解决的问题。登录接口如果裸奔任何人都可以用脚本无限次尝试用户名和密码这就是典型的暴力破解。验证码的作用是建立一个图灵测试——你得证明自己是人类才能继续尝试登录。但这里有个关键权衡验证码太简单OCR工具分分钟破解形同虚设验证码太复杂真实用户看不清登录转化率暴跌。优秀的验证码生成端追求的是人类识别率在95%以上、机器识别率尽可能低的平衡点。从安全角度看验证码生成端通常要考虑几个维度字符集的选择排除易混淆字符、干扰手段的强度噪点、干扰线、扭曲程度、图片尺寸和字体风格以及最关键的一点——验证码与用户的会话状态是否正确绑定能不能被重放攻击。这些在只做识别的人眼里是永远不会触碰的盲区。1.2 识别端的真实用途测试、风控与自检识别端也不是只有破解一个用途。在我实际接触的项目里验证码识别至少要服务三类场景自动化测试。团队做登录模块的回归测试时总不能每次都手工输入验证码。用识别脚本自动填充能把整个登录流程的自动化率拉满。内网风控模拟。安全团队要验证自家验证码的抗破解能力必须先用OCR和深度学习模型打一遍评估风险等级。这时候识别端就是红队工具。数据采集与爬虫。这是最敏感但也最现实的需求。合法授权的数据采集场景里对方网站如果用了廉价验证码你又拿不到官方API识别就是唯一出路。把生成和识别放在一个项目里本质上是在运维一个对抗演练场。生成端每更新一次识别端就重新测一遍用识别准确率来量化生成端的强度。这是一个持续迭代的正循环比单纯做完就扔有意义得多。1.3 这套系统的技术栈概览整个系统的核心依赖其实非常轻PillowPython生态最主流的图像处理库负责验证码的绘制、扭曲、加噪。OpenCV识别端的预处理主力用于灰度化、二值化、去噪、找轮廓、字符分割。NumPy图像数据在Python里本质是NumPy数组几乎每一步都离不开。TensorFlow / Keras训练一个轻量级CNN模型来识别分割后的字符。如果只是demo级应用也可以用Pytesseract但效果天花板很低。系统跑起来的流程是后端生成验证码图片并保存答案到Session - 前端展示图片 - 用户输入 - 后端比对。识别端则单独脱机工作读取图片 - 预处理 - 分割 - CNN预测 - 输出字符串。两端共享一套字符集和图片参数方便同步调优。2. 生成端的技术拆解从空白画布到一张能打的验证码生成端是整个系统的地基。很多人觉得用Pillow画个验证码就几行代码的事但真要达到人类能看清、机器难识别的效果里面的门道不少。我按实际开发顺序拆开讲。2.1 设计原则清晰度与安全性的平衡生成验证码之前先明确几条核心原则否则后面全是在返工人类必须认得出。字符大小、间距、对比度都要够不要为了防机器把人类也防了。干扰要有层次。单一的噪点或线条都很容易被简单滤波干掉干扰必须是背景纹理 随机噪点 干扰线 字符扭曲的组合。字符集要剔除歧义字符。0和O、1和l、z和2这类对人和机器都容易混淆的字符直接从字符集里拿掉。很多验证码翻车都是栽在这个细节上。答案必须只存服务端。验证码图片里不能藏任何关于答案的元数据提交校验必须走服务端Session不能在浏览器端做任何比对。2.2 核心生成流程与图像渲染细节第一步是选画布参数。我常用的配置是width160, height60四到五个字符字体大小在36到42磅之间。160x60这个尺寸比较中庸太小了字符挤在一起分割困难太大了图片加载慢而且单字符面积过大反而容易被CNN那种小模型轻松识别。第二步是画背景。很多人直接用纯白背景这其实是个误区——纯白背景虽然对比度高但二值化阈值太好选了机器处理起来毫无难度。我通常的做法是生成一个浅色渐变背景或者用ImageDraw随机画大量浅色的短线、小圆点作为纹理基底。纯色背景也没有不行但建议加一层淡色噪点背景与字符颜色的明度差控制在合理范围不能大到一眼就被阈值分割的底部干净地切出来。第三步是写字符。这里有个关键点不要直接在最终画布上写字而是先把每个字符单独渲染到透明图层上再逐字摆放。原因很简单直接在一张图上写完再扭曲整体效果是有了但字符分割时会遇到大量跨字符粘连分开渲染、分别扭曲再合成识别端会好处理得多而且生成端的可控性也更高。每个字符落到画布上时我会给一个细微的随机旋转-15度到15度和一个很小的垂直偏移模拟人手写字的不规整感。第四步是加干扰。干扰线我用arc和line组合画一到三条带随机弧度的曲线颜色跟字符颜色属于同一个色系但更淡一些噪点则是随机撒几百个大小不同的小方块和圆点分布在字符周围和背景里。这里特别注意干扰物不能出现在字符笔画的正上方且颜色不能过深否则人类都不好认了那就没意义了。2.3 字符扭曲与粘连的控制生成端和识别端博弈的核心验证码防机器最重要的手段其实就是扭曲skew和粘连。CNN识别单个字符的准确率可以做到99%以上但一旦字符之间发生笔画重叠分割算法就会崩整体识别率直线下降。所以生成端的博弈点就是把扭曲和粘连控制在一个人眼能通过上下文猜出字符但简单投影分割直接失效的程度。我的扭曲方案是用Pillow的Image.transform配合一组随机位移映射简单说就是给图片做一个波浪形变形。伪代码逻辑如下import random from PIL import Image, ImageDraw, ImageFilter def distort_image(img, magnitude4): # 创建一个轻微的波浪形位移映射每个x位置的纵向偏移是随机的正弦组合 import math xshift magnitude yshift 0 # 这里用 mesh 方式做透视/位移变换 w, h img.size displacement [ (0, 0, random.randint(-xshift, xshift), random.randint(-yshift, yshift)), (w, 0, random.randint(-xshift, xshift), random.randint(-yshift, yshift)), (w, h, random.randint(-xshift, xshift), random.randint(-yshift, yshift)), (0, h, random.randint(-xshift, xshift), random.randint(-yshift, yshift)), ] return img.transform((w, h), Image.MESH, (displacement,), resampleImage.BICUBIC)上面这个transform做的是四角随机偏移的网格变形会让整张图产生不规则的拉伸感。实际生产里我还会在此基础上叠加一个正弦波扭曲函数按行偏移使字符边缘出现波浪状断裂感。对识别端来说这种扭曲之后字符的笔画宽度不再均匀传统OCR的模板匹配直接报废而CNN因为提取的是高层特征还有一定鲁棒性。干扰线和噪点主要针对的是传统图像处理方案。投影法分割依赖二值化后的连通域噪点如果不清理干净连通域数量会暴涨干扰线如果不剔除会把两个本来分离的字符缝在一起。所以生成端这边干扰线和噪点的强度需要反复调优找到一个让传统处理方案很难受、但人不至于抓狂的中间值。2.4 生成端完整代码示例下面这段代码是生成器的核心我在生产代码基础上精简过逻辑保留了精华部分import random import io from PIL import Image, ImageDraw, ImageFont, ImageFilter # 字符集刻意去掉容易混淆的字符 CHARS ABCDEFGHJKMNPQRSTUVWXYZ23456789 def random_color(low100, high255): return (random.randint(low, high), random.randint(low, high), random.randint(low, high)) def generate_captcha(textNone, width160, height60, font_pathNone): if text is None: text .join(random.choices(CHARS, k4)) # 1. 带渐变底色的背景 bg Image.new(RGB, (width, height), random_color(200, 235)) draw ImageDraw.Draw(bg) # 2. 背景纹理大量细小短线 for _ in range(random.randint(30, 60)): x1, y1 random.randint(0, width), random.randint(0, height) x2, y2 x1 random.randint(-15, 15), y1 random.randint(-15, 15) draw.line((x1, y1, x2, y2), fillrandom_color(180, 230), width1) # 3. 每个字符单独渲染后旋转再粘贴 chars_layer Image.new(RGBA, (width, height), (0, 0, 0, 0)) chars_draw ImageDraw.Draw(chars_layer) font ImageFont.truetype(font_path, sizerandom.randint(36, 42)) char_w width // len(text) for i, ch in enumerate(text): # 临时图层画单个字符 tmp Image.new(RGBA, (char_w, height), (0, 0, 0, 0)) tmp_draw ImageDraw.Draw(tmp) tmp_draw.text((random.randint(2, 8), random.randint(2, 14)), ch, fontfont, fill(*random_color(40, 120), 255)) # 轻微旋转 tmp tmp.rotate(random.randint(-15, 15), expandTrue, resampleImage.BICUBIC) # 粘贴回字符层 chars_layer.paste(tmp, (i * char_w random.randint(-4, 4), 0), tmp) # 4. 合成背景和字符 bg Image.alpha_composite(bg.convert(RGBA), chars_layer).convert(RGB) # 5. 全局轻度扭曲 bg distort_image(bg, magnituderandom.randint(2, 5)) # 6. 干扰线两条 draw ImageDraw.Draw(bg) for _ in range(2): x1, y1 random.randint(0, width // 2), random.randint(0, height) x2, y2 random.randint(width // 2, width), random.randint(0, height) draw.line((x1, y1, x2, y2), fillrandom_color(80, 160), width2) # 7. 随机噪点 for _ in range(random.randint(200, 400)): x, y random.randint(0, width - 1), random.randint(0, height - 1) draw.point((x, y), fillrandom_color(0, 180)) return bg, text这里有几个细节值得解释字体路径生产环境一定要用.ttf字体文件不要依赖系统默认字体。不同系统默认字体差异很大直接决定后续识别端的预处理难度。我用的是类似DejaVu Sans这种笔画粗细均匀的无衬线字体。每个字符单独渲染这个设计决策非常关键。分开渲染后再合成就能控制字符之间的粘连程度你在粘贴时可以给随机偏移这相当于给识别端制造可控难度的分割样本。两次干扰背景纹理一次画完字符后再加干扰线和噪点一次。字符上方的噪点如果在字符渲染之后加会被扭曲带着一起变形更难被简单滤波去掉。2.5 session关联与验证刷新机制生成验证码只是前半程后半程是把它和登录流程绑在一起。我采用的标准方案是生成验证码时创建或读取当前会话的Session把答案存进session[captcha]同时记录一个过期时间戳比如5分钟。图片响应头设置Cache-Control: no-store防止浏览器缓存导致同一张验证码被重复利用。前端点看不清换一张时请求同一个接口后端重新生成并覆盖session[captcha]。登录接口校验时先校验验证码再校验账号密码。这样哪怕是验证码正确、密码错误也要重新刷新验证码避免脚本用固定验证码反复试密码。这里最容易踩的坑是有些开发者会把答案直接放在Cookie里或者通过前端JS变量传递这是完全错误的。验证码一旦可以被前端读到攻防就结束了——攻击者根本不需要去识别图片直接取答案就行。记住答案只能存在服务端Session里前端拿到的只是一张没有意义、带噪点的图片。3. 识别端的两条技术路线传统图像处理到底死在哪里识别验证码主流做法可以分成两代。第一代是图像预处理 OCR或模板匹配第二代是图像预处理 字符分割 CNN分类。绝大多数网上流传的Python验证码识别教程都还停留在第一代它们用的是Pytesseract调Tesseract引擎。我可以直接给你结论Tesseract在纯数字、无扭曲、无干扰的验证码上还能玩一旦遇到我们上面这种噪声、扭曲、干扰线齐全的验证码识别率通常不到20%基本等于废了。3.1 预处理流程灰度化、二值化、降噪、分割不管走哪条路线预处理都是必经之路。目标只有一个把彩色验证码变成一张干净的黑白图字符区域变成连通的白色或黑色块背景噪点被尽可能消除。import cv2 import numpy as np def preprocess(image_path): # 读入并灰度化 img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊去除细小噪点 blurred cv2.GaussianBlur(gray, (3, 3), 0) # 自适应阈值二值化保留字符主体 binary cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 15) # 形态学开运算干掉孤立噪点 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) cleaned cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 找到所有轮廓按x坐标排序 contours, _ cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keylambda c: cv2.boundingRect(c)[0]) return cleaned, contours这段代码本身已经很标准但我得强调几个容易出问题的点二值化阈值选择固定阈值cv2.threshold在光照均匀的时候可以用但验证码背景有渐变时效果很差。我改用adaptiveThreshold后字符边缘的保留效果好得多。开运算的核大小(2, 2)这种小核只能干掉单个像素的噪点如果生成端噪点是3x3以上的色块这里就得用更大核但核太大又会腐蚀字符笔画得不偿失。实际调参时要对着具体生成端的参数调这就是我前面说生成和识别必须放在一起的原因。轮廓筛选RETR_EXTERNAL只取最外层轮廓能避开字符内部空洞产生的内轮廓。但如果有干扰线横跨图片这条线会跟字符粘连成一个超级轮廓分割直接失败。这也是传统方案的死穴之一。3.2 字符分割所有识别难点的汇聚点预处理做完后最核心也最脆弱的一步就是分割。我的处理思路是用连通域分析加宽度过滤把每个轮廓的外接矩形取出来如果矩形的宽度在某个合理范围内比如20到50像素就认为是单个字符如果宽度远大于单字符就按宽度做均分切割把连在一起的字符拆开。听起来很简单实际上成功率完全取决于生成端的脸色。字符粘连严重时连通域分析会把两个字当成一个大字均分切割又会因为切割线正好压在笔画上导致两个字符都不完整。这也是为什么我在生成端强调分开渲染再粘贴、可控偏移量——这是给识别端留活路也是给自己留活路。3.3 为什么传统OCR方案走到这里就结束了Tesseract这类传统OCR引擎擅长的是印刷体、版面规整的文档识别它的识别单元是整行、整词。验证码这种字符级扭曲、干扰密集的图片Tesseract会先把图片里的所有连通域按行分组然后再逐字符匹配字形库。问题在于字形库是为标准印刷体设计的扭曲变形后匹配相似度大幅下降。干扰线导致同一行里出现非字符连通域行分割直接混乱。Tesseract的字符集配置不灵活遇到自定义字符集时经常把字符识别成完全不相关的符号。所以我给的结论很直接在验证码识别这个任务上传统OCR基本不可用。想真正解决识别问题必须上深度学习。4. CNN识别方案训练一个自己的验证码识别器深度学习方案的逻辑是把分割好的单个字符图片归一化成固定尺寸训练一个CNN做分类类别数等于字符集大小。这个方案的好处是对扭曲、粘连的鲁棒性远远胜过模板匹配你只要把训练样本铺够识别率能做到95%以上。4.1 为什么是分割 CNN而不是目标检测端到端你可能听过YOLO这类目标检测模型能不能直接拿它识别整张验证码可以但没必要。验证码本质上是多个排列规整的字符字符之间虽然有粘连但大致位置是固定的。用一个轻量级CNN做整图多标签分类反而是更合理的方案。不过要考虑工程成本和难度。对大多数读者来说在GPU资源有限的情况下先分割再分类的两段式方案是最容易复现、最容易调试的。分割一旦出问题你能明确看到是哪个环节而端到端模型出了问题你只能干瞪眼调参。4.2 训练数据怎么来训练数据有两种来源哪一种都行但我建议两个都用生成端直接批量产出。这是最爽的方式。因为生成端是我们自己的你可以一口气生成几万张带标签的图片然后同步跑一遍预处理和分割把分割好的单字符图片和对应标签存下来。一个4字符验证码能产出4个样本5万张就是20万样本完全够用。真实线上样本人工标注。从生产环境捞验证码图片人工标答案。这个能反映线上真实的噪声分布但代价高适合在模型上线后做增量微调。训练之前做数据增强也很重要。对字符图片做小角度的旋转、微小的缩放、轻微的亮度变化都能提高模型的泛化能力。注意旋转角度别超过10度缩放别超过0.1否则会破坏字符结构。4.3 CNN模型结构别贪深够用就行验证码字符识别不是ImageNet那种千分类任务字符集顶多30到40个图片尺寸又小我归一化到28x28或32x32所以模型不需要太深。我用的是经典LeNet结构改出来的轻量网络from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout def build_model(num_classes, input_shape(32, 32, 1)): model Sequential([ Conv2D(32, (3, 3), activationrelu, paddingsame, input_shapeinput_shape), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activationrelu, paddingsame), MaxPooling2D((2, 2)), Flatten(), Dense(128, activationrelu), Dropout(0.3), Dense(num_classes, activationsoftmax), ]) model.compile(optimizeradam, losscategorical_crossentropy, metrics[accuracy]) return model为什么用两层卷积就够了因为字符图片的纹理复杂度很低特征就是笔画和边缘两层卷积足以提取足够多的判别特征。再用更大更深的模型不仅训练慢、推理慢而且在小数据集上很容易过拟合。4.4 训练与推理的完整闭环训练部分的标准姿势是把单字符图片统一缩放成32x32灰度图转成float32并归一化到0到1之间标签做one-hot编码训练20到30个epoch。说实话训练很快CPU上十分钟之内就能跑到95%以上。推理时把一张验证码图片跑一遍预处理和分割然后把每个字符图片喂给模型取argmax索引映射回字符即可。这里有个细节要注意训练验证准确率和整体识别率的关系。单字符准确率99%四个字符全对的概率就是99%的四次方约96%。也就是说单字准确率的一个小小波动会被放大三到四倍。所以在训练时我要求单字符准确率至少到99.3%以上整张验证码的识别率才能稳定在97%以上。这也是为什么数据增强和调参值得花时间。4.5 识别端整体流程串起来完整跑一轮识别的代码逻辑大致是这样def recognize_captcha(image_path, model, char_list): # 1. 预处理 cleaned, contours preprocess(image_path) result [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) # 过滤太小的噪点轮廓 if w 8 or h 12: continue # 提取字符区域并归一化 roi cleaned[y:yh, x:xw] roi cv2.resize(roi, (32, 32)) roi roi.reshape(1, 32, 32, 1).astype(float32) / 255.0 # 预测 pred model.predict(roi, verbose0) idx np.argmax(pred) result.append(char_list[idx]) return .join(result)实际用的时候上面的逻辑还需要应对几种异常分割出来多个轮廓但数量不等于验证码长度怎么办分割出的矩形高宽比明显异常怎么办这些都需要根据生成端的实际情况写兜底逻辑一句话识别端必须和生成端结对调试孤立存在的识别代码只是一堆废代码。5. 从单体Demo到工程应用的五个常见坑项目能从demo跑通到真正上线抗住生产环境流量中间还有好几个坑。我把这几年踩过的坑集中列出来每个都给出解决方案。5.1 字体文件在不同环境下的差异开发环境是Windows部署环境是Linux字体路径不同导致图片渲染效果完全不同这是生成端最常被坑的地方。我的方案是把字体文件直接打包进项目目录写死相对路径读取不依赖系统字体。否则你在本地生成验证码效果很好一上服务器全变成方块字或者直接报错找不到字体。5.2 Session的并发问题和分布式会话如果登录接口被频繁请求同一个Session可能同时在多个请求中被读写。一般的Web框架如Flask、Django的Session读写不是线程安全的高并发下会出现验证码明明对了但提交时Session已经过期的情况。我在项目文档里的建议是用Redis做Session存储并设置captcha字段的过期时间。用户每次刷新验证码时先把旧的失效再写新的防止旧验证码被重放。5.3 验证码过期时间多长合适太短比如1分钟会导致用户手慢了就过期体验很差太长比如15分钟又增大了验证码被多轮尝试利用的风险。我一般取3到5分钟。同时要在登录逻辑里做错误尝试次数限制连续输错5次就锁定该账号10分钟这个策略比无限延长时间有用得多。5.4 图片接口的性能优化验证码图片虽然小但如果在接口里每次都现生成、现做扭曲并发一高CPU还是会有压力。我的做法是做一个小的验证码池后台定时批量生成100到200张验证码放进队列接口请求时直接从池里取一张绑定Session速度能快一个数量级。这个方案特别适合活动期间登录流量突增的场景。代价是验证码可能出现重复但只要池子足够大比如几万张重复率可以忽略不计。5.5 识别模型的上线维护CNN模型上线后如果生成端调整了字体、字符集或扭曲强度模型的准确率会立刻受到影响。所以生成端一旦有参数变更必须重新跑一遍识别测试准确率跌破95%就说明调整幅度过大需要回滚或者重新训练。我在源码文档里特意留了一个benchmark.py脚本自动生成1000张验证码并统计识别率和单字准确率每轮改动跑一次一目了然。6. 这套系统还能怎么延伸验证码这个方向做完生成识别闭环之后可以往上做很多有意思的东西。行为式验证码比如滑块拼图、点选文字可以引入前端手势轨迹数据和后端行为特征分析识别端也要从纯图像拓展到时序序列。思路是类似的——生成端要生成拼图缺口模板识别端要检测缺口位置和模拟人类轨迹。语义验证码比如请点击图片中的红色汽车这类验证码需要目标检测模型做定位难度直接上一个台阶。但它也正好是训练目标检测 图像理解的好项目。无感验证基于设备指纹和行为特征的被动风控用户根本不需要任何操作。不过这个方向对基础设施建设要求很高小团队不建议碰。反过来用识别端评估生成端强度把识别准确率作为一个核心安全指标写进CI/CD流水线每次生成端改动自动跑评测。这可以称得上验证码生产的自动化质检了。我在实际项目里体会最深的一点是验证码的攻防是一场持续的动态博弈。生成端和识别端不是对立的两拨人而是同一个系统里互相校验的两半。你今天做的识别模型可能明天就用作评估新验证码方案的武器你今天写下的生成参数也许后天就被自己对端的模型打穿。保持两套代码共用字符集和参数的默契让它们共同进化这套系统的价值才会真正体现出来。最后再分享一个小经验如果你只是做学习用途不必追求模型多先进。先把Pillow生成端玩熟把OpenCV预处理调顺把分割逻辑跑稳这三个基本功比堆模型参数重要得多。我见过不少人一上来就上YOLO、上Transformer结果数据样本不够、分割模块不牢最后识别率还不如我一个老掉牙的LeNet。先把这个轻量版跑通再慢慢往深层演进这条路最稳妥。
返回列表