Python+OpenCV答题卡识别系统:透视校正与涂卡判定实战

📅 发布时间:2026/10/2 14:43:47
Python+OpenCV答题卡识别系统:透视校正与涂卡判定实战
简介本资源面向计算机视觉入门者、课程设计或竞赛开发者提供一套基于Python与OpenCV的智能答题卡识别系统完整实现覆盖从图像预处理、答题卡检测、选项区域分割到深度学习选项识别、答案判定与分数计算的完整链路帮助读者理解图像识别算法在真实阅卷场景中的落地方式。压缩包共60个文件约38.86MB包含5个py源码脚本、39张jpg测试与样例图像、8个xml标注文件、1个xls数据表及readme说明等源码、训练与测试数据集、文档一应俱全目录按图像、脚本与配置模块组织便于直接运行与二次修改。目前已有448人学习下载适合希望掌握OpenCV图像处理与CNN分类实践、快速搭建答题卡识别原型的读者参考。1. 从一张歪着拍的答题卡说起这套 OpenCV 识别系统到底能干什么答题卡识别这件事听起来像是考试院的专属需求实际上很多做教育硬件、在线判题、扫描阅卷的团队都会撞上。最常见的场景是老师拿手机随手拍一张答题卡角度是歪的、光线不均匀、边缘还有桌面背景你希望程序能自动把答题卡抠出来、摆正、找到每个题目的选项框、判断哪个被涂黑最后对照标准答案算分。这套基于 Python OpenCV 的智能答题卡识别系统做的就是这条链路。它把图像预处理、透视校正、选项区域分割、涂卡判定、分数计算串成一个可跑通的流程配套给了全部源码、训练与测试数据集和文档适合想快速搭出答题卡识别原型、又不想从零啃论文的开发者。下面我按实际拆包的顺序把这份资源怎么用、参数怎么调、哪里容易翻车讲清楚。2. 拆开 SYS.zip目录结构、技术栈与运行环境怎么定2.1 从文件清单反推这套系统的模块划分拿到 SYS.zip 之后别急着双击运行先花两分钟把目录扫一遍能省掉后面一半的报错排查时间。从文件清单看这套资源的组织方式比较典型根目录下散落着大量答题卡样本图比如demo.jpg、sheet1.jpg、sheet2.jpg、new1.jpg、new2.jpg、bad.jpg、bad2.jpg、bupt.jpg、school.jpg、paper.jpg、t1.jpg、t2.jpg、iidd.jpg、name.jpg、id.jpg、cls.jpg、warped.jpg这些基本覆盖了正常卡、倾斜卡、模糊卡、不同版式的卡是后面调参和验证的主要素材。Python 脚本层面能看到ooctest.py、get_answer.py、iidd.py、excel.py、turntogui.py从命名大致能推断出各自职责get_answer.py负责答案提取与判定excel.py负责把结果写进data.xlsturntogui.py是把命令行流程包一层图形界面iidd.py和ooctest.py更像是识别主流程或测试入口。images目录放中间产物__pycache__是 Python 字节码缓存.idea下是 PyCharm 工程配置workspace.xml、misc.xml、modules.xml、inspectionProfiles、answer-sheet.imlreadme.txt是唯一的文字说明。这套结构说明它不是纯算法 demo而是带了一点工程化的痕迹——有数据、有中间产物、有导出、有 GUI。2.2 环境依赖与版本选择为什么 OpenCV 版本要卡住这套系统跑起来依赖的核心库不多但版本敏感。常见做法是 Python 3.83.10 配 OpenCV 4.xNumPy 跟着 OpenCV 走处理 Excel 需要xlwt/xlrd或openpyxlGUI 部分看turntogui.py用的是 Tkinter 还是 PyQt。这里有个血泪经验OpenCV 4.5 之后部分轮廓相关 API 的行为有微调cv2.findContours的返回值从两个变成三个OpenCV 3 返回 image、contours、hierarchyOpenCV 4 返回 contours、hierarchy如果你的代码是从旧教程抄的直接跑会报解包错误。另外热词里常出现的ModuleNotFoundError: No module named opencv九成是装成了opencv-python之外的包名或者虚拟环境没激活。建议直接用虚拟环境隔离# 创建并激活虚拟环境避免污染全局 Python python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate # 安装核心依赖opencv-python 是主包contrib 版含额外算法 pip install opencv-python4.5.5.64 pip install numpy pip install openpyxl xlwt xlrd参数说明opencv-python4.5.5.64是一个兼容性较好的稳定版本既支持 OpenCV 4 的新 API又不会因为太新导致老代码报错opencv-contrib-python只有在用到 SIFT、SURF 等专利算法时才需要这套答题卡识别用基础模块就够不必多装。装完用一行命令验证import cv2 import numpy as np print(cv2.__version__) # 应输出 4.5.5 print(np.__version__)如果这里就报错后面所有步骤都不用看了先把环境理顺。VSCode 用户记得在右下角选对解释器指向 venv 里的 python否则终端装好了、编辑器里还是找不到模块这种玄学问题我见过太多次。2.3 数据集与样本图的使用方式sheet1.jpg、sheet2.jpg这类是标准版式样本bad.jpg、bad2.jpg是质量较差的样本bupt.jpg、school.jpg可能是不同学校/机构的真实卡面。用的时候不要一上来就拿bad.jpg测先用sheet1.jpg把主流程跑通确认能出结果再逐步换难样本。data.xls是答案与成绩的存储文件get_answer.py大概率会读它做比对。建议先把data.xls复制一份备份因为脚本写回时可能覆盖原文件这是很多人第一次跑就丢数据的坑。3. 答题卡识别的核心链路从透视校正到涂卡判定3.1 图像预处理与答题卡区域定位答题卡识别的第一步不是识别是让计算机知道「卡在哪」。真实拍摄的图里答题卡往往只占画面一部分周围有桌面、手指、阴影。常见做法是灰度化 → 高斯模糊 → 边缘检测 → 轮廓查找 → 按面积和形状筛选出最大的四边形轮廓。这一步的稳定性直接决定后面所有环节因为透视校正依赖这四个角点。下面是一段可复用的定位代码import cv2 import numpy as np def find_answer_sheet(image_path): # 读取图像并统一缩放到固定宽度减少分辨率差异带来的参数漂移 img cv2.imread(image_path) ratio img.shape[1] / 800.0 img cv2.resize(img, (800, int(img.shape[0] / ratio))) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊抑制噪点核大小取奇数5x5 对答题卡这种大轮廓足够 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny 双阈值低阈值太小会引入桌面纹理太大又丢卡边 edged cv2.Canny(blurred, 75, 200) # OpenCV 4 返回两个值别写成三个 contours, _ cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue)[:5] sheet_cnt None for c in contours: # 用多边形逼近判断是不是四边形周长 2% 作为精度阈值 peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: sheet_cnt approx break return img, sheet_cnt逻辑说明先缩放是为了让 Canny 阈值和轮廓面积阈值在不同分辨率下表现一致否则手机拍的大图和扫描仪的小图要用两套参数。cv2.RETR_EXTERNAL只取外轮廓避免卡内选项框干扰。approxPolyDP的0.02 * peri是逼近精度值越小越贴近原始轮廓但越容易逼近出多于四边的形状值越大越容易把不规则四边形简化成四边形实测 0.02 是个平衡点。如果这一步返回None说明没找到四边形先去看edged中间图多半是阈值问题或卡面与背景对比度太低。3.2 透视变换把歪着拍的卡摆正找到四个角点之后要把答题卡「拉平」否则选项框是斜的后面的分割和判定全乱。透视变换的关键是确定四个角点的顺序顺序错了图会翻转或扭曲。常见做法是按坐标和求和排序左上角 xy 最小右下角 xy 最大右上角 x-y 最小左下角 x-y 最大。def four_point_transform(image, pts): # 把四个角点按 左上、右上、右下、左下 顺序排好 rect order_points(pts.reshape(4, 2)) (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]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped def order_points(pts): 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)] # 右上 rect[3] pts[np.argmax(diff)] # 左下 return rect参数说明maxWidth和maxHeight用对边最大值而不是平均值是为了尽量保留分辨率避免信息丢失。warpPerspective的输出尺寸就是这两个值。跑完这一步warped.jpg应该是一张端正的答题卡如果发现上下颠倒就是order_points的排序逻辑被改动了检查sum和diff的用法。这套资源里warped.jpg很可能就是这一步的中间产物可以拿来对照。3.3 选项区域分割与涂卡判定摆正之后答题卡变成一张规整的图接下来要定位每个题目的选项框。常见做法有两种一是基于形态学操作找圆形/矩形轮廓二是基于固定版式按比例切分。前者通用性强但参数难调后者稳定但换版式就要改坐标。这套资源里同时有sheet1.jpg和sheet2.jpg说明版式不止一种建议先用轮廓法做通用定位再对特定版式做微调。def detect_bubbles(warped): gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 自适应阈值应对光照不均比全局阈值稳 thresh cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) # 开运算去掉细小的噪点和文字笔画保留选项框 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) thresh cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) contours, _ cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) bubbles [] for c in contours: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) # 选项框近似圆形或正方形长宽比接近 1面积在合理区间 if 0.8 ar 1.2 and 15 w 60 and 15 h 60: bubbles.append(c) return bubbles逻辑说明adaptiveThreshold的11是邻域块大小必须是奇数太小会把框内涂痕也当成背景太大对光照变化不敏感2是常数 C从均值里减去的值调大能让阈值更严格。MORPH_OPEN用椭圆核是因为选项框多为圆形椭圆核比矩形核更贴合。长宽比和面积区间是过滤条件这两个参数要根据实际答题卡的选项框尺寸调拍得近就调大上限拍得远就调小下限。判定涂黑时统计每个选项框内非零像素占比超过阈值就算涂了def is_filled(bubble_contour, thresh_img, fill_threshold0.35): mask np.zeros(thresh_img.shape, dtypeuint8) cv2.drawContours(mask, [bubble_contour], -1, 255, -1) mask cv2.bitwise_and(thresh_img, thresh_img, maskmask) total cv2.countNonZero(mask) area cv2.contourArea(bubble_contour) return total / area fill_thresholdfill_threshold默认 0.35 是个经验值铅笔涂得浅就调低到 0.25涂得重或印刷框本身有底色就调高到 0.45。这个参数是整套系统里最需要按实际数据调的没有之一。4. 答案判定、成绩导出与 GUI 封装怎么串起来4.1 get_answer.py 与 data.xls 的配合方式识别出每道题涂了哪个选项之后要和标准答案比对。get_answer.py大概率负责读取data.xls里的答案列和识别结果做匹配算出对错和总分。data.xls是 Excel 97-2003 格式读写要用xlrd读和xlwt写或者用openpyxl但要注意它默认不写.xls。常见做法是把答案和成绩分成两个 sheet一个存标准答案一个存学生成绩。跑之前先确认data.xls的表头结构列名对不上会导致 KeyError 或读到空值。如果脚本报xlrd.biffh.XLRDError: Excel xlsx file; not supported说明文件其实是 xlsx 只是后缀写成了 xls用openpyxl打开另存为真正的 xls 即可。4.2 excel.py 导出成绩时的格式坑excel.py负责把判定结果写回 Excel。这里有两个高频坑一是中文列名在某些xlwt版本下会乱码需要显式指定编码二是写回时如果文件正被 Excel 打开会报PermissionError。常见做法是写之前先检测文件是否可写或者写到临时文件再替换。另外成绩列建议用数值类型而不是字符串否则后续在 Excel 里求和会得到 0。如果只是自己用导出 CSV 比 xls 省事得多但既然资源里给了data.xls说明有对接 Excel 的需求保持格式一致更稳妥。4.3 turntogui.py 把命令行流程包成界面turntogui.py是把前面这套流程包一层图形界面方便不熟悉命令行的使用者。常见做法是用 Tkinter因为它标准库自带、不用额外装。GUI 部分本身不难难的是把耗时操作放到独立线程否则点一下「开始识别」界面就卡死用户以为程序崩了。如果你要改这个界面记得把识别主流程放进threading.Thread主线程只负责刷新进度。另外 GUI 里选文件、选目录的路径要处理中文和空格Windows 下路径含空格时subprocess调用会截断用列表传参而不是字符串拼接。5. 避坑与排查这套资源跑不起来时先看这几条5.1 现象findContours 报「too many values to unpack」原因代码是按 OpenCV 3 写的cv2.findContours返回三个值而 OpenCV 4 只返回两个。解决把_, contours, _ cv2.findContours(...)改成contours, _ cv2.findContours(...)或者全局搜一遍所有findContours调用统一改掉。这是从旧教程迁移代码时最常踩的坑。5.2 现象透视变换后图像全黑或全白原因order_points返回的角点顺序错了导致变换矩阵把图像映射到了画面外。解决打印四个角点坐标确认左上角 x、y 都最小右下角都最大。如果角点本身来自approxPolyDP且顺序随机先做排序再传入four_point_transform。另外检查warped的尺寸是否算成了 0 或负数对边长度算错时会出现这种情况。5.3 现象所有选项都被判定为「已涂」或「未涂」原因fill_threshold与实际图像不匹配或者adaptiveThreshold的 blockSize 和 C 值不对。解决先把二值化后的thresh图保存下来肉眼看正常应该是选项框轮廓清晰、框内涂痕为白色。如果整张图全白调大 C 值如果全黑调小 C 值或增大 blockSize。然后再调fill_threshold用一张已知答案的卡做标定。5.4 现象ModuleNotFoundError 或 ImportError原因虚拟环境没激活、装错了包名、或者 VSCode 解释器指向全局 Python。解决在终端确认which pythonWindows 用where python指向 venv在 VSCode 里CtrlShiftP选 Python 解释器指向同一个路径。opencv的包名是opencv-python不是cv2pip install cv2会失败。5.5 现象data.xls 被覆盖或读出来是乱码原因脚本写回时直接覆盖原文件或者用错了读写库。解决跑之前备份data.xls读写分别用xlrd和xlwt注意xlwt不支持写 xlsx。中文乱码时在xlwt.Workbook的add_sheet后设置编码或者改用openpyxl并显式指定encodingutf-8。6. 进阶技巧用 bad.jpg 做鲁棒性回归与参数标定这套资源里最有价值的其实不是那些标准样本而是bad.jpg、bad2.jpg这种「脏数据」。标准样本只能证明流程能跑通脏样本才能暴露参数边界。我一般会建一个小型回归集把sheet1.jpg、sheet2.jpg、new1.jpg、new2.jpg、bad.jpg、bad2.jpg、bupt.jpg、school.jpg各跑一遍记录每张图的定位是否成功、透视是否正确、涂卡判定是否与人工核对一致。然后针对失败的样本单独调参而不是一上来就改算法。具体做法是写一个批量测试脚本把关键中间产物落盘方便肉眼比对import os import cv2 SAMPLES [sheet1.jpg, sheet2.jpg, new1.jpg, new2.jpg, bad.jpg, bad2.jpg, bupt.jpg, school.jpg] OUT_DIR debug_out os.makedirs(OUT_DIR, exist_okTrue) for name in SAMPLES: if not os.path.exists(name): print(f跳过不存在的样本: {name}) continue img, cnt find_answer_sheet(name) if cnt is None: print(f{name}: 未定位到答题卡检查 Canny 阈值) continue warped four_point_transform(img, cnt) cv2.imwrite(os.path.join(OUT_DIR, fwarped_{name}), warped) bubbles detect_bubbles(warped) print(f{name}: 定位成功检测到 {len(bubbles)} 个选项框)逻辑说明这段脚本不追求一次跑对而是把每张图的warped结果存下来人工看哪张摆正了、哪张歪了。bad.jpg如果定位失败先看 Canny 的75, 200是不是太低引入了桌面纹理试着提到100, 250如果bupt.jpg检测到的选项框数量明显偏少多半是选项框尺寸超出了1560的区间按实际像素调。参数标定的顺序建议是先固定 Canny 阈值让所有样本都能定位再固定approxPolyDP精度让角点稳定最后才调fill_threshold做涂卡判定。顺序反了会互相干扰调一个坏一个。还有一个容易被忽略的点不同样本的分辨率差异。find_answer_sheet里统一缩放到宽度 800这个值不是随便定的。太小会丢选项框细节太大则 Canny 和形态学操作变慢。如果你的样本普遍是 3000 像素宽的手机照800 可能偏小可以提到 10001200但记得同步调整选项框的面积区间因为缩放后框的像素尺寸也变了。我习惯在脚本开头把缩放宽度、Canny 阈值、面积区间、填充阈值集中定义成常量改的时候一处改、全局生效比散落在各个函数里靠谱得多。从那以后我每次拿到新的答题卡版式都强制先跑一遍这个回归集把中间图存下来肉眼过一遍确认没有玄学失败再上批量。这套流程不复杂但能省掉大量「为什么这张行那张不行」的排查时间。希望帮到你。本文还有配套的精品资源点击获取