Python+OpenCV实现答题卡自动识别判卷
简介面向教育测评、问卷调查及在线测试等自动化阅卷场景基于OpenCV的答题卡识别判卷完整Python代码包为开发者提供了一套可直接落地的工程方案尤其适合具备Python基础并希望深入图像处理实战的读者学习参考。压缩包共7个文件核心是1个py主程序有序串起图像预处理、答题卡定位、轮廓检测、形状匹配、选项区域识别与答案统计的完整流程另附6张png测试图覆盖不同答题卡版式可用于验证识别阈值与参数调整效果。整体大小仅3.08MB目录结构简洁便于快速定位主程序与测试样本。目前已有341人学习使用代码在关键函数与参数处配有注释读者既能直接部署到标准化考试判卷环节也能迁移至问卷统计或在线测试系统根据实际版式微调ROI坐标即可显著降低二次开发成本与排错难度。 用 Python 加 OpenCV 做答题卡识别判卷是我给工作室搭小工具时最有成就感的项目之一。它不像人脸识别、目标检测那样需要堆显存和花时间训练但恰好把 OpenCV 最常用的一整套图像流程都串起来了读图、灰度化、去噪、边缘检测、轮廓查找、透视变换、阈值分割、像素统计。做完以后打印版答题卡、手机随手拍的照片都能自动判个七七八八特别适合需要快速批改选择题、收集课堂反馈的老师或者正在学 OpenCV 想找一个完整练手项目的同学。这套代码前前后后改了好几版。最开始只能处理规规矩矩的扫描件后来加上了透视矫正手机斜着拍也能应付再后来处理过铅笔颜色太浅、光照不均匀、边缘反光等问题。今天把完整可运行的 Python 版本整理出来每一步为什么这么做、参数怎么调、坑在哪里一次讲清楚。1. 项目需求与整体设计思路1.1 答题卡识别到底在识别什么标准答题卡的结构通常很简单若干题号行每行若干选项框考生把选中的选项涂黑或者涂满。所谓“识别判卷”本质上就是两件事第一定位每一题每一选项的位置第二判断哪个选项被填涂。最后把识别出的答案和标准答案做对比统计得分。听起来不难但实际图片不会那么干净。手机拍照会倾斜打印纸在桌面上会有阴影铅笔涂出来的灰度深浅不一致扫描件还可能带黑边。这些干扰都会让一个“看起来没问题”的循环直接失效。所以这个项目的核心不是某一行代码而是整套图像处理流程能不能在不太完美的图片上稳定工作。1.2 为什么选 OpenCV 而不是深度学习有人会问现在识别填涂区域用目标检测模型不是更省事吗理论上可以但答题卡是强结构化对象每个题目和选项的相对位置基本固定用传统图像处理反而更合适。原因有三点第一OpenCV 方案不需要训练数据不需要标注几百张答题卡拿到图片直接跑。第二计算量小CPU 上几十毫秒就能处理一张部署非常方便。第三结果可解释哪一行识别错了可以直接把轮廓、透视变换结果打印出来检查调试成本低。深度学习更适合手写答题卡、复杂排版、随意摆放的纸张这类“无规则”场景。对标准模板题型来说先把轮廓找到、把透视矫正做好比任何模型都实用。1.3 整体流程一条龙我把整个识别流程拆成这样读图 - 灰度化 - 高斯模糊去噪 - Canny 边缘检测 - 找最大四边形外轮廓 - 透视矫正 - Otsu 二值化 - 查找选项轮廓 - 按行列排序 - 对每个选项 ROI 统计黑色像素占比 - 判断填涂答案 - 与标准答案比对判分。这个流程里最影响成败的是两步外轮廓定位和填涂判断。外轮廓定位不准后面所有选项坐标全部偏掉填涂判断阈值不合适要么漏判浅色铅笔要么把空选项的边框误判成填涂。后面我会逐一说明。2. 环境准备与工具选型2.1 我使用的版本组合这里的代码不需要最新版本我用的是 Python 3.9 OpenCV 4.8 NumPy。OpenCV 4.x 和 3.x 在findContours返回值上有差异代码里我已经按 4.x 的写法处理了下面会详细说。操作系统 Windows、Linux、macOS 都可以。如果只是跑脚本不需要装 PyTorch、TensorFlow 这些东西环境越干净越省心。安装依赖只需要两条命令pip install opencv-python numpy如果是在 Anaconda 环境里也可以conda install -c conda-forge opencv numpy这里只装 opencv-python 就够用不需要 opencv-contrib-python。识别答题卡用到的都是 OpenCV 基础模块。2.2 导入库与代码组织代码主要依赖cv2和numpy没有用到 imutils。很多教程喜欢用 imutils 的sort_contours和four_point_transform但我更建议自己写这两个工具函数因为它们的实现就是几个 NumPy 函数自己写反而能加深理解也方便后续改成自己的模板。我会在代码中把坐标排序、透视变换、轮廓过滤、分行、填涂检测分别写成独立函数。这样调参的时候只需要改函数里的一个常数不需要动主流程。3. 核心步骤逐个拆解3.1 边缘检测与最大四边形定位拿到彩色图片后首先要转灰度然后做高斯模糊。高斯模糊的作用是去掉扫描噪点避免 Canny 边缘检测出现大量细小边缘。核大小我一般用(5,5)如果原图很大可以适当加大到(7,7)。Canny 参数我常用75和200。这两个阈值不是固定死的如果答题卡边缘断成一段一段说明最大阈值太大改成150或120如果背景太复杂、边缘碎线太多就适当提高最小阈值到100。找答题卡外轮廓时用RETR_EXTERNAL只找外轮廓然后用approxPolyDP做多边形逼近。答题卡本身是矩形最外轮廓逼近成四边形时就应该有 4 个顶点。如果没找到 4 顶点轮廓多半是照片没拍全、背景太乱或者答题卡和背景颜色太接近。这一步有一个很实用的技巧不要把整个照片里的轮廓全部遍历先按面积排序只取面积最大的前 5 个轮廓再在里面找四边形。这样能极大减少计算量也不容易被答题卡内部的小区域干扰。3.2 透视矫正与坐标排序细节透视矫正是整个项目里最“反直觉”的地方。findContours给出来的四个顶点顺序可能是乱的可能从左上开始也可能从右下开始不处理就直接做透视变换图像会歪掉。所以要先写一个order_points函数去把四个点排序成“左上、右上、右下、左下”。我用的是经典技巧四个点的坐标分别求xy左上角一定是最小值右下角一定是最大值再求x-y右上角是最小值左下角是最大值。这个逻辑非常稳定比按角度判断更简洁。透视变换时输出图像的宽度取上下两条边中较长的一条高度取左右两条边中较长的一条防止由于边缘检测误差导致图片被压缩。目标点直接设成规则的矩形用getPerspectiveTransform求变换矩阵再用warpPerspective得到正面视图。矫正后的图像答题卡才真正变成“正面平行”状态。后面所有选项坐标、填涂判断都在矫正图上进行不再依赖原图。3.3 选项轮廓提取与按行列排序透视矫正后就对矫正图做灰度化和 Otsu 二值化。这里用THRESH_BINARY_INV而不是普通阈值是为了让填涂区域变成白色、背景变成黑色方便后续计算白色像素占比。二值化之后再用一次findContours找到所有选项轮廓。不过这时的图像里除了选项还可能包含标题文字、印刷线条所以必须做过滤。我的过滤条件是轮廓面积在 100 到 10000 之间外接矩形宽高在 15 到 120 像素之间长宽比在 0.4 到 2.5 之间。这样既能保留空选项框也能把大部分文字和小噪点排除掉。接下来要按题号和选项排序。这里不能直接按轮廓数组顺序处理因为findContours返回顺序不定。我的做法是先把所有轮廓的外接矩形按 y 坐标排序然后根据相邻中心点的 y 距离分组距离在 20 像素以内算同一道题的选项行。每组内部再按 x 坐标排序就是 A、B、C、D 的顺序。3.4 填涂判断阈值怎么选判断填涂不是简单地看灰度值因为铅笔深浅受光照影响很大。我的做法是对每个选项的小区域分别做一次 Otsu 二值化然后用cv2.erode腐蚀一次去掉空白选项边框的细线最后统计区域内白色像素占比。为什么要在局部做二值化而不是用整张图的阈值因为一束阴影可能只影响答题卡的一小块区域全局阈值会把局部阴影下的填涂区域判成黑色。局部 Otsu 能自动适应当地亮度鲁棒性高很多。填涂阈值我默认设在0.35。正常填满的选项黑色像素占比通常在 0.6 到 0.9空白选项经过腐蚀后边框残留占比一般低于 0.15。所以 0.35 是一个比较安全的中间值。如果学生用的铅笔偏浅可以降到 0.25如果经常把边框误判成填涂就提高到 0.45。4. 完整代码可直接运行4.1 调用方式把下面代码保存为answer_sheet.py准备一张答题卡图片answer_sheet.jpg然后运行python answer_sheet.py代码里的ANSWER_KEY需要改成你自己的标准答案。默认是 5 道题、每题 4 个选项。4.2 代码实现import cv2 import numpy as np # 标准答案按你的试卷修改 ANSWER_KEY [B, A, D, C, A] 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 # 宽度取两条横向边的最大值 width_a np.linalg.norm(tr - tl) width_b np.linalg.norm(br - bl) max_width max(int(round(width_a)), int(round(width_b))) # 高度取两条纵向边的最大值 height_a np.linalg.norm(br - tr) height_b np.linalg.norm(bl - tl) max_height max(int(round(height_a)), int(round(height_b))) dst np.array([ [0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1] ], dtypefloat32) matrix cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, matrix, (max_width, max_height)) def filter_option_boxes(contours): 过滤出可能的选项框轮廓 boxes [] for c in contours: area cv2.contourArea(c) if area 100 or area 10000: continue x, y, w, h cv2.boundingRect(c) if w 15 or h 15: continue if w 120 or h 120: continue aspect w / h if aspect 0.4 or aspect 2.5: continue boxes.append((x, y, w, h)) return boxes def group_rows(boxes, y_gap20): 按 y 坐标中心点把选项框分成一行一行 boxes sorted(boxes, keylambda b: b[1]) rows [] current [] for b in boxes: cy b[1] b[3] // 2 if not current: current.append(b) continue avg_y sum(box[1] box[3] // 2 for box in current) / len(current) if abs(cy - avg_y) y_gap: current.append(b) else: rows.append(current) current [b] if current: rows.append(current) return rows def detect_answers_from_image(image_path): 主流程识别答题卡返回答案列表和分数 image cv2.imread(image_path) if image is None: raise FileNotFoundError(f读取不到图像: {image_path}) orig image.copy() # 1. 灰度化 去噪 边缘检测 gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edged cv2.Canny(blurred, 75, 200) # 2. 找最大四边形外轮廓 contours, _ cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue)[:5] screen_cnt None for c in contours: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: screen_cnt approx break if screen_cnt is None: raise ValueError(没有找到答题卡外轮廓请检查图像是否把答题卡拍全) # 3. 透视矫正 warped four_point_transform(orig, screen_cnt.reshape(4, 2)) gray_warped cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 4. 二值化 找选项轮廓 _, thresh cv2.threshold( gray_warped, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU ) contours, _ cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes filter_option_boxes(contours) rows group_rows(boxes, y_gap20) # 按实际 y 中心排序每行内按 x 排序 rows.sort(keylambda row: sum(b[1] b[3] // 2 for b in row) / len(row)) option_boxes [] for row in rows: row sorted(row, keylambda b: b[0]) if len(row) 4: option_boxes.append(row[:4]) if len(option_boxes) ! len(ANSWER_KEY): print( f警告: 识别到 {len(option_boxes)} 行选项 f题目数量为 {len(ANSWER_KEY)}请检查模板或参数 ) # 5. 逐行判断填涂答案 answers [] for row_boxes in option_boxes: ratios [] for x, y, w, h in row_boxes: roi gray_warped[y:y h, x:x w] _, roi_thresh cv2.threshold( roi, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU ) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) roi_thresh cv2.erode(roi_thresh, kernel, iterations1) ratio cv2.countNonZero(roi_thresh) / (w * h) ratios.append(ratio) max_ratio max(ratios) if max_ratio 0.35: answers.append(chr(ratios.index(max_ratio) ord(A))) else: answers.append(?) # 6. 判分 print(识别答案:, answers) score 0 for idx, (detected, gold) in enumerate(zip(answers, ANSWER_KEY)): if detected gold: score 1 print(f得分: {score}/{len(ANSWER_KEY)}) return answers, score if __name__ __main__: detect_answers_from_image(answer_sheet.jpg)代码里有两个地方需要特别注意。第一filter_option_boxes里的面积和尺寸参数是我针对普通 A4 答题卡调出来的经验值。如果你用的是大字号答题卡选项外接矩形可能更大需要把max_area和w/h上限调高。第二group_rows里的y_gap20这个值取决于选项行间距。图片分辨率高、行间距大的时候可以调到 30 或 40。5. 实测效果与参数调优5.1 我跑出来的效果在我的测试集里扫描版的答题卡基本 100% 识别。手机拍照的话只要答题卡完整入镜、背景不是太乱识别率也很高。特别是加上了透视矫正之后30 度以内的倾斜都能被拉正。倾斜超过 45 度时答题卡边缘信息可能缺失外轮廓会直接找不到这种情况需要重新拍照。最容易翻车的场景是铅笔颜色很浅加上光线不均匀。我的处理办法是在透视矫正后先对灰度图做一次 CLAHE 对比度增强再二值化。代码里没写这一步因为不是所有图片都需要。如果你的图片阴影明显可以在二值化前加一行clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray_warped clahe.apply(gray_warped)5.2 关键参数调整对照表我把常用参数整理成了表遇到问题可以直接对照调整参数作用经验值问题表现调整方向Canny 阈值控制边缘检测灵敏度75, 200外轮廓断裂或碎线太多断线降低高阈值噪点增加低阈值approxPolyDP epsilon多边形逼近精度0.02 * 周长找不到 4 边形适当调小到 0.01轮廓面积下限过滤噪点轮廓100识别行数变少降低到 50y_gap选项分行容差20串行或同一行拆成多行行距大就调大填涂阈值判断选项是否填涂0.35误判或漏判误判调高漏判调低5.3 不同答题卡模板怎么办这套代码默认是“每道题 4 个选项、单选题”的模板。如果你的答题卡是 5 个选项或者多选题改动很小。选项数量代码在row[:4]这里取了前 4 个轮廓改成row[:5]即可。多选题目前逻辑是取每行最大黑像素占比的选项作为答案。多选题需要改成统计所有超过阈值的选项然后按字母顺序拼成字符串。注意多选题判断时阈值可以稍微提高避免把边框误判进去。题数变化只需要修改ANSWER_KEY的长度和内容主流程会自动匹配。如果答题卡的题号和选项不是规则矩形分布比如跳题排版那么“按 y 分组 按 x 排序”的策略可能失效。这种情况更推荐直接用固定 ROI 坐标把每个题号对应的 4 个选项坐标写死再逐块判断。代码结构不变只是把自动轮廓提取换成坐标表。6. 常见问题与避坑指南6.1findContours返回值报错OpenCV 4.x 的findContours返回两个值轮廓列表和层级关系。OpenCV 3 也是两个。但老版本 OpenCV 2 或某些国产教程里会返回三个值代码直接写cnts, _ cv2.findContours(...)在新版本上没问题。如果报错说“not enough values to unpack”先确认cv2.__version__再决定怎么改。6.2 找不到答题卡外轮廓这是最常见的问题。处理办法按优先级排序确认答题卡完整拍进画面垫一张深色纸再拍加大答题卡和背景的对比度如果拍摄环境光线很差先补光如果答题卡边缘被阴影挡住可以在 Canny 之前把高斯模糊核加大到(7,7)。我踩过最大的坑是答题卡白色边框太窄和白色桌面融为一体。后来在桌面上垫一张深色卡纸外轮廓检测一下子就稳定了。这个习惯一直用到现在。6.3 识别出来的答案都是 ?答案全是问号通常是填涂检测阈值太高。学生用 HB 铅笔浅涂的时候黑色像素占比可能只有 0.3 左右默认阈值 0.35 就会漏判。可以把max_ratio 0.35改成0.25或者提醒学生尽量用 2B 铅笔。另外还有一种情况是空选项的边框被误判成填涂。这时需要把腐蚀的迭代次数从 1 增加到 2或者把填涂阈值提高到 0.45。边框线条很细腐蚀两次基本就没了。6.4 某一行答案总是错位或者串行串行问题基本出在group_rows上。选项行的 y 中心点差距可能比预想的大导致本该是同一行的 4 个选项被拆成了两行。解决办法是把y_gap调大比如从 20 改到 35。反过来如果两行间距很近导致并成一行就把y_gap调小。如果是固定某一行识别错乱可以先把warped和thresh输出成图片叠加轮廓画框看看。这一步能帮你快速判断是透视矫正问题、轮廓过滤问题还是分行问题。实际开发中把中间结果可视化出来比盯着代码猜原因快得多。本文还有配套的精品资源点击获取