简介基于OpenCV与Python实现的答题卡识别项目面向计算机相关专业在校学生、老师及企业员工可用于课程设计、毕业设计或图像处理项目初期演示也适合初学者进阶学习。源码经本地编译运行通过评审分95分以上功能完整稳定能直接用于课设、毕设等场景。资源包共4个文件包含两个Python源码文件分别承担答题卡识别主流程与辅助处理功能另附测试样例图片压缩包整体约660KB轻量便携配合随包使用文档可快速上手。该项目已有366人学习下载代码结构清晰注释与模块划分合理覆盖图像预处理、轮廓检测、选项判分等关键环节并配有调试思路与使用说明。读者可在此基础上按需修改扩展或直接作为高分毕设项目的参考资料。1. 从一张随手拍的照片到成绩单答题卡识别到底在识别什么如果你以为答题卡识别是把图片丢给OpenCV然后程序自动“看懂”哪些格子被涂了那多半会翻车。实际跑过这个基于OpenCVPython的答题卡识别项目后你会发现整条管线里真正决定成败的并不是“判断涂没涂”而是前一步的透视矫正和轮廓定位。换句话说拍歪了、光线不均、铅笔灰度太浅都比“算法选型不对”更容易让识别结果变成一堆随机答案。这个高分毕设资源包含两个可独立运行的识别脚本、两份测试图片和使用文档代码已经在本机编译运行过评审分95分以上。它适合正在做课设、毕设或者想用传统图像处理走通“图像→答案→得分”全流程的从业者——你不需要懂深度学习只需要一台装了Python和OpenCV的电脑跟着把管线拆开就能知道每个参数在干什么以及什么情况下它会失灵。2. 先搞懂识别管线为什么是OpenCV而不是CNN2.1 答题卡识别的标准流程与选型理由传统答题卡识别本质上是一个“定位 判决”问题核心依赖的是轮廓检测和像素统计根本不需要训练数据。相比深度学习方案OpenCV路线的优势在于可解释性强、依赖少、单张图片毫秒级出结果而且没有数据集标注成本。你在答辩时也更方便讲清楚每一步为什么这么做——这一点对课设和毕设来说非常关键。整个流程通常分为六个环节灰度化把三通道BGR图像压缩成单通道减少计算量高斯模糊用5x5卷积核去噪避免铅笔颗粒噪点干扰边缘检测边缘检测Canny算子输出二值边缘图用于后续找轮廓透视变换把拍摄倾斜的答题卡矫正为正视图这是整个项目最容易出错的一步阈值分割用Otsu算法自动计算全局阈值把填涂区域从背景中分离轮廓分析与答案判决遍历每个选项框的轮廓统计内部非零像素比例超过阈值即判定为“已填涂”我用过不少类似的毕业设计源码多数项目会在第4步偷懒——直接假设输入图像是正的。而这个项目的做法是拿max-contour当答题卡边界再做四点透视变换属于真正能对付“随手拍”的方案。你可以把这两行代码打印出来看中间结果每一步都存储了中间图调试友好度比很多商用闭源包还高。2.2 环境搭建版本不对后面全是坑在跑通源码之前先把环境钉死。OpenCV的Python接口在4.x和3.x之间行为差异不小部分API在旧版是cv2.findContours返回3个值新版返回2个值——这种细节能卡你一晚上。# Python 3.8 环境下建议 pip install opencv-python4.5.5.64 pip install numpy1.21.6 pip install imutilsimutils是这个项目里用到的一个工具库里面封装了contour排序、透视变换等常用函数能让代码缩短三分之一。如果安装网络不稳可以把opencv-python换成opencv-python-headless识别本身不需要GUI窗口。装完后跑一条命令验证python -c import cv2; print(cv2.__version__)看到4.5.5就说明环境没跑偏。注意不要随手装成opencv-contrib-python最新版部分新版本对旧API的兼容并不好而这份源码是按4.5.x的接口写的。装完环境后把answer_sheet_recognition.py和两张测试图放在同一目录直接执行python answer_sheet_recognition.py终端会输出判出的答案列表、标准答案以及得分。第一次跑通你就已经走完了整个识别流程接下来要做的是逐行拆解它到底在干什么。3. 核心实现拆解从图像预处理到答案判定3.1 图像预处理与透视变换答题卡歪了也能救回来拿到一张答题卡照片第一个要解决的问题不是“涂没涂”而是“卡在哪”。手机拍摄不可避免会产生透视畸变答题卡是矩形但照片里的轮廓可能是个梯形。不矫正就直接做阈值分割结果一定是错位的。先从读取图片和预处理开始import cv2 import numpy as np def preprocess_image(image_path): # 读取图片并缩放宽度统一到800像素保持宽高比 image cv2.imread(image_path) ratio image.shape[1] / 800.0 resized cv2.resize(image, (800, int(image.shape[0] / ratio))) # 灰度化 gray cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY) # 高斯模糊5x5卷积核消除噪点 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny边缘检测低阈值75高阈值200 edged cv2.Canny(blurred, 75, 200) return resized, ratio, gray, edgedratio在这里很关键因为后面算答案坐标时用的是缩放后的图最终映射回原图必须乘回这个比例。Canny的阈值我一般先用75, 200起步如果答题卡边框太淡边缘图会出现断线这时把低阈值降到50。但注意阈值过低会把答题卡内部印刷框也检测成边缘反而干扰最大轮廓的锁定。拿到边缘图后下一步是找出答题卡的四条边界。做法是提取所有轮廓然后按面积排序取最大的那个——正常情况下它就是答题卡本身def get_card_contour(edged): # 提取轮廓新版OpenCV返回两个值注意解包 contours, _ cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 按轮廓面积降序排序取最大的一个 max_contour max(contours, keycv2.contourArea) # 用多边形近似逼近真实四边形边界 peri cv2.arcLength(max_contour, True) approx cv2.approxPolyDP(max_contour, 0.02 * peri, True) return approxapproxPolyDP的第二个参数0.02 * peri是近似精度值越小拟合越严格。如果答题卡边缘本身有折痕你可能会得到五边形或六边形近似这时候需要把精度放宽到0.03 * peri。这一步的输出是四个角点的坐标但它们是无序的必须按“左上、右上、右下、左下”的顺序重排否则透视变换结果会是旋转或镜像的。def order_points(pts): # 四个角点排序左上、右上、右下、左下 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上角xy最小 rect[2] pts[np.argmax(s)] # 右下角xy最大 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上角x-y最小 rect[3] pts[np.argmax(diff)] # 左下角x-y最大 return rect这个排序是后面所有坐标计算的地基。排序错了后面的透视变换矩阵就是错的答题区域定位全部偏移。拿到有序角点后计算目标矩形的宽高再做透视变换def four_point_transform(image, pts): 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)) # 目标矩形的四个顶点顺序必须与rect一致 dst np.array([ [0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]], dtypefloat32) # 计算变换矩阵并执行透视变换 M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped如果你的输入图片本身就是扫描件或截图跳过多边形近似直接传四个角点也是可行的。但切记cv2.getPerspectiveTransform要求输入和输出的点集各自顺序一致且都是float32类型——经常有人在这里传了int数组运行时报Expected Ptrcv::UMat for argument src其实就是数据类型问题。3.2 阈值化与轮廓定位怎么把“涂卡痕迹”变成判决依据透视矫正完之后图像已经变成了一张正面的、规整的答题卡。这时再做灰度化和阈值分割效果会稳定很多。这个项目用的是Otsu自适应阈值它不需要你手动指定阈值算法会遍历所有灰度级找到类间方差最大的那个阈值自动完成前景和背景的分离。def threshold_and_find_boxes(warped): # 转灰度后做Otsu阈值化 gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] # 在二值图上查找所有轮廓这是每个选项框的候选 contours, _ cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) return thresh, contours注意这里用了THRESH_BINARY_INV也就是“反转二值化”——因为涂卡痕迹在灰度图里是偏暗的区域用反相处理后填涂的像素会变成白色255背景变成黑色0。Otsu的假设是图像直方图呈现双峰分布对应“背景”和“前景”两个峰值。如果拍照时环境光不均匀直方图出现多峰Otsu可能会把阴影区域也划进前景这就是很多照片在强光下误判的原因。轮廓定位完成后需要筛掉那些过小或过大的轮廓——噪声点、印刷线、答题卡边框都会生成轮廓但只有真正对应选项框的轮廓才有分析价值# 圆形选项框的筛选逻辑 for c in contours: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) # 圆形框的宽高比应接近1.0这里留出0.8~1.2的容差 if 0.8 ar 1.2: # 面积也要在一个合理范围内 if w 20 and h 20: question_contours.append(c)宽高比ar是筛选的核心指标。方形选项框的宽高比接近1条形码区域宽高比可以是3:1以上印刷线更是细长条——用这个条件可以干净地过滤掉大部分干扰。w 20和h 20的面积下限取决于你拍照时答题卡在画面中的占比如果识别到的选项框不到这个值建议回看透视变换输出图确认缩放是否合理。4. 两个脚本怎么选教学版与紧凑版的边界差异4.1 脚本1与脚本2的差异中间过程可视化 vs 直接出结果这个资源里有两个核心脚本answer_sheet_recognition.py和answer_sheet_recognition_2.py。很多人打开压缩包会困惑两个脚本看起来功能类似是不是有一个是冗余的其实不是这两份脚本对应的是两种不同使用场景。脚本1的定位是“教学过程可视化”。它的代码按函数拆分——预处理、轮廓提取、透视变换、答案判决各自独立并且每步都有cv2.imshow或者保存中间图的逻辑。我跑的时候把每一阶段的输出图都导出来看过边缘检测后的线稿图、透视变换后的矫正图、二值化后的分割图——这对理解OpenCV图像处理管线和准备答辩PPT非常有帮助。脚本2的定位是“结果导向的紧凑实现”。它的预处理和判决逻辑集成在主流程里不再单独拆函数全局变量更少直接跑完就输出成绩。使用场景是你已经有了一份测试图集不需要看中间状态只要批量地输入输出。# 脚本2核心循环的结构示意 for q_idx, question in enumerate(question_contours): # 第q_idx题的5个选项轮廓按x坐标排序 choices sorted(question, keylambda c: cv2.boundingRect(c)[0]) choice_results [] for c in choices: (x, y, w, h) cv2.boundingRect(c) roi thresh[y:y h, x:x w] # 用掩码统计轮廓内部的白色像素占比 mask np.zeros(roi.shape, dtypenp.uint8) cv2.drawContours(mask, [c], -1, 255, -1) pixel_sum cv2.countNonZero(cv2.bitwise_and(roi, roi, maskmask)) ratio pixel_sum / float(cv2.countNonZero(mask)) choice_results.append(ratio) # 超过阈值的选项判定为“已填涂” detected np.where(np.array(choice_results) 0.5)[0] # 映射为A/B/C/D/E字母这里的判定阈值0.5代表涂卡像素占选项框总像素的50%以上才认定“填涂了”。如果学生用2B铅笔涂得很浅或者在框内打勾而不是涂满这个阈值就偏高了。我在测试图片t1.png上跑的时候发现有一道题判不出来后来把阈值降到0.3才通过。如果你用的是HB铅笔或复写纸建议阈值设为0.25起步。4.2 参数调整的边界什么时候改全局变量什么时候改算法这两个脚本都把关键参数放在文件头部方便调整。你需要关注的参数主要有这么几个表格脚本关键参数与调整建议参数脚本中位置默认值调整依据Canny低/高阈值预处理部分75 / 200边缘断裂就降低噪声增多就调高approxPolyDP精度系数轮廓近似0.02四边拟合异常时改为0.03选项判定阈值答案判决0.5铅笔浅涂时降到0.3宽高比容差轮廓筛选0.8~1.2方形框可收紧到0.9~1.1最小选项框尺寸轮廓筛选20px图片尺寸大要上调过滤噪点请注意参数不是万能药。如果透视变换的输出图本身是歪的调阈值没有意义如果选项框错位调整判定阈值只会让错判从“漏判”变成“误判”。我的习惯是每次只改一个参数并且保存中间输出图来做前后对比——盲调多个参数是定位问题的大忌。5. 避坑指南这个项目最容易翻车的四个地方5.1 现象MAX_COLOR_VAL100导致扫描件识别率骤降现象在明亮环境拍摄的图片识别正常但换到扫描仪输出的图片后识别率突然下降很多空白选项被误判为“已填涂”。原因在脚本里你可能会看到类似MAX_COLOR_VAL 100这样的全局阈值它的作用是把灰度值大于100的像素视为白色背景。扫描件的背景灰度通常在180以上但印刷噪点也有不少落在100~180区间这些噪点被误当成前景导致选项框内非零像素比例虚高。解决把阈值常量从固定值改成动态计算用Otsu求出的阈值再加上一个偏移量通常是thresh_value - 10作为前景判断下限。或者更直接的办法在选项判决那一行改成基于cv2.bitwise_and后计算有效比例不要直接用全图二值化结果。改完后再跑一遍原测试图确认没有引入新的误判。5.2 现象透视变换输出的图像是镜像或旋转90度的现象变换后的答题卡朝向不对文字是反的选项框位置全乱。原因order_points函数的排序逻辑错了。这个坑特别隐蔽——排序本身不会报错但角点顺序一旦错位变换矩阵就完全不对。我用test6.jpg跑的时候出现过一次镜像输出排查了半天发现是np.diff(pts, axis1)对二维数组的处理维度和我预期不一致导致右上角选成了左上角。解决给order_points加一个可视化调试在原始图上把四个点用不同颜色的圆圈标出来并标注序号。确认序号顺序依次是左上、右上、右下、左下。如果顺序不对打印函数内部的s和diff两个中间数组检查维度。注意np.diff的输出形状是(4, 1)取值时要取[:, 0]。5.3 现象答题卡区域有折痕或阴影时最大轮廓锁定失败现象边缘检测后的最大轮廓不是答题卡边框而是阴影或折痕形成的内部闭合区域。原因max(contours, keycv2.contourArea)只按面积选不检查轮廓近似的点数量。答题卡边缘在强光下断裂成多段面积最大的反而可能是答题卡内部某个选项框区域甚至是一条粗折痕。解决在选最大轮廓之前先加一道过滤条件轮廓近似后的点数量必须在4到6之间四边形或轻微变形的四边形面积必须占整张图的一定比例。这两个条件同时满足才把它当作答题卡边界。加了过滤后折痕和阴影基本都被排除掉了这道题从“看运气”变成“可预期”。5.4 现象按x坐标排序选项框时左右分栏的答题卡排序错乱现象答题卡是A4横向排版、左右两栏分布按x坐标排序后第1题到第10题的选项框混在一起答案全部错位。原因脚本默认答案按照“从上到下、从左到右”的Z字形顺序排列。但当答题卡是左右分栏版式时同一行右侧的题目x坐标反而大于左侧下一行的题目单纯的x排序导致序列断裂。解决先按y坐标聚类成行然后每一行内再按x排序最后按行号拼接。具体做法是遍历所有选项框轮廓取它们的中心点y坐标按相近的y值分组容差可取20像素。分组后每组里按x排序再按照组号从上到下输出。这个改动大概需要20行代码但换来了对分栏版式的兼容是值得的。6. 从识别到可用把输出变成一份成绩报告脚本默认在终端打印答案和得分但实际课设或毕设展示时你通常需要的是“带标记的结果图”和“结构化成绩数据”。这里给出一个我常用的收尾方案把OpenCV的绘图能力和Python的文件输出结合到一起。def draw_results(warped, question_contours, correct_answers, detected_answers, output_pathresult.jpg): # 在矫正后的答题卡上画圈标记 for q_idx, (correct, detected) in enumerate(zip(correct_answers, detected_answers)): if correct ! detected: # 找到该题对应的轮廓画红色圆圈标记错误 for c in question_contours[q_idx]: (x, y, w, h) cv2.boundingRect(c) if detected NONE: # 漏答整题轮廓用黄色标出 cv2.rectangle(warped, (x-5, y-5), (xw5, yh5), (0, 255, 255), 2) else: # 答错在错误选项上画红叉 cv2.drawContours(warped, [c], -1, (0, 0, 255), 3) # 保存带标记的结果图 cv2.imwrite(output_path, warped) # 计算得分并写入文本文件 total len(correct_answers) score sum(1 for c, d in zip(correct_answers, detected_answers) if c d) with open(score_report.txt, w, encodingutf-8) as f: f.write(f总题数: {total}\n) f.write(f答对: {score}\n) f.write(f得分: {score / total * 100:.1f}\n) f.write(错题详情:\n) for idx, (c, d) in enumerate(zip(correct_answers, detected_answers)): if c ! d: f.write(f第{idx1}题: 标准答案{c}, 识别结果{d}\n) return score这段代码做的事情很朴素答对的题不画任何标记答错的题在对应选项上画红叉漏答的整题用黄框圈出来。输出文件同时包含总题数、得分率和错题详情——这份材料可以直接用来写验收报告也可以贴到课设答辩PPT里做“测试结果展示”页面。zip(correct_answers, detected_answers)这行有个隐患两份答案的长度不一致时会静默截断到较短的那个。我吃过一次亏标准答案20题、识别输出18题结果得分按18题算还是满分看起来非常不合理。建议在调用前加上长度断言assert len(correct_answers) len(detected_answers), 答案数量不一致还有一个小技巧如果答题卡背景是浅黄色的红色圆圈在视觉上不够突出可以考虑用cv2.line画双色标记——先画一圈白色粗线打底再画红色细线覆盖在任何底色上都清晰可见。那之后我每次跑这套识别管线都会强制走一遍“中间结果落盘”的习惯边缘图、透视变换图、二值图各存一份选项框标记图和成绩报告再存一份。从拿到一张新照片到输出结果全程不超过两分钟但任何时候出了问题打开中间图看一眼就知道是哪一步坏了不需要从头猜。这个习惯帮我节省了大量调试时间也希望帮到你。本文还有配套的精品资源点击获取