简介一款面向计算机专业课程设计与毕业设计的答题卡识别源码包基于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.cvtColorCOLOR_BGR2GRAY无高斯滤波cv2.GaussianBlurksize(5,5), sigmaX0边缘断裂时加大核边缘检测cv2.Cannythreshold150, threshold2150边框不闭合时降低 threshold1轮廓拟合cv2.approxPolyDPepsilon0.02*peri顶点数不等于4时调整 epsilon2.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, keycv2.contourArea, reverseTrue)[: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), dtypenp.float32) s pts.sum(axis1) # x y rect[0] pts[np.argmin(s)] # 左上角和最小 rect[2] pts[np.argmax(s)] # 右下角和最大 diff np.diff(pts, axis1) # 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]], dtypenp.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, rows20, cols4, top_margin100): 将答题卡下方区域按题目行、选项列切分为 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, threshold0.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让阈值随灰度直方图自动确定避免不同扫描亮度下固定阈值失效。threshold0.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.05selected里对应位置记 -1这里已经处理了。4. 学号识别与 VGG 迁移训练从分割到分类的工程链路选择题用像素统计就够了但学号面对的是手写数字需要真正的分类模型。项目里my_vgg.py定义了一个精简 VGGtrain_my_model.py负责训练exam_num_recognition.py负责把学号区域切分成单字符。这一部分体现了传统图像处理和深度学习的配合。4.1 学号区域定位与单字符切分学号识别的前半段仍是传统图像处理定位顶部学号区按 10 个数字位切分。切分方式与选择题类似但每个格子对应一个数字位。def split_student_number(warped_gray, num_digits10): 学号区域位于图像顶部按列切分成 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, iterations1) # 统一缩放与训练时的输入尺寸保持一致 cell28 cv2.resize(binary, (28, 28)) cells.append(cell28) return cells两个细节容易出错。一是先膨胀再缩放手写数字笔迹断裂很常见3x3 核膨胀一次可以连接断笔但迭代次数不能超过 1否则数字笔画糊在一起模型会把“0”和“6”、“1”和“7”混淆。二是尺寸必须缩放到 28x28my_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, transformNone): 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.pyVGG 精简结构与训练策略my_vgg.py不是完整 VGG19而是按 28x28 输入设计的精简 VGG4 个卷积块每块两个 3x3 卷积加 ReLU 和最大池化。输入尺寸小堆 5 层以上卷积反而会把特征图压没了。import torch.nn as nn class MyVGG(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 28 - 14 nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 14 - 7 nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 7 - 3 nn.Conv2d(128, 256, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1), nn.ReLU(inplaceTrue), ) # 3x3 特征图展平全连接分类 self.classifier nn.Sequential( nn.Linear(256 * 3 * 3, 256), nn.ReLU(inplaceTrue), 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_classes10) optimizer optim.Adam(model.parameters(), lr1e-3) criterion torch.nn.CrossEntropyLoss() train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) 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(fepoch {epoch1}, loss: {running_loss / len(train_loader):.4f})lr1e-3对 Adam 是安全起点。训练 15 个 epoch 后如果验证准确率还在上升说明没收敛继续训练如果 loss 下降但验证准确率不涨优先调高 Dropout 或做数据增强而不是调低学习率。需要特别注意in_channels1如果迁移彩色图预训练权重第一层卷积的in_channels要改成 3否则加载权重维度对不上。这里“迁移”的含义是数据域迁移——先用公开数据集预训练再用答题卡截图微调不是直接加载 ImageNet 权重。超参数值调整建议batch_size32数据量低于 1000 时降到 16lr1e-3后期可降到 1e-4 精调epochs15看验证准确率曲线决定Dropout0.5过拟合时加到 0.7dilate iterations1超过 1 会把数字糊成一团4.4 推理与结果融合训练完成后推理时把split_student_number切出的 10 个格子拼成一个 batch 喂给模型得到 10 个数字。def recognize_number(model, cells, devicecpu): # 拼 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, dim1) 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, resultresult) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)run_full_pipeline按顺序调用preprocess_image、warp_card、split_choice_regions、grade_answer、split_student_number和recognize_number。debugTrue方便现场改代码自动重载提交代码时建议改回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 收敛区间和验证准确率被问“怎么判断模型收敛”时直接报数是最有说服力的演示方式。本文还有配套的精品资源点击获取