
简介基于Python和OpenCV开发的智能答题卡识别系统毕业设计项目面向计算机相关专业正在做毕业设计的学生也适合需要图像识别项目实战练习的学习者。项目整合了传统图像处理与深度学习方法包含答题卡识别、选择题识别、考号识别等核心模块并附带自定义数据集工具与模型训练脚本帮助读者完整理解从图像预处理到识别结果输出的全流程。资源共48个文件以Python源码为主另有22张测试图片、XML配置文件、HTML前端页面以及答辩PPT和PDF格式的设计报告压缩包仅2.99MB目录结构清晰便于按模块查阅。源码经过严格调试验证可在本机直接运行毕业设计评审得分为98分目前已有373人学习下载。下载后可获得可执行的识别代码、模型训练脚本、测试样例与答辩汇报材料配合报告和PPT可快速复现项目深入理解图像识别从数据准备、模型训练到结果输出的完整链路特别适合需要参考完整毕业设计实现思路的开发者。1. 一张答题卡照片怎么在几秒内变成可打印的判分结果起点是手机拍的一张答题卡终点是学号、每题作答情况和总分。人工判一摞卡要十几分钟这套基于 Python OpenCV 的管线单张不到两秒。最反直觉的是涂卡判断很好做难的是学号数字识别。涂卡只需统计黑色像素占比而学号要认 0-9 的模糊手写体所以项目把 OpenCV 的图像处理找卡、透视矫正、区域切分和一个小型 VGG 网络数字分类拼在一起。这个混合方案对毕业设计很合适传统 CV 部分讲得清深度学习部分又能完整展开代码量不大却覆盖从训练到部署的闭环源码、报告、答辩 PPT 一应俱全评审拿到了 98 分属于可以直接运行、直接讲演的那类资源。2. 答题卡定位与透视矫正轮廓筛选抓四个角点2.1 为什么先做透视变换而不是直接切 ROI手机拍答题卡镜头平面和纸面基本不平行出来的图是梯形近处宽、远处窄。如果不矫正后面按行按列切选项格子时越靠边缘误差越大后十几题的格子会整体错位。所以常见做法是「先找到卡片的四个角点再用透视变换把梯形拉回矩形」这也是 OpenCV 图像处理里文档扫描类任务的标准流程。项目里 utils.py 放通用图像函数answer_card_recognition.py 串完整识别流程两者分工很清楚。有人会想用模板匹配直接定位但模板匹配对光照、阴影、拍摄角度都敏感换个环境就要重新截模板。轮廓筛选只依赖一个假设——答题卡是画面里面积最大的四边形这个假设在考试场景下基本成立鲁棒性好得多。跑之前先把环境装齐Python 3.9、opencv-python、torch前两个负责图像处理第三个给第三章的 VGG 推理用常规 pip install 就行不需要额外配 CUDACPU 跑训练和推理都够。2.2 预处理参数选型灰度、高斯模糊、Canny统一图片宽度到 1000px减少后续计算量也让轮廓筛选的尺度一致。接着是灰度化、高斯模糊、Canny 边缘检测三个算子各有各的影响面import cv2 import numpy as np def preprocess(image): # 统一宽度后续所有参数都按 1000px 宽度设计 h, w image.shape[:2] if w ! 1000: scale 1000.0 / w image cv2.resize(image, (1000, int(h * scale))) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 高斯核 (5,5) 压掉传感器噪点又不会把卡片边缘糊没 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny 低阈值 75、高阈值 200低于 75 的梯度直接丢弃 edged cv2.Canny(blurred, 75, 200) return image, gray, edged选参逻辑高斯核越大噪点越少但卡片边缘变粗角点位置会外移Canny 低阈值决定边缘连续性桌面纹理和卡面反光会产生弱边缘低阈值 75 能滤掉大部分高阈值 200 把强边缘保留下来。如果照片明暗差距大先做一次 CLAHE 增强再进 Canny后面选项识别时同样受益于这个操作。resize 用整数高度是因为 warpPerspective 的输出尺寸只接受整数提前保证这一点可以少踩一个类型报错。2.3 最大四边形筛选与透视变换实现边缘图里除了卡片还有桌面、手指、笔的影子所以按轮廓面积从大到小找第一个能近似成四边形的轮廓def find_card_contour(edged): # RETR_EXTERNAL 只取外层轮廓避免卡内框线干扰 cnts, _ cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts sorted(cnts, keycv2.contourArea, reverseTrue) for c in cnts[:5]: # 只看面积最大的 5 个候选 peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: # 四边形视为答题卡外框 return approx raise ValueError(画面中未找到答题卡)approxPolyDP 的 epsilon 取周长的 2%意思是近似多边形与原始轮廓的最大偏差不超过周长的 2%。调小到 1% 更严格复杂背景里可能找不到四边形调到 3% 会把一些圆弧压成四边形导致误检。拿到角点后做透视变换这里有个容易忽略的细节角点顺序必须统一成「左上、右上、右下、左下」否则输出是旋转或镜像的。def four_point_transform(image, pts): # 按坐标和差排序xy 最小为左上最大为右下 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # y-x 最小为右上 rect[3] pts[np.argmax(diff)] # y-x 最大为左下 (tl, tr, br, bl) rect width max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)) height max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl)) dst np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, M, (int(width), int(height)))宽高取 max 而不是平均是因为透视下两对边长度不一致取 max 保证内容不丢。变换矩阵 M 是 3x3 的OpenCV 对每个像素做一次映射输出就是俯视视角的矩形答题卡后续所有切分都基于这张矫正图。常见的坑卡片边缘被手指或阴影挡住时最大轮廓的顶点数会大于 4。处理办法是放宽到 len(approx) in (4, 5)再按最小外接矩形裁剪一次如果前 5 个候选都没有四边形直接返回「请重拍」的提示比强行继续识别更合理。参数作用建议值调大后的影响高斯核大小平滑噪点(5, 5)边缘变粗角点外移Canny 低阈值弱边缘过滤75太高会丢失暗部卡边Canny 高阈值强边缘保留200太低会引入桌面纹理approxPolyDP epsilon四边形近似精度0.02 * 周长太大把弧线压成四边形轮廓提取模式内外层选择RETR_EXTERNAL换 RETR_LIST 会混入卡内框线3. 学号数字识别从数据集组织到 VGG 训练3.1 为什么学号不直接上通用 OCR学号是手写体虽然写在格子里但每个人的粗细、倾斜、连笔都不一样。Tesseract 这类通用 OCR 对印刷体效果好遇到手写数字且风格和训练集差异大时识别率掉得很快。用形态学找连通域、数笔画也不行数字 0/6、8/9 在笔画残缺时几乎无法区分。所以项目里单独做了一个小型 VGGmy_vgg.py 定义网络my_dataset.py 负责读图train_my_model.py 是训练入口。手写数字分类是 MNIST 级别的任务小网络在几百张样本上就能到 97% 以上CPU 训练十分钟性价比远高于写一堆图像规则。3.2 数据集组织目录即标签训练数据来源常见有两种一是把答题卡学号区域每个格子裁出来人工分到 0-9 的目录二是先用公开手写数字集预训练再拿自己裁的几十张卡做微调。目录名就是 label读取逻辑不需要单独的标注文件# my_dataset.py —— 目录名即标签 import os import cv2 import torch from torch.utils.data import Dataset class DigitDataset(Dataset): def __init__(self, root): self.samples [] # root/0、root/1 ... root/9每目录一类数字 for label_name in os.listdir(root): if not label_name.isdigit(): continue label_path os.path.join(root, label_name) for fname in os.listdir(label_path): self.samples.append((os.path.join(label_path, fname), int(label_name))) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img cv2.imread(path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (28, 28)) # 归一化到 [0,1] 加速收敛unsqueeze(0) 补通道维 img torch.from_numpy(img).float().unsqueeze(0) / 255.0 return img, label关键点是归一化和通道维。除以 255.0 把像素压到 [0,1]模型收敛速度明显快于直接喂 0-255 的整数unsqueeze(0) 把灰度图从 (28, 28) 变成 (1, 28, 28)对应网络第一个卷积层的输入通道。如果采集到的数字有倾斜在getitem里加随机旋转 ±10 度、平移 1~2 像素的增强泛化能力会明显改善这个技巧在样本量少于 200 时几乎是必需的。3.3 网络结构两组双卷积加分类头my_vgg.py 是简化版 VGG——两组「双卷积 最大池化」再接全连接分类头import torch.nn as nn class MyVGG(nn.Module): def __init__(self, num_classes10): super(MyVGG, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 28x28 - 14x14 nn.Conv2d(32, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 14x14 - 7x7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))padding1 保持特征图尺寸28x28 经过两次池化变 7x7展平后是 64773136 维。Dropout 只在训练时按 0.5 概率随机丢神经元推理时自动关闭不需要手动切换。样本量小的话把 64 通道改成 32参数量减半过拟合风险更低识别率损失可以忽略。3.4 训练配置与收敛判断train_my_model.py 用交叉熵损失加 Adam 优化器这是分类任务的标准组合import torch import torch.nn as nn model MyVGG(num_classes10) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): for imgs, labels in train_loader: out model(imgs) loss criterion(out, labels) optimizer.zero_grad() loss.backward() optimizer.step() # 每个 epoch 后打印验证集准确率交叉熵损失直接对应概率分布输出层 10 个值就是 0-9 的置信度。训练完用 torch.save(model.state_dict(), vgg_digit.pth) 存权重推理时加载。观察指标看验证准确率而不是训练 loss训练 loss 降但验证准确率停滞是过拟合加大 Dropout 或增强两者都不动先检查数据目录是否为空、标签映射是否写错这是排查训练问题最快的两条路径。参数取值说明输入尺寸28x28池化两次后 7x7全连接层维度固定batch size32显存不够就减到 16学习率1e-3loss 震荡时降到 3e-4Dropout0.5样本少时提到 0.6epoch30验证准确率连续 5 轮不升就早停4. 选项涂卡统计与判分从行列切分到答案比对4.1 学号区域切分竖直投影找格子边界透视矫正图出来后第一件事是切学号区域再做选项区域。学号区域在卡片顶部一般是 10 位数字每位一格。切分不能直接等宽均分因为手写笔画的投影会把相邻格子的边界粘连。常见做法是先对学号 ROI 做竖直投影——统计每列黑色像素数连续为空的列区间就是格子边界。这块逻辑在 exam_num_recognition.py 里和训练模块解耦换卡版式时只需要改 ROI 的起始比例def locate_digit_cells(warped, roi(0.05, 0.18)): h, w warped.shape[:2] roi_img warped[int(h*roi[0]):int(h*roi[1]), :] gray cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) _, thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) col_sum thresh.sum(axis0) / 255.0 # 每列黑像素数 # 连续 col_sum 0 的区间作为候选分隔线 # 宽度小于 3px 的空隙直接过滤避免噪声产生伪边界投影区间的处理要点先按「连续空白列」找到候选分隔线再过滤掉宽度不足 3px 的窄缝最后取中间 10 个等宽区间作为数字格。这样切出来的每个格子直接 resize 到 28x28 送进 MyVGG得到学号字符串。学号识别错了成绩就记到别人头上所以这里宁可返回「无法识别」让用户人工看也不要硬给一个低置信度的结果可以在取 argmax 时要求最高置信度大于 0.9否则标为未知。4.2 选项区域切分与涂卡判定选项区域切分在 choice_question_recognition.py 里相对简单按行均分题目、按列均分选项就行。默认版式是 20 题单选题、每题 A-D 四个选项这两个值在代码里是常量换版式只改数字。判定是否涂卡的核心是统计每个格子里的黑色像素占比def bubble_score(cell): # OTSU 自适应二值化阈值随格子亮度自动变化 thresh cv2.threshold(cell, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] filled cv2.countNonZero(thresh) total cell.shape[0] * cell.shape[1] return filled / total def mark_answers(choice_grid, threshold0.35): questions, options choice_grid.shape[:2] result [] for q in range(questions): scores [bubble_score(choice_grid[q, o]) for o in range(options)] best max(scores) result.append(best if best threshold else -1) return resultthreshold0.35 是实践中的分界值正常涂卡格子的黑色占比在 0.6 以上未涂格子里印刷圆圈本身有少量黑像素一般在 0.1 左右中间隔得很开。铅笔涂得浅时占比会降到 0.4 左右此时把阈值降到 0.25 就行。用 OTSU 而不是固定阈值的原因是它会按每个格子自己的灰度分布找分割点自动适应局部光照避免整张图一个阈值在强光下失效。4.3 答案比对与判分输出选项结果和学号结果在 answer_card_recognition.py 里汇合输出一个字典结构方便后续渲染模板。判分逻辑就是逐题比对索引ANSWER_KEY [1, 4, 0, 3, 2, 2, 1, 4, 0, 3, 0, 1, 4, 2, 3, 4, 0, 3, 1, 2] # 每题正确答案索引 def grade(answers, keyANSWER_KEY): correct 0 details [] for q, (a, k) in enumerate(zip(answers, key), 1): hit (a k) correct int(hit) details.append({question: q, answer: a, key: k, hit: hit}) score round(correct / len(key) * 100, 1) return {score: score, correct: correct, details: details}索引和选项的映射是 0-A、1-B、2-C、3-D换试卷顺序只改 ANSWER_KEY 这张表识别逻辑不用动。result.html 会把 details 渲染成带红绿标记的表格哪题对哪题错一眼可见答辩演示时比只给一个总分有说服力得多。现象原因处理方式某题两个选项同命中行切分偏移格子错位微调选项区域上下边界检查 row_h 是否整除整列题目漏判铅笔涂色太浅阈值降到 0.25或先做 CLAHE擦除不净误判残留石墨占比超阈值阈值提到 0.45或做形态学开运算学号串位投影切分被笔画粘连过滤窄裂缝置信度不够就报未知5. Flask 服务化与阈值调优把中间结果变成调试依据5.1 路由与模板渲染app.py 和 main.py 都指向同一个 Flask 应用两者只是入口文件不同功能等价GET 请求返回上传页 hello.htmlPOST 请求接收图片、跑完整识别管线、把结果渲染到 result.html。代码很短核心是文件保存和调用识别函数这两件事from flask import Flask, render_template, request import os app Flask(__name__) UPLOAD_FOLDER uploads os.makedirs(UPLOAD_FOLDER, exist_okTrue) app.route(/, methods[GET, POST]) def index(): if request.method POST: f request.files[file] f.save(os.path.join(UPLOAD_FOLDER, input.jpg)) result run_pipeline(os.path.join(UPLOAD_FOLDER, input.jpg)) return render_template(result.html, resultresult) return render_template(hello.html) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)注意 debugFalse因为识别管线里大量调用 OpenCV开着 debug 会让 reloader 重复初始化模型每次上传都慢一倍。如果要在局域网演示host 写 0.0.0.0手机和电脑在同一 Wi-Fi 就能访问不用额外配反向代理。5.2 调优收敛中间产物落盘加逐格打分这套系统调试时最有效的工具不是断点而是把中间图像和每格分数落盘。建议在管线里加一个 debug 开关输出矫正图、二值图、以及每题每个选项的占比# 调试开关True 时把中间结果写到 debug/ 目录 if DEBUG: cv2.imwrite(debug/warped.jpg, warped) cv2.imwrite(debug/thresh.jpg, thresh) for q in range(questions): for o in range(options): print(q, o, round(scores[q][o], 3))打印出来的每一行就是 20x4 的数字矩阵正常卡片的矩阵里每行只有一个超过 0.6 的值其他都低于 0.15。如果某行出现两个超过 0.4 的值就是切分偏移如果整行都低于 0.2就是拍照过暗或阈值过高。把这组数字和矫正图对照看五分钟内就能定位问题出在透视、切分还是阈值。这一招同样适用于学号识别把每个数字格的 top-2 置信度打出来能直接判断是模型问题还是切分问题。把这些 debug 代码留在源码里换一张新卡反复试参数调完再用 result.html 验证整个毕业设计最花时间的调参环节就收在这里。本文还有配套的精品资源点击获取