每到毕业设计选题季,类似《基于Python的Django-html登录网站验证码的生成与识别系统》这种题目,都能在备选清单上反复出现。我第一次拿到这个题时,以为无非是调库生成一张图片、再调用一个OCR识别完事。真做下来才发现,它把Web后端、图像处理、机器学习三条线串在了一个完整业务里,是一个被低估的综合型项目。这篇文章就完整复盘这个系统的设计与落地,从Pillow画验证码,讲到OpenCV预处理、CNN识别,再讲到Django登录流程闭环,最后整理一份踩坑清单。无论你是毕业设计拿到了类似方向,还是单纯想做一个能跑通前后端的验证码安全Demo,都可以照着这份实践经验来排时间、控难度、准备验收。
1. 项目要做什么:从毕业设计选题角度理解需求
1.1 这题目表面在问验证码,实际在考什么
把标题拆开看,其实是一个典型的“三段式”系统:
- 前端部分:基于Django模板渲染的HTML登录页,包含用户名、密码、验证码输入框和图片展示。
- 生成部分:后端动态生成一张带干扰的验证码图片,同时把正确答案放到服务端会话里。
- 识别部分:对验证码图片做图像处理和字符识别,把图片里的内容自动读出来,并接入登录流程做自测。
大部分同学看到这个题的第一反应是“工作量太大”,实际上它比纯CRUD管理系统有意思得多,而且技术层次非常分明:Web开发、图像处理、机器学习、系统集成各占一块,每一块都能单独展开讲,答辩的时候不怕没内容。更关键的是,这三块是连通的——生成端画出来的图片质量,直接影响识别端的难度;识别端的准确率,又反过来检验生成端的干扰强度。这种互相制约的项目,才是导师想看到的“有思考深度”的毕设。
1.2 系统整体架构与数据流向
整个系统可以分成两条链路:
第一条是用户正常登录链路。浏览器请求登录页,HTML里的img标签向后端验证码接口发起GET请求;后端用Pillow生成图片,把答案写入session,图片以PNG流返回;用户填入验证码后,表单POST到登录视图,后端先校验验证码,再走用户名密码认证。
第二条是识别自测链路。管理员或者评委可以在调试页面触发一次“自动识别”,系统会把当前显示的验证码图片交给预处理模块和识别模型,最终在界面上打印出识别结果和置信度。这条链路不干扰正常业务,只服务于系统演示和能力验证。
整体数据流向并不复杂,但要注意一个设计原则:验证码的正确答案永远只存在服务端session里,不能跟着图片一起下发到前端。很多初稿常见的错误就是把答案拼在URL参数或者图片名里,这在安全上等于没设验证码。
1.3 技术选型:为什么是Django + Pillow + OpenCV + CNN
这组技术栈看起来多,但它们各有明确分工。
Django负责Web框架和session机制。选Django而不是Flask的理由很实在:Django自带用户认证、session中间件、CSRF防护,登录场景天然契合,省掉自己造轮子。Flask虽然轻量,但也意味着认证要自己写或者额外引扩展,对毕设来说没必要增加复杂度。
Pillow负责生成验证码。它足够轻,ImageDraw和ImageFont两个模块就能完成画布、文字、干扰线、噪点的绘制,不需要上OpenCV那种重工具。生成端用Pillow,识别端用OpenCV,两边各取所长,这也是业界做验证码攻防研究的常见搭配。
识别端提供两条路线:传统方法是预处理后分割字符,用Zoning特征加SVM或随机森林分类;进阶方法是训练一个轻量CNN模型。两者不冲突,建议先做传统方法作为基线,再上CNN做精度提升,文档里还能多一段对比分析。
2. 验证码生成:用Pillow手工绘制安全验证码
2.1 字符集与图像参数:先定好生成规则再动手
生成器是整个系统的基础,因为识别训练的数据也靠它批量产出,所以参数规则必须先想清楚。
字符集设计上,最常用的做法是去掉容易混淆的字符:
import random # 去掉 0/O、1/I/L 等易混淆字符 CHAR_SET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789" CODE_LEN = 4为什么去掉这些字符?一方面是人眼容易看错,用户输错率高;另一方面是识别模型在区分“0和O”、“1和I”这类相似类别时很容易出错,去掉后等于同时降低了生成端和识别端的难度。这个细节在文档里写明,就是一个很漂亮的思考点。
画布参数我实测下来比较稳的一组是:
WIDTH = 130 HEIGHT = 45 FONT_SIZE = 304个字符、每个字符30像素,加上左右留白和字符间距,130像素宽度刚好合适。宽度太小字符会挤在一起,增加识别分割的难度;宽度太大又会让图片在页面上显得笨重。高度45像素配30像素字体,上下还有空间画干扰线,不会太局促。
2.2 噪点、干扰线与扭曲变形:验证码看着“乱”才安全
验证码的安全性是靠“乱”撑起来的,这里的“乱”不是随便撒一把像素,而是有层次地叠加:
背景用浅色随机RGB,取值范围大约180到230,避免纯白背景导致后期判断不出哪是噪点哪是文字。
文字颜色取深色随机RGB,范围0到120,确保文字和背景对比度足够强。如果背景和文字颜色都取全随机,可能出现底色深、文字也深的情况,人眼都看不清,反而被用户骂。
干扰线我一般画2到3条,线宽控制在1到2像素,颜色贴近文字色但略微随机偏移。线不能太粗,太粗会把字符糊住,真实环境里验证码也不会让干扰线盖过字符主体。
from PIL import Image, ImageDraw, ImageFont import math def random_bg_color(): return (random.randint(180, 230), random.randint(180, 230), random.randint(180, 230)) def random_text_color(): return (random.randint(0, 120), random.randint(0, 120), random.randint(0, 120)) def generate_captcha(): text = "".join(random.choices(CHAR_SET, k=CODE_LEN)) image = Image.new("RGB", (WIDTH, HEIGHT), random_bg_color()) draw = ImageDraw.Draw(image) # 干扰线 for _ in range(random.randint(2, 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_text_color(), width=random.randint(1, 2)) # 噪点 for _ in range(random.randint(150, 300)): x, y = random.randint(0, WIDTH - 1), random.randint(0, HEIGHT - 1) draw.point((x, y), fill=random_text_color()) # 字符绘制,x 方向按步长平铺,带随机偏移 font_path = "C:/Windows/Fonts/arialbd.ttf" # Linux 下换成 /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf font = ImageFont.truetype(font_path, FONT_SIZE) step = WIDTH // (CODE_LEN + 1) base_y = (HEIGHT - FONT_SIZE) // 2 for i, ch in enumerate(text): x = step * (i + 1) + random.randint(-3, 3) y = base_y + random.randint(-4, 4) draw.text((x, y), ch, font=font, fill=random_text_color()) return text, distort_image(image)扭曲变形是干扰的关键一步。最简单的方案是正弦波位移:每个x坐标对应一个垂直偏移量,偏移量由正弦函数产生。视觉效果是整张图像波浪一样抖动,字符不再是直线排布,识别端如果直接按水平投影切字符就会遇到困难。
def distort_image(image): distorted = Image.new("RGB", (WIDTH, HEIGHT), (255, 255, 255)) amplitude = random.randint(3, 6) wavelength = random.randint(15, 25) phase = random.randint(0, 10) for x in range(WIDTH): offset = int(amplitude * math.sin(2 * math.pi * x / wavelength + phase)) for y in range(HEIGHT): src_y = y + offset if 0 <= src_y < HEIGHT: distorted.putpixel((x, y), image.getpixel((x, src_y))) return distorted实测下来,这种逐像素搬运的写法虽然慢一点,但胜在直观,生成一张图几毫秒,Web场景完全够用。对毕设来说,重点是把这个思路讲清楚,不要一上来就上OpenCV的仿射变换矩阵,原理都没理解就堆代码,答辩一问就容易露馅。
2.3 生成代码入Django:一个视图加一个img标签就够
生成器作为独立工具函数放好后,Django这边的接入非常轻。在app里新建一个views方法:
from io import BytesIO from django.http import HttpResponse from .utils import captcha_gen def captcha_image(request): text, image = captcha_gen.generate_captcha() request.session["captcha_text"] = text request.session["captcha_time"] = int(time.time()) buf = BytesIO() image.save(buf, "PNG") return HttpResponse(buf.getvalue(), content_type="image/png")URL配置:
from django.urls import path from . import views urlpatterns = [ path("captcha/image", views.captcha_image, name="captcha_image"), path("login", views.login, name="login"), ]前端登录页里,验证码区域就三样东西:一张img、一个输入框、一个“换一张”操作:
<img src="{% url 'captcha_image' %}" id="captchaImg" width="130" height="45" alt="验证码" onclick="refreshCaptcha()"> <input type="text" name="captcha" placeholder="验证码" maxlength="4" required>点击图片刷新的核心是加时间戳参数,否则浏览器会命中缓存不再请求后端:
function refreshCaptcha() { document.getElementById("captchaImg").src = "{% url 'captcha_image' %}?t=" + Date.now(); }这个时间戳方案是毕设里最容易漏的点,但又是必须有的。没有它,用户输错一次验证码后,图片永远不变,session里的答案也不会更新,等于验证码被“死缓存”打穿。
3. 验证码识别:从图像预处理到深度学习模型
3.1 预处理:灰度、去噪、二值化三步走
识别端和生成端是两套图像处理栈。生成端用Pillow,识别端我更推荐OpenCV,因为识别涉及的形态学操作、连通域分析、轮廓提取在OpenCV里都是一行调用,处理速度也更快。
输入进来第一步是灰度化。这里的坑是:Pillow生成的PNG在RGB通道和OpenCV的BGR顺序不同,直接转会有色偏,虽然对灰度影响不大,但后续如果有颜色特征就得注意。
import cv2 import numpy as np def preprocess(pil_image): # PIL 转 OpenCV BGR img = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.medianBlur(gray, 3) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) return cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))中值滤波核大小选3比较合适。核太小去不掉孤立噪点,核太大会把字符边缘磨平,导致后续字符断裂。
二值化这里有一个细节:用的是THRESH_BINARY_INV,也就是把深色文字变成白色(255)、浅色背景变成黑色(0)。为什么?因为后面积分投影、连通域分析都是默认“前景为白”的逻辑,统一处理成这个方向,后面代码才不用来回切换。
3.2 字符分割:投影法如何应对常见边缘情况
传统识别里最难的就是分割。字符一旦粘连,后面的识别模型再强也白搭。
最常用的基础方法是垂直投影法:统计每一列中白色像素的个数,连续有白色像素的区间就是一个候选字符区域。
def split_chars(binary): col_sum = np.sum(binary == 255, axis=0) regions = [] start = None for x, cnt in enumerate(col_sum): if cnt > 0 and start is None: start = x elif cnt == 0 and start is not None: regions.append((start, x)) start = None if start is not None: regions.append((start, len(col_sum))) return regions这个版本对字符间距正常的情况已经能跑通,但实际会有两个典型问题。
第一个问题是干扰线把两个字符连在一起,导致垂直投影合并成一个宽区域。解决办法是分割后检查区域数量,如果数量小于验证码位数,就按宽度先验把过宽区域均分。第二个问题是字符因为扭曲产生上下断裂,白色像素不连续,被切成多段。这种情况可以在投影前先做一次膨胀,把同字符内断裂的笔画桥接上。
分割阈值和形态学参数没有银弹,我每次调整后都会用100张测试图批量跑一遍,看区域数量分布,而不是盯着单张图调。
3.3 识别模型的两种路线:传统分类器与CNN对比
字符分割完成后,把每个字符归一化成固定大小,比如28x28。
传统方案的特征可以简单到用像素密度分块统计:把28x28的图分成5x5的网格,每个格子统计白色像素占比,得到一个25维特征向量,直接丢给SVM:
from sklearn.svm import SVC def extract_feature(char_img): resized = cv2.resize(char_img, (28, 28)) feature = [] for i in range(0, 28, 5): for j in range(0, 28, 5): block = resized[i:i+5, j:j+5] feature.append(np.mean(block[block > 0])) return featureSVM的训练成本极低,数据量几千张就够,几秒出模型。缺点是准确率上限不高,对扭曲强烈或者干扰线残留比较多的图,很容易翻车。
CNN是更稳的路线,但也不需要做得太重。一个两层卷积加全连接的小模型,已经能在这类验证码上取得90%以上的单字准确率:
import tensorflow as tf from tensorflow.keras import layers def build_model(num_classes): model = tf.keras.Sequential([ layers.Conv2D(32, (3, 3), activation="relu", input_shape=(28, 28, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation="relu"), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(128, activation="relu"), layers.Dropout(0.5), layers.Dense(num_classes, activation="softmax") ]) model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"]) return model训练数据直接用生成器批量产出,这一点是这个项目最大的“作弊器”——你自己就是数据工厂。批量生成几万张图片,每张图片自动带标注文本,切分成单字符后保存成训练集。再加一点随机位移和缩放增强,模型泛化能力就能好不少。
CNN和SVM可以做一个对比表放在论文或文档里:
| 方案 | 训练时间 | 单字准确率 | 代码复杂度 | 部署难度 |
|---|---|---|---|---|
| 像素密度特征 + SVM | 几秒 | 80%左右 | 低 | 低 |
| 轻量CNN | 十几分钟 | 90%以上 | 中 | 中 |
| 复杂CNN / CTC端到端 | 数小时 | 可能更高 | 高 | 高 |
毕设做到轻量CNN这档就已经有足够的完成度了,不建议上CTC端到端,训练调试周期会失控。
3.4 把识别模块做成Django内置能力
识别模块不能只活在训练脚本里,得集成到Django工程中。我建议单独建一个captcha_ocr.py,对外暴露一个predict_pil_image函数,内部维护全局模型实例,避免每次请求都重新加载模型权重:
_model = None def get_model(): global _model if _model is None: _model = build_model(num_classes) _model.load_weights(BASE_DIR / "captcha_model.h5") return _model def predict_pil_image(pil_image): binary = preprocess(pil_image) regions = split_chars(binary) chars = [] for x1, x2 in regions: char_img = binary[:, x1:x2] char_img = cv2.resize(char_img, (28, 28)) char_img = char_img.reshape(1, 28, 28, 1).astype("float32") / 255.0 pred = get_model().predict(char_img, verbose=0).argmax(axis=1)[0] chars.append(idx_to_char[pred]) return "".join(chars)这个函数可以在Django里挂一个调试接口,比如/captcha/debug-ocr,把当前验证码图片对象传进去,返回识别文本。只要用settings.DEBUG做开关,生产模式自动关闭,这条链路就只是系统自测能力,不会变成被人利用的后门。
4. 登录流程闭环:验证码如何保护一次真实的表单提交
4.1 会话保存与过期机制:验证码答案不能只丢给前端
先说一个常见的错误设计:把验证码答案放在前端控件里,比如一个隐藏input,或者图片文件名里带上答案。这种设计在演示时看起来“工作正常”,但只要稍微追问一下安全原理就站不住脚。
正确做法是生成图片时把答案写入服务端session,前端只显示图片流。Django自带session机制,默认存数据库或缓存中,对毕设来说足够。
要注意的细节是过期时间。验证码不是无限期有效的,一般建议60到120秒就过期,过时就要求用户刷新。我的实现里记录了生成时间戳:
captcha_time = request.session.get("captcha_time", 0) if int(time.time()) - captcha_time > 120: error = "验证码已过期,请刷新"还有一个容易被忽视的点:验证码是一次性的。校验成功后应该立刻把session里的答案清掉,防止同一个答案被重复使用。这也是反自动化攻击的基本要求。
4.2 前端刷新与后端校验:一次完整的登录示例
登录视图的校验次序很关键。我建议先校验验证码,再校验用户名密码。理由很直接:验证码目的是挡住自动化攻击,如果先查用户名密码,等于让攻击者可以先消耗大量认证资源,验证码就被架空了。
from django.contrib.auth import authenticate, login as auth_login from django.shortcuts import render, redirect def login_page(request): error = "" if request.method == "POST": username = request.POST.get("username", "") password = request.POST.get("password", "") captcha_input = request.POST.get("captcha", "").strip().lower() captcha_expected = request.session.get("captcha_text", "").lower() captcha_time = request.session.get("captcha_time", 0) if not captcha_expected: error = "验证码已失效,请刷新" elif captcha_input != captcha_expected: error = "验证码错误" elif int(time.time()) - captcha_time > 120: error = "验证码已过期,请刷新" else: user = authenticate(request, username=username, password=password) if user is not None: auth_login(request, user) request.session.pop("captcha_text", None) request.session.pop("captcha_time", None) return redirect("index") error = "用户名或密码错误" return render(request, "login.html", {"error": error})注意比对验证码时统一转成小写,忽略大小写敏感性。这样既能减少用户输入成本,也不会被“大小写匹配”这种无关逻辑干扰。
连续输错次数也要有控制。简单的做法是session里记录一个错误计数,超过5次强制刷新验证码,超过10次就暂时锁定该会话的登录尝试。这段逻辑不用做得很重,但有了它,系统的安全论述就完整了。
4.3 代码分层:把生成、识别、业务逻辑分开维护
工程代码划分我建议这样组织:
captcha_site/ ├── manage.py ├── captcha_site/ │ ├── settings.py │ └── urls.py └── captcha_app/ ├── views.py # 登录视图、验证码视图、调试识别视图 ├── urls.py ├── templates/ │ └── login.html └── utils/ ├── captcha_gen.py # 验证码生成 ├── captcha_ocr.py # 图像预处理、分割、识别 └── cnn_model.py # CNN结构定义与权重加载核心逻辑放在utils里,views.py保持轻量,只做请求接收、session读写、流量控制。这么分层的好处是:生成器可以独立运行脚本批量造数据,识别器也可以独立跑批量评测,不依赖Web环境。万一答辩现场Web页面出问题,还能当场跑命令行演示,多一条退路。
5. 避坑清单与答辩实用建议
5.1 高频错误的快速定位表
实操中反复出现的问题基本集中在这张表里:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 图片在浏览器显示成破图标 | content_type写成了text/html | 响应里设置content_type="image/png" |
| 刷新图片验证码一直不变 | 浏览器缓存命中 | img的src加时间戳参数 |
| 文字重叠成一团 | 字符间距计算错误 | 用固定步长平铺字符,随机偏移控制在±3px |
| 验证码输对了却一直报错 | session里的答案被多次请求覆盖 | 检查页面是不是加载了多个图片实例 |
| 识别时字符断裂 | 二值化阈值不对或滤波过度 | 改用Otsu自适应阈值,核大小不超过3 |
| 分割出的字符区域数量不对 | 干扰线贯穿文字 | 先做连通域过滤,再看区域数量 |
| CNN准确率忽高忽低 | 训练集和测试集生成参数不一致 | 统一两边的字符集、画布尺寸和噪点强度 |
其中“训练集和测试集不一致”这个坑最隐蔽。比如训练时用的图片是130x45,测试时把图片resize了一下,字符比例变了,模型准确率立刻掉一截。我的经验是生成数据时固定一组参数,训练、测试、评测全部走同一个生成函数,不要中途手动画图往测试集里塞。
5.2 演示阶段的小技巧
毕设答辩实战和开发是两个世界。开发时可以每次刷新赌运气看识别准不准,答辩现场绝对不能这么干。
我建议复盘时准备一份预生成的图片集,比如50张已经标注好的验证码图片,批量跑一遍识别脚本,统计出准确率,再挑几张嘴型清楚、识别成功率高、干扰效果又明显的图,专门用于现场展示。展示流程控制在三步:显示原图、显示预处理后的二值图、显示识别结果和置信度。这三步能直观摊开整个图像处理链路,比光给一个准确率数字有说服力得多。
如果现场环境允许视频录制,录一段从打开登录页到自动识别填充、再到登录成功的完整体验,插到PPT里作备用。以我的经验看,这招在答辩时非常省时间,也避免现场网络和浏览器兼容性带来的翻车风险。
5.3 答辩提问环节的加分点
评委大概率会问这几个方向的问题,提前想好答案:
- 为什么验证码答案要放session而不是放前端?拿这个问题的核心是状态管理,可以扯到Http无状态协议、服务端会话和CSRF的关系。
- 识别模块的意义是什么?不要总强调“突破验证码”,措辞上要往“系统安全自测”靠。可以说:这是对自身验证码强度的自动化检验工具,调试模式才开放,线上默认关闭。
- 干扰强度怎么平衡?这个问题的标准答法是:干扰强度提升会加大用户输入成本,也会给识别系统增加难度;项目里可以通过调节噪点数量、干扰线数量和扭曲振幅三个参数,做一组对照实验,观察识别率变化曲线。如果能现场展示这张曲线图,加分效果很明显。
- 模型准确率为什么不做到99%?可以说:验证码本身追求的是“人容易识别、机器难破解”,但机器识别能力也在增强,这是一个动态对抗过程;毕设的意义在于把这条对抗链路完整跑通,而非把某一方做到绝对极致。
我真正做完这套项目后的一个突出体会是:生成器和识别器是互相成就的。生成端稍微加强一点扭曲,识别端的预处理就要跟着调半天;反过来,识别端想提高准确率,又逼着你去理解连通域分析、形态学操作和模型结构。这种对抗式学习带来的实打实成长,跟做一个纯后台管理系统完全不是一个量级。
时间规划上给你一个参考:登录页面两天,验证码生成器一星期,传统识别方法一星期,CNN模型两星期,Django集成加调优三天,最后留一星期专门跑对照实验、整理文档和做答辩PPT。节奏拉开,每一块都有沉淀,项目自然能撑得起“完整系统”这四个字。