简介:一款面向计算机专业课程设计与毕业设计的答题卡识别源码包,基于Python数字图像处理技术实现选择题、准考证号识别与答题卡判分。适合正在做期末大作业、毕业设计或需要项目实战练习的学习者,代码完整、可直接运行调试,附带答辩PPT、报告PDF与图片样本,能够覆盖从图像预处理到结果展示的完整流程。压缩包共48个文件,以9个py源码脚本(含训练、识别、数据加载模块)、22张jpg测试图片及5个xml配置为主,另有pptx答辩演示、pdf报告和html展示页面,整体仅2.99MB,轻量易下载。当前已有211人学习使用,主要用于辅助理解数字图像处理中的模板匹配、轮廓检测、字符识别等知识点。项目内目录按src核心代码、模板图片、测试图片、输出结果分层整理,对cv2、numpy等库的使用示范较清晰。可以直接替换图片运行main.py查看识别效果,也可按my_vgg.py等脚本自行扩展训练模型,适合作为高分开题或课设参考。
1. 答题卡识别的关键不是“识别”,而是定位与阈值分离
做答题卡识别课程设计,最容易走偏的是上来就套深度学习模型。实际跑通整套流程后会明白,难点集中在图像几何处理:照片是倾斜的要先透视校正,光照不同则二值化阈值不能写死,填涂区切偏一个像素判分就出错。这套 Python 数字图像处理课设源码走的是“几何校正 + 区域分割 + 阈值判分 + VGG 分类”链路,answer_card_recognition.py做预处理与透视变换,choice_question_recognition.py判选择题,exam_num_recognition.py定位学号,my_vgg.py与train_my_model.py训练数字分类,app.py用 Flask 封装成可上传图片的 Web 页面。源码带 16 张测试图、答辩 PPT 和报告,适合正在做数字图像处理课设、毕业设计或期末大作业的计算机专业学生。
2. 预处理与透视校正:把拍照角度拉回正视图
答题卡识别的第一步不是识别,而是让图像回到“正视角”。手机拍摄的答题卡往往带透视形变,直接按固定坐标切格子必然错位。所以源码里所有识别模块共享同一套预处理链路:缩放、灰度化、滤波、边缘检测、轮廓定位、透视变换。
2.1 灰度化、高斯滤波与 Canny 边缘检测
utils.py和answer_card_recognition.py里,图像进入流程的第一步都是先缩放再灰度化。缩放的目的是控制轮廓检测的计算量,同时让后续透视变换的目标尺寸固定为 800x1000,这样所有 ROI 坐标都可以按这个尺寸预计算。
import cv2 import numpy as np def preprocess_image(image_path: str, target_size=(800, 1000)): # 读取原图并统一缩放,保证后续 ROI 坐标计算可复用 img = cv2.imread(image_path) img = cv2.resize(img, target_size) # 灰度化:边缘检测只依赖亮度梯度,不需要颜色信息 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯滤波:核越大越模糊,噪声多时优先加大核而不是调 Canny 阈值 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # Canny 双阈值:低阈值连接断裂边缘,高阈值抑制噪点 edges = cv2.Canny(blurred, 50, 150) return img, gray, blurred, edges几个参数值得展开说。高斯核(5, 5)是通用值,扫描件干净时可以降到(3, 3);手机拍摄、桌面有纹理时我一般加到(7, 7)。Canny 双阈值50/150中,低阈值决定边缘断点能否连上,调太低会引入大量细碎轮廓,调太高外边框会断成几段。判断标准很直接:把edges用cv2.imshow显示出来,看答题卡四条边是否闭合。这个阶段的输出质量基本决定了透视校正的上限,因为findContours依赖边缘的连续性。
高斯滤波还有一个容易被忽略的作用:抑制 JPEG 压缩在填涂边界产生的振铃效应。拍照后直接用原图做 Canny,填涂区域边缘会出现双线,影响后续面积统计精度。先滤波再边缘检测,双线问题明显减少。
| 阶段 | 函数 | 关键参数 | 参数失效表现 |
|---|---|---|---|
| 灰度化 | cv2.cvtColor | COLOR_BGR2GRAY | 无 |
| 高斯滤波 | cv2.GaussianBlur | ksize=(5,5), sigmaX=0 | 边缘断裂时加大核 |
| 边缘检测 | cv2.Canny | threshold1=50, threshold2=150 | 边框不闭合时降低 threshold1 |
| 轮廓拟合 | cv2.approxPolyDP | epsilon=0.02*peri | 顶点数不等于4时调整 epsilon |
2.2 轮廓提取与四边形拟合
拿到边缘图后,下一步是找到答题卡的外边框。常见做法是cv2.findContours取最外层轮廓,按面积排序后用approxPolyDP拟合成四边形。
def find_card_contour(edges): # RETR_EXTERNAL 只取最外层轮廓,能避开内部填涂块形成的闭合区域 contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 按面积从大到小排序,答题卡通常占画面最大比例 contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5] card_pts = None for cnt in contours: # epsilon 与周长成正比:周长越长,允许的拟合误差越大 peri = cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) # 答题卡外框是矩形,拟合后应得到 4 个顶点 if len(approx) == 4: card_pts = approx.reshape(4, 2).astype(np.float32) break if card_pts is None: raise ValueError("未找到答题卡边框,尝试调低 Canny 低阈值或加大高斯核") return card_ptsapproxPolyDP的0.02 * peri是最需要调的比例参数。它控制拟合时允许多大的偏差:值越大多边形越粗糙,顶点越少;值越小越贴近实际边缘,但也容易把矩形拟合出 5 到 6 个顶点。场景固定时,把比例在0.01到0.04之间各跑一遍,选能稳定输出 4 个顶点的那组。RETR_EXTERNAL也很关键,它只检索最外层轮廓。如果填涂区域的黑色连成一片形成闭环,用RETR_LIST会把内部区域误识别为卡片边界。
2.3 透视变换:把四边形映射成标准矩形
找到四个顶点后,需要把照片里倾斜的区域“拉平”。用getPerspectiveTransform计算单应矩阵,再用warpPerspective重采样。四个点的顺序必须一致,否则输出会是镜像或对角翻转。
def order_points(pts): # 用坐标和、坐标差排序角点,旋转 45 度以上也能稳定对应 rect = np.zeros((4, 2), dtype=np.float32) s = pts.sum(axis=1) # x + y rect[0] = pts[np.argmin(s)] # 左上角和最小 rect[2] = pts[np.argmax(s)] # 右下角和最大 diff = np.diff(pts, axis=1) # y - x rect[1] = pts[np.argmin(diff)] # 右上角差最小 rect[3] = pts[np.argmax(diff)] # 左下角差最大 return rect def warp_card(img, pts): pts = order_points(pts) dst = np.array([[0, 0], [800, 0], [800, 1000], [0, 1000]], dtype=np.float32) # M 是 3x3 透视变换矩阵,建立原图到标准视图的坐标映射 M = cv2.getPerspectiveTransform(pts, dst) warped = cv2.warpPerspective(img, M, (800, 1000)) return warped, Morder_points用的是几何性质而不是像素位置:左上角横纵坐标和最小,右下角最大;右上角 y-x 最小,左下角最大。这个逻辑在卡片旋转超过 45 度时依然成立,比按 x 坐标排序稳健得多。目标坐标dst设为 800x1000 而不是原图尺寸,是为了让后面选择题和学号区域能用固定行列切分。
warpPerspective默认双线性插值。如果缩放后填涂边界发虚,可以显式传cv2.INTER_CUBIC,但耗时增加。反过来,如果图像过大导致内存占用高,可以用cv2.INTER_AREA,它在缩小场景下保留更多边缘信息。
3. 选择题填涂区域分割与答案判定:阈值怎么定才不误判
透视校正只是把答题卡摆正,真正的判分逻辑从区域分割开始。选择题部分没有用模型,而是基于像素统计的二值分类问题——每个格子里是否被填涂。这样设计既符合课设的数字图像处理定位,也便于逐格调试。
3.1 答题卡布局与 ROI 网格切分
这套模板的结构是:顶部一行学号填涂区,下方 20 道单选题,每题 4 个选项。透视校正后图像固定为 800x1000,格子行高、列宽可以直接由图像尺寸除以行列数得到。
def split_choice_regions(warped_gray, rows=20, cols=4, top_margin=100): """ 将答题卡下方区域按题目行、选项列切分为 cell rows: 题目数量;cols: 选项数量;top_margin: 学号区高度 """ cell_h = (warped_gray.shape[0] - top_margin) // rows cell_w = warped_gray.shape[1] // cols cells = [] for r in range(rows): row_cells = [] y1 = top_margin + r * cell_h y2 = y1 + cell_h for c in range(cols): x1 = c * cell_w x2 = x1 + cell_w cell = warped_gray[y1:y2, x1:x2] # 记录 (x1, y1, x2, y2),后面标注结果要回填坐标 row_cells.append((cell, (x1, y1, x2, y2))) cells.append(row_cells) return cellstop_margin是这套源码里最需要按真实模板调整的参数。不同打印机的页边距不同,学号区高度偏差 10 像素,第一道题就会切到学号区。调试时把切分格子画在图上输出,看第一行第一个格子是否正好框住填涂块;偏了就按 5 像素步进调整top_margin。cell_w这里直接用总宽除以列数,如果模板右侧还有姓名栏,需要给cols加偏移量,否则最后一道题的 C、D 格会被截断。
3.2 填涂判定:二值化与面积占比
判定“格子是否被涂黑”,项目用的是面积占比而非模板匹配。核心代码在choice_question_recognition.py里,逻辑是二值化后统计前景像素比例。
def is_checked(cell, threshold=0.2): """ 对单个选项格子做 Otsu 二值化,统计填涂面积占比 threshold: 超过该比例判定为已填涂 """ # THRESH_BINARY_INV:深色笔迹转为白色前景;Otsu 自动选阈值 _, binary = cv2.threshold(cell, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 白色像素占比 = 填涂面积 / 格子总面积 checked_pixels = np.count_nonzero(binary) total_pixels = cell.shape[0] * cell.shape[1] ratio = checked_pixels / total_pixels return ratio, ratio > threshold用THRESH_BINARY_INV是因为笔迹是深色、纸面是浅色,二值化后要把笔迹翻成白色前景才便于计数;THRESH_OTSU让阈值随灰度直方图自动确定,避免不同扫描亮度下固定阈值失效。threshold=0.2表示格子内至少 20% 像素是笔迹才判为填涂。
这个比例是误判高发区。浅涂卡痕迹时占比可能只有 0.1 到 0.15;填涂框本身偏大时 0.2 会把正常填涂漏掉。我的做法是先跑一遍,打印每个格子的ratio分布。正常情况下,未涂格子集中在 0.02 以下,已涂格子集中在 0.35 以上,中间有明显间隔;如果两类数据重叠,优先检查 ROI 是否切偏,而不是急着调阈值。阈值只解决同一个格子内的比例问题,治不了格子框偏移导致的误切。源码里choices/selected和choices/unselected两个目录就是用来快速核对误判的,跑批后直接翻这两个目录里的切图,能一眼看出是哪道题切歪了。
3.3 答案比对与判分逻辑
识别出每题的填涂选项后,与标准答案比对。源码里answer_key是题目下标列表,判分逻辑按“选项一致且非空”计对。
def grade_answer(selected, answer_key): """ selected: 每道题选中的选项下标,未作答记 -1 answer_key: 标准答案下标列表,例如 [0, 2, 1, 3, ...] """ total = len(answer_key) correct = 0 detail = [] for idx, (sel, ans) in enumerate(zip(selected, answer_key)): # 多选、错选、未作答都视为错误 if sel == ans and sel != -1: correct += 1 detail.append((idx + 1, True)) else: detail.append((idx + 1, False)) score = round(correct / total * 100, 2) return correct, score, detail判分本身不复杂,细节在selected生成方式上。is_checked是对单个格子判断,一道题四个格子可能出现两个都超阈值的情况。因此生成selected时必须取四个格子中占比最大的作为该题答案,再传进grade_answer。另一个容易被答辩追问的点是空白卷:如果某道题所有格子占比都低于 0.05,selected里对应位置记 -1,这里已经处理了。
4. 学号识别与 VGG 迁移训练:从分割到分类的工程链路
选择题用像素统计就够了,但学号面对的是手写数字,需要真正的分类模型。项目里my_vgg.py定义了一个精简 VGG,train_my_model.py负责训练,exam_num_recognition.py负责把学号区域切分成单字符。这一部分体现了传统图像处理和深度学习的配合。
4.1 学号区域定位与单字符切分
学号识别的前半段仍是传统图像处理:定位顶部学号区,按 10 个数字位切分。切分方式与选择题类似,但每个格子对应一个数字位。
def split_student_number(warped_gray, num_digits=10): """ 学号区域位于图像顶部,按列切分成 num_digits 个数字格 返回 28x28 的二值图,统一输入尺寸给 VGG """ top_region = warped_gray[:100, :] cell_w = top_region.shape[1] // num_digits cells = [] for i in range(num_digits): x1 = i * cell_w x2 = x1 + cell_w cell = top_region[:, x1:x2] # Otsu 二值化后先膨胀,把断裂笔迹连起来 _, binary = cv2.threshold(cell, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary = cv2.dilate(binary, kernel, iterations=1) # 统一缩放,与训练时的输入尺寸保持一致 cell28 = cv2.resize(binary, (28, 28)) cells.append(cell28) return cells两个细节容易出错。一是先膨胀再缩放:手写数字笔迹断裂很常见,3x3 核膨胀一次可以连接断笔,但迭代次数不能超过 1,否则数字笔画糊在一起,模型会把“0”和“6”、“1”和“7”混淆。二是尺寸必须缩放到 28x28,my_vgg.py里全连接层的输入维度按256 * 3 * 3计算,即假设输入是 28x28;传入其他尺寸会在view时报维度错误。如果学号位是 12 位或含字母,需要同步改num_digits和classes。
4.2 my_dataset.py:数据管线与数据增强
my_dataset.py定义Dataset子类,把图像路径和标签绑定,供DataLoader加载。这是 PyTorch 训练的标准写法。
from torch.utils.data import Dataset from PIL import Image class DigitDataset(Dataset): def __init__(self, img_paths, labels, transform=None): self.img_paths = img_paths self.labels = labels self.transform = transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): # 统一转灰度并缩放到 28x28 img = Image.open(self.img_paths[idx]).convert("L") img = img.resize((28, 28)) if self.transform: img = self.transform(img) label = self.labels[idx] return img, label实际训练中发现,只用公开手写数字数据集训练的模型在答题卡截图上效果一般,原因是答题卡学号格内有定位框线,和公开数据集的干净背景差异大。改进方式是加载公开数据集后,从项目给的0.jpg到15.jpg里截取真实学号区域加入训练集。每类补 30 到 50 张真实截图,泛化效果就有明显提升。transform里可以加随机旋转、平移,幅度控制在 5 度、2 像素以内,幅度大了会让模型学到错误不变性。
4.3 train_my_model.py:VGG 精简结构与训练策略
my_vgg.py不是完整 VGG19,而是按 28x28 输入设计的精简 VGG:4 个卷积块,每块两个 3x3 卷积加 ReLU 和最大池化。输入尺寸小,堆 5 层以上卷积反而会把特征图压没了。
import torch.nn as nn class MyVGG(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(32, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 28 -> 14 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 14 -> 7 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 7 -> 3 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), ) # 3x3 特征图展平,全连接分类 self.classifier = nn.Sequential( nn.Linear(256 * 3 * 3, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return xtrain_my_model.py里的训练超参数按小数据集设计,适配几千张的训练规模。
import torch.optim as optim from torch.utils.data import DataLoader model = MyVGG(num_classes=10) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = torch.nn.CrossEntropyLoss() train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) for epoch in range(15): running_loss = 0.0 for imgs, labels in train_loader: optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f"epoch {epoch+1}, loss: {running_loss / len(train_loader):.4f}")lr=1e-3对 Adam 是安全起点。训练 15 个 epoch 后,如果验证准确率还在上升说明没收敛,继续训练;如果 loss 下降但验证准确率不涨,优先调高 Dropout 或做数据增强,而不是调低学习率。需要特别注意in_channels=1:如果迁移彩色图预训练权重,第一层卷积的in_channels要改成 3,否则加载权重维度对不上。这里“迁移”的含义是数据域迁移——先用公开数据集预训练,再用答题卡截图微调,不是直接加载 ImageNet 权重。
| 超参数 | 值 | 调整建议 |
|---|---|---|
| batch_size | 32 | 数据量低于 1000 时降到 16 |
| lr | 1e-3 | 后期可降到 1e-4 精调 |
| epochs | 15 | 看验证准确率曲线决定 |
| Dropout | 0.5 | 过拟合时加到 0.7 |
| dilate iterations | 1 | 超过 1 会把数字糊成一团 |
4.4 推理与结果融合
训练完成后,推理时把split_student_number切出的 10 个格子拼成一个 batch 喂给模型,得到 10 个数字。
def recognize_number(model, cells, device="cpu"): # 拼 batch 推理,比单张循环稳定且快 batch = np.stack(cells).astype(np.float32) / 255.0 batch_tensor = torch.from_numpy(batch).unsqueeze(1).to(device) model.eval() with torch.no_grad(): outputs = model(batch_tensor) probs = torch.softmax(outputs, dim=1) student_id = "" for prob in probs: digit = int(torch.argmax(prob).item()) student_id += str(digit) return student_idastype(np.float32) / 255.0必须和训练时的归一化一致。推理阶段常见坑是忘了model.eval(),Dropout 仍然生效导致结果随机。模型对“1”和“7”的混淆最多,可以保留概率前二的两类,再结合格子内像素分布做二次判断:竖线偏左、无横笔更像“1”,横笔明显则是“7”。这种“软决策”在答辩演示时也能输出更合理的解释。
5. Flask 封装与答辩演示技巧:把识别结果变成可提交的页面
课设代码在命令行能跑,答辩时演示效果却一般。项目里app.py搭配hello.html、result.html把识别链路包成 Web 应用,上传图片即可看到完整结果。
5.1 路由设计与文件上传
from flask import Flask, request, render_template import os app = Flask(__name__) UPLOAD_FOLDER = "uploads" ALLOWED_EXT = {"jpg", "jpeg", "png", "bmp"} app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER def allowed_file(filename): return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXT @app.route("/") def index(): return render_template("hello.html") @app.route("/upload", methods=["POST"]) def upload(): f = request.files["file"] if not f or not allowed_file(f.filename): return "仅支持 jpg/png/bmp", 400 save_path = os.path.join(app.config["UPLOAD_FOLDER"], f.filename) f.save(save_path) # 管线串联:预处理 -> 判分 -> 学号识别 result = run_full_pipeline(save_path) return render_template("result.html", result=result) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)run_full_pipeline按顺序调用preprocess_image、warp_card、split_choice_regions、grade_answer、split_student_number和recognize_number。debug=True方便现场改代码自动重载,提交代码时建议改回False。
5.2 结果页渲染与中间过程可视化
result.html里除了显示得分,还可以把透视校正前后的对比图、每题判定结果用表格展示。OpenCV 图像要先编码成 base64 再传给模板。
import base64 def encode_img(img_bgr): # BGR 转 RGB,避免网页上红蓝通道反转 rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) _, buf = cv2.imencode(".jpg", rgb, [cv2.IMWRITE_JPEG_QUALITY, 90]) return "data:image/jpeg;base64," + base64.b64encode(buf).decode()记录原图、透视校正后、标注正误三张中间图,页面同一行展示。OpenCV 读入是 BGR,直接imencode会导致红蓝互换,所以必须先转 RGB。判分图用cv2.rectangle把错误题号框出来,答辩时视觉冲击力比文字强得多。
5.3 答辩演示路径与参数避坑
答辩建议走三条演示路径:用answer_card.png跑通完整链路;换一张明显倾斜的测试图如3.jpg,在透视校正后暂停,解释getPerspectiveTransform的坐标映射;把threshold从 0.2 改到 0.4 再改回,展示阈值对判分的影响。被追问“为什么用 VGG 而不是完整 VGG19”时,回答框架是:28x28 输入下 4 个卷积块足够表达手写数字特征,参数量少、收敛快,几千张训练数据不会过拟合。答辩前把train_my_model.py重新跑一遍,记住 loss 收敛区间和验证准确率,被问“怎么判断模型收敛”时直接报数,是最有说服力的演示方式。
本文还有配套的精品资源,点击获取