简介:Python+OpenCV+PyQt开发的答题卡识别软件源码,面向计算机相关专业毕业设计、课程设计及期末大作业场景,也适合有Python基础、希望实战图像处理与GUI开发的初学者。项目涵盖图像预处理、选项检测、学号识别、成绩统计等完整答题卡识别流程,并通过PyQt搭建可视化操作界面,下载后可直接运行体验。资源共48个文件,以Python源码为核心,辅以测试图片、模型文件、XML配置、HTML结果页与答辩PPT等,压缩包约3MB。图片样本便于验证识别效果,py文件对应核心算法和界面逻辑,配置与模型文件支持项目环境快速还原,整体结构清晰、易于二次开发。目前已有100人学习浏览,项目源自导师指导并认可的高分设计,评审分99分,代码完整且确保可运行。获取后可得到可执行源码、测试数据集、训练模型及答辩演示材料,既可作为项目实战练手,也能直接用于毕业设计或期末大作业的提交与展示,对理解OpenCV图像处理与PyQt应用开发的结合方式很有帮助。
1. Python+OpenCV+PyQt答题卡识别软件源码:它到底能当“毕设撑腰”还是只能当demo?
我拆过不少挂着“毕业设计”名头的视觉项目,这套Python+OpenCV+PyQt的答题卡识别源码算是少数真正能闭环的一套:它不只是给你一段图像处理流程,而是从图像透视矫正、选项填涂检测到判分统计、GUI展示全都串起来了。你拿到的不是单点函数,是一整个“上传图片—出成绩单”的应用骨架。适合三类人:期末大作业里需要交可运行界面的在校生,想快速验证答题卡识别思路的开发者,以及需要带学生做程序演示的导师——只要你有基本的Python环境,改几条路径就能看到识别结果。但它也不是开箱即用:填涂判定的阈值、答题卡版式的行列数、甚至铅笔深浅都会影响识别率。这篇笔记就把我拆包、复现过程中最关键的步骤和翻车点一次说清。
2. 答题卡的透视矫正:先拿到“端端正正”的图再谈识别
2.1 不矫正的代价:歪斜图为什么必然判错
实际使用里,手机拍摄的答题卡几乎没有完全水平的,相机角度一偏,选项框坐标就全跟着偏。如果不先把答题卡区域从背景里抠出来并校正成正面视角,后面无论二值化还是连通域统计都会乱套。常见做法是:先做灰度化和边缘检测,再通过findContours拿到整张答题卡的外轮廓,用approxPolyDP压成四个角的四边形,再用getPerspectiveTransform做透视变换。这四步几乎是答错所有题的起点。这里有一个容易被新手忽视的坑:findContours返回的轮廓顺序不是固定的,需要自己按面积排序,并且要处理四边形顶点的顺序,否则透视变换出来的图可能是倒的。我一般会把这一步单独封装成一个函数,并且在调试阶段额外弹一个窗口直接看变换后的图是否端正。
import cv2 import numpy as np def order_points(pts): # 按 (x+y) 排序取左上与右下,按 (y-x) 排序取右上与左下 rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上 rect[2] = pts[np.argmax(s)] # 右下 diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] # 右上 rect[3] = pts[np.argmax(diff)] # 左下 return rect def perspective_correct(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blur, 75, 200) cnts, _ = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:1] peri = cv2.arcLength(cnts[0], True) approx = cv2.approxPolyDP(cnts[0], 0.02 * peri, True) if len(approx) != 4: raise ValueError("未找到矩形答题卡区域") pts = approx.reshape(4, 2).astype("float32") rect = order_points(pts) (tl, tr, br, bl) = rect widthA = np.linalg.norm(br - bl) widthB = np.linalg.norm(tr - tl) maxWidth = max(int(widthA), int(widthB)) heightA = np.linalg.norm(tr - br) heightB = np.linalg.norm(tl - bl) maxHeight = max(int(heightA), int(heightB)) dst = np.array([[0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(img, M, (maxWidth, maxHeight))逻辑说明:Canny的阈值这里用了75和200,对白底答题卡效果比较好;如果扫描件边框非常浅,可以把200往下调到150。approxPolyDP里的0.02 * peri是经验比例,意思是按轮廓周长的2%作为多边形拟合精度,太小会拟合出一堆碎边,太大又可能把矩形压成三角形。最后以四边形的最长边确定输出尺寸,避免矫正后的图被拉伸变形。返回值是一张正向的白底图像,后面所有判分逻辑都以这张图为基础。
参数说明:这个函数的输入可以是手机拍的彩色图,输出是端平扶正的答题卡图。三个关键参数是高斯核大小(5,5)、Canny双阈值、approxPolyDP精度。高分辨率图片建议先缩放到宽度800像素再走这套流程,速度提升明显,而且轮廓查找更稳定。用RETR_EXTERNAL只取最外层轮廓,可以避免把答题卡内部的题号框误当外框。
2.2 轮廓找不到或者找到的不是答题卡时怎么办
最典型的翻车场景:背景里有桌面纹路或手指入镜,Canny之后最大轮廓根本不是答题卡。我一般会先用RETR_TREE替换RETR_EXTERNAL,或者把画面先裁剪到中心区域再找轮廓。还有一种情况是答题卡本身在深色背景上,白底变成黑底,Canny边缘会把内边框识别成最大轮廓。这种可以通过灰度均值判断:如果整图灰度均值小于128,就先做一个反色操作,把白底答题卡拉回到正常状态。源码里没有写这一步,但你要拿它处理扫描仪图片,这个检查几乎必加。
另外,EXTERNAL模式下如果答题卡带圆角或者边缘有毛刺,approxPolyDP可能凑不出四个点。我常用的补救是把Canny的低阈值抬高到100以上,让边缘更连续,或者在做轮廓近似之前先对边缘图做一次闭运算,把细小的断裂补上。这步比较看具体输入,属于一个需要现场试的参数,建议用滑杆同步调节Canny阈值观察矫正结果。把“找到的四边形顶点打到原图上”这个可视化动作加上,比盲调参数高效得多。
提示:如果同一张图换个阈值,矫正结果完全不一样,说明问题出在边缘检测阶段。先把输入统一缩放到固定宽度,再做边缘检测,比反复调阈值可靠得多。
3. 填涂检测与判分:选项格子怎么定位,怎么判断涂了还是没涂
3.1 从矫正图到选项区域:写死版式还是动态投影
拿到端正的图之后,不能直接全图找黑点,因为答题卡上还有题号、页码、二维码和姓名区域。先把彩色图转灰度,再做二值化。答题卡通常是白底黑字,用THRESH_BINARY_INV可以把填涂的黑色区域转成白色、背景转成黑色,方便后续统计白色像素。
定位选项框有两种思路:一种是写死答题卡的版式,按行数和列数等分图片;另一种是自动检测每行每题的位置。这套源码用的是写死行列数的方式,这也是绝大多数毕业设计项目的通用做法——答题卡版式固定,程序只需要按固定网格切分选项格。如果你要识别不同版式的卡纸,就需要做投影分析:对二值化图片分别沿水平方向和垂直方向统计黑像素分布,找到峰谷分界点,再把图片切成行和列。投影切分对排版的鲁棒性更高,代价是代码量会增加不少。我一般在处理标准答题卡模板时直接用固定行列,处理问卷类版式才做投影。
3.2 像素密度判空与标准答案比对
每个选项格单独取出来之后,统计白色像素占总面积的比例,大于某个阈值就认为被涂黑。判断“涂了”还是“没涂”,阈值定0.2还是0.5差别不小——铅笔涂得太浅或者擦得不干净,都会让密度值卡在0.2到0.6之间。我的习惯是先按0.3兜底,再根据实际错判情况上调。
def check_marked(roi_gray, thr=0.3): _, bw = cv2.threshold(roi_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) white = cv2.countNonZero(bw) ratio = white / (bw.shape[0] * bw.shape[1]) return ratio > thr, ratio answers = {1: 'A', 2: 'C', 3: 'B', 4: 'D'} # 模板答案 score = 0 for q, ans in answers.items(): # 假设每个选项格已经拆成 roi_map[q]['A'] ~ roi_map[q]['D'] ratios = [] for opt in ['A', 'B', 'C', 'D']: marked, r = check_marked(roi_map[q][opt]) ratios.append(r) chosen = 'ABCD'[ratios.index(max(ratios))] if chosen == ans: score += 1 # 把 ratios 存进日志,便于调试阈值逻辑说明:check_marked函数内部又做了一次OTSU二值化,而不是直接用全图二值化的结果。原因是每个选项格大小、纸张底色有细微差异,局部自适应按格子重新算阈值,比全局一刀切稳定很多。返回值是判定结果和白色像素占比,占比可以用来排序、调试和阈值分析。判分时用“四个选项中占比最大”作为答案,而不是直接拿阈值去布尔判断,这样即使阈值定得偏小,只要真正涂黑的选项密度明显比其他三个高,依然能选对。
参数说明:OTSU的阈值是算法自动算的,不需要手调,但前提是格子里的噪声不能太重。如果扫描件有明显噪点,先在函数里加一步cv2.medianBlur(roi_gray, 3)再二值化。thr=0.3不是随手写的:多数2B铅笔填涂的选项格,白色像素占比能到0.4到0.6,没涂的空格通常在0.15以下。换成HB铅笔,占比会掉到0.3附近,这时需要把阈值放宽到0.25。
3.3 脏点、折痕、填涂太轻的三种处理
脏点被误判成填涂,是识别率最大的天敌。流程上先做一次形态学闭运算,把选项区域内的细颗粒噪点滤掉。
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) bw = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, kernel)闭运算对白底上的独立黑点有效,但那些是灰色而不是纯黑的脏迹,需要先做灰度归一化再进阈值。填涂太浅时,先膨胀再阈值,或者直接改用cv2.adaptiveThreshold做局部自适应二值化。这三个问题的共同根源是“白色像素统计不可靠”,核心思路是让图像尽量接近干净模板,而不是盲目调判空阈值。还有一种做法是记录整张答题卡所有选项格的密度分布,如果某个格子的密度偏离中位数异常大,多半是脏点而不是填涂。
注意:形态学核不是越大越好,3×3和5×5对细线笔画影响完全不同。答题卡里印好的选项字母如果被闭运算扩张成块,密度会被大幅拉高,导致空白格误判为填涂。我一般只在扫描件有明显的细颗粒噪点时才用闭运算。
4. PyQt界面集成:把识别函数变成能交差的桌面软件
4.1 GUI骨架:主窗口、按钮和表格的布局
识别引擎写好后,剩下的工作就是把它塞进PyQt界面。QMainWindow加布局管理器,是这套源码里工作量最小但最容易出问题的地方。常见问题不是布局写不出来,而是信号槽连接错误导致按钮点了没反应。下面是一段最基础的界面骨架:
from PyQt5.QtWidgets import (QMainWindow, QPushButton, QLabel, QFileDialog, QTableWidget, QApplication) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("答题卡识别") self.btn = QPushButton("选择图片", self) self.btn.clicked.connect(self.open_image) self.table = QTableWidget(self) # 布局代码省略:常见做法是用QVBoxLayout把按钮和表格上下排列 def open_image(self): path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "Image Files (*.jpg *.png *.bmp)") if path: self.process(path)逻辑说明:open_image用QFileDialog弹出文件选择框,拿到路径后调用process方法,process内部再调用前面写好的识别函数。如果process直接写在这个方法里,图片一大会卡住界面,所以4.2节要把它移到QThread里执行。注意这里按钮点击信号connect时一定不能加括号,加了括号就成了“立即调用一次”,这是新手最容易翻车的点。
参数说明:QFileDialog的过滤器只放jpg、png、bmp三种格式,因为答题卡扫描件基本就这三类。如果你想把raw格式也放进来,后续就要写额外的格式判断逻辑,性价比不高。识别结果用QTableWidget展示时,行数按题目数量设置,列数固定为“题号、学生答案、标准答案、是否得分”四列,这样表格和判分结果能直接对应上。
4.2 为什么识别要放到QThread:别让界面白屏转圈圈
一个识别流程大约要跑300到800毫秒,图像大一点会到两秒左右。直接把process写在主线程,界面会在点完按钮后直接白屏好几秒,演示现场非常尴尬。用QThread把识别放到后台,识别完成后用信号把结果传回主线程更新界面,这是源码里没有细讲但直接影响使用体验的一步。
from PyQt5.QtCore import QThread, pyqtSignal class Recognizer(QThread): result_ready = pyqtSignal(dict) def __init__(self, path, answer, parent=None): super().__init__(parent) self.path = path self.answer = answer def run(self): # 依次调用矫正、切分、判分函数 res = self.process(self.path, self.answer) self.result_ready.emit(res) def process(self, path, answer): # 省略:这里复用第二章和第三章的函数 return {"score": 12, "detail": []} # 示意数据 # 在主窗口里 self.thread = Recognizer(path, answer) self.thread.result_ready.connect(self.show_result) self.thread.start()逻辑说明:pyqtSignal(dict)定义了一个携带字典参数的信号,run方法在后台执行整个识别流程,最后把返回值emit回主线程。show_result收到dict后再更新QTableWidget。这里有一个关键细节:QThread对象必须作为窗口的属性保存,不能是局部变量,否则Python的垃圾回收会在线程还没跑完时把它干掉,程序直接闪退。这种崩溃往往不报任何异常,排查时第一个看线程引用还在不在。
参数说明:信号传dict而不是传多个独立参数,是因为识别结果里包含总分、每题答案、每个选项的像素密度,打包成dict最灵活。后面如果还要加识别进度条,可以再定义一个progress_ready信号,按2%步进反馈进度。注意不要在run里调用cv2.imshow这类GUI阻塞函数,一旦弹窗出现,后台线程会被卡住。
4.3 结果展示与再次识别
识别结果拿到之后,要把dict内容填进表格里。这里建议把“填充表格”单独抽成一个方法,方便多次识别时复用。同时界面右下角放一个“重新识别”按钮,点击后用上一次的图片路径重新跑一遍流程,这个按钮在校验阈值参数时可以省很多操作次数。
def show_result(self, result): self.table.setRowCount(len(result["detail"])) for row, item in enumerate(result["detail"]): self.table.setItem(row, 0, QTableWidgetItem(str(item["q"]))) self.table.setItem(row, 1, QTableWidgetItem(item["chosen"])) self.table.setItem(row, 2, QTableWidgetItem(item["answer"])) self.table.setItem(row, 3, QTableWidgetItem("对" if item["ok"] else "错")) self.statusBar().showMessage(f"总分: {result['score']}")逻辑说明:表格每一行对应一道题,setItem把数据写进对应单元格。重新识别按钮直接复用self.thread,只不过换一个路径。这样设计的好处是,识别引擎和界面完全解耦,内核可以在命令行里单独测试。
5. 避坑排查:复现这套源码时绕不开的五个问题
5.1 现象:自己的扫描件识别率从demo的99%掉到60%
原因:示例图片是干净的电子转pdf,底色纯白、字迹清晰;你自己用手机拍或者扫描仪扫出来的卷子,底色发黄、带网格线和水印,全局灰度统计被整体抬高了,阈值自动算出来的结果就偏了。
解决:在矫正之后先做一次灰度归一化,最简单的是cv2.equalizeHist(gray)做直方图均衡化。这个操作对暗光拍摄的卷子效果很明显。做完直方图均衡化,再按选项格做局部OTSU,识别率能拉回不少。另外,如果你手上的扫描件是300DPI以上,分辨率高、噪点多,先降采样到宽度800像素,反而比在高清图上直接跑更稳。
5.2 现象:透视矫正出来的图是左右翻转或者90度翻转
原因:findContours找到的四个顶点顺序不稳定,order_points函数里用(x+y)和(y-x)的最小值区分配对,但在不同旋转角度下,这个配对会错位。
解决:order_points补完之后,再取答题卡的“题号区”做方向校验。常见答题卡在顶部印有姓名、学号区域,可以用水平投影找黑色像素最密集的那一条带,判定顶部到底在哪一侧,不对就原地旋转90度或180度。这个方法对手机随手拍的图基本必要,对扫描仪正放输入的图则可以跳过。
5.3 现象:铅笔涂得太浅,程序把填涂的选项认成没涂
原因:HB铅笔在OTSU二值化里灰度和背景分离不彻底,填涂区域的白色像素占比达不到0.3的判定线。
解决:把判定阈值往下调到0.22,并在识别前做一次cv2.dilate膨胀,把浅色笔迹的连通域扩大。更稳妥的办法是在调试模式下把每个选项格的密度值打印出来,看看真实数据的分布再定阈值。不要一个阈值套用所有试卷,因为不同批次印刷的答题卡,底灰差异可能大到0.1。
5.4 现象:答题卡上有修改液痕迹,结果那一题反复选错
原因:修改液在二值化之后依然是白色,它会叠加到填涂区域的统计里。如果修改液覆盖了试卷印字,那一块的白色像素占比被抬得很高,直接触发误判。
解决:不要统计整格白色像素,改为统计连通域。用cv2.connectedComponentsWithStats取出每个连通域,只保留面积最大的连通域,再看它的面积是否超过选项格面积的某个比例。修改液形成的小碎块会被这个操作自然过滤掉。这是我在拆这套源码后给它加的最有价值的一段代码,肉眼可见地降低了误判。
5.5 现象:PyQt界面点识别后卡死,点关闭也没反应
原因:识别过程在主线程执行,图片一高清就阻塞GUI事件循环;或者识别函数里有time.sleep、cv2.imshow这类阻塞调用,把整个进程拖死。
解决:把识别切到QThread,并把耗时操作从主循环里挪干净。调试阶段可以在识别函数里按阶段加print时间戳,看到异常长的时间段就能定位卡在哪一步。一个更实际的问题是:如果图片路径是网络路径或U盘路径,IO等待会把线程卡住,先拷贝到本地临时目录再处理,能避开很多莫名奇妙的问题。
6. 进阶实用技巧:批量化、导出和模板热切换,一个都不能少
识别单张图片只是这套源码的起点。我在给一个模拟项目X做交付时,把“一张一张选图”改成了“扫目录自动处理”,顺手加了CSV导出,效果比识别算法本身的改动还要明显。做法是:用pathlib遍历目录,对每张图片调用同一套识别管线,把结果追加进列表,最后用csv模块落盘。注意用sorted对文件名排序,否则试卷顺序容易错乱;导出时编码固定用utf-8-sig,否则Office打开中文名文件会直接乱码。
from pathlib import Path import csv def batch_recognize(folder, answer): rows = [] for img_path in sorted(Path(folder).glob("*.jpg")): res = recognize_one(str(img_path), answer) rows.append([img_path.name, res["score"]]) with open("scores.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["file", "score"]) writer.writerows(rows)逻辑说明:batch_recognize里对目录内所有jpg图片顺序识别,结果统一写进scores.csv。recognize_one就是前面章节封装好的完整流程函数,它在命令行环境也能跑,不依赖GUI。参数说明:glob(".jpg")只匹配jpg,如果目录里混着png可以改成glob(".jp*g"),或者用后缀过滤列表做更完整的匹配。
再往上一层,把标准答案从代码里抽出来做成json配置,加一个下拉框选择模板,这样同一套识别引擎可以同时处理A卷、B卷甚至多套版式。做这个功能其实很快:json.load读配置文件,下拉框的选项就是配置文件的key,选择之后把模板传入识别函数。这种做法让我后来在批量处理不同科目答题卡时省了大量改代码的时间。
从那以后我每次做完一个图像识别demo,都会强制走一遍“换一张不同光源的测试图、打印回真实纸张再扫描”这个检验流程,因为纯电子图测出来的识别率根本不算数。希望它能帮到你。
本文还有配套的精品资源,点击获取