
1. 项目背景与目标在教育信息化场景中答题卡自动识别是一项非常实用的技术。传统的人工阅卷不仅耗时而且容易出错。借助 OpenCV 的图像处理能力我们可以实现答题卡填涂区域的自动检测、答案比对和评分大幅提升阅卷效率。本文将以一个完整的 Python 案例为主线详细讲解如何利用 OpenCV 实现答题卡识别判卷。案例的核心流程包括图像预处理、轮廓检测、透视变换、填涂区域定位、答案比对与评分。通过本案例你将掌握 OpenCV 在文档扫描与自动判卷中的典型应用方法。2. 核心思路与整体流程答题卡识别判卷的整体流程可以概括为以下几个关键步骤图像预处理将彩色图像转换为灰度图进行高斯模糊去噪再通过 Canny 边缘检测提取轮廓信息。轮廓检测与筛选查找图像中的外部轮廓按面积排序找到近似为四边形的轮廓作为答题卡区域。透视变换对检测到的答题卡区域进行透视校正得到正视角的标准化图像。填涂区域定位对校正后的图像进行二值化处理查找圆形填涂轮廓并按行列排序。答案比对与评分通过掩膜统计每个填涂区域的非零像素数量判断填涂状态与正确答案比对后计算得分。下面将结合代码逐段讲解每个步骤的实现细节。3. 辅助函数详解在正式处理图像之前代码定义了几个辅助函数用于坐标排序、透视变换、轮廓排序和图像显示。理解这些函数是掌握整个案例的关键。3.1 坐标点排序函数 order_points透视变换要求输入四个点且顺序必须为左上、右上、右下、左下。由于轮廓检测返回的点顺序是不确定的因此需要先对四个点进行排序。def order_points(pts): # 一共4个坐标点 rect np.zeros((4, 2), dtypefloat32) # 用来存储排序之后的坐标位置 # 按顺序找到对应坐标0123分别是 左上右上右下左下 s pts.sum(axis1) # 对pts矩阵的每一行进行求和操作。(xy) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) # 对pts矩阵的每一行进行求差操作。(y-x) rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect排序原理如下左上角的点xy最小右下角的点xy最大因此通过求和可以确定左上和右下。右上角的点y-x最小左下角的点y-x最大因此通过求差可以确定右上和左下。这种基于几何特性的排序方法简洁高效是透视变换前的重要准备工作。3.2 透视变换函数 four_point_transform透视变换的目的是将倾斜拍摄的答题卡校正为正视角图像便于后续处理。def four_point_transform(image, pts): # 获取输入坐标点 rect order_points(pts) (tl, tr, br, bl) rect # 计算输入的w和h值 widthA np.sqrt(((br[0] - bl[0]) ** 2) ((br[1] - bl[1]) ** 2)) widthB np.sqrt(((tr[0] - tl[0]) ** 2) ((tr[1] - tl[1]) ** 2)) maxWidth max(int(widthA), int(widthB)) heightA np.sqrt(((tr[0] - br[0]) ** 2) ((tr[1] - br[1]) ** 2)) heightB np.sqrt(((tl[0] - bl[0]) ** 2) ((tl[1] - bl[1]) ** 2)) 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函数首先计算原始四边形四条边的欧氏距离取最大值作为变换后图像的宽和高确保校正后的图像不会丢失内容。然后构造目标矩形坐标通过cv2.getPerspectiveTransform计算变换矩阵最后用cv2.warpPerspective完成图像校正。3.3 轮廓排序函数 sort_contours检测到的填涂轮廓顺序是杂乱的需要按行或按列排序才能与题目序号对应。def sort_contours(cnts, methodleft-to-right): reverse False i 0 if method right-to-left or method bottom-to-top: reverse True if method top-to-bottom or method bottom-to-top: i 1 boundingBoxes [cv2.boundingRect(c) for c in cnts] (cnts, boundingBoxes) zip(*sorted(zip(cnts, boundingBoxes), keylambda b: b[1][i], reversereverse)) return cnts, boundingBoxes函数通过cv2.boundingRect获取每个轮廓的外接矩形然后根据指定的排序方法如top-to-bottom对轮廓进行排序。排序键为外接矩形的y坐标按行或x坐标按列通过reverse参数控制升序或降序。3.4 图像显示函数 cv_showdef cv_show(name, img): cv2.imshow(name, img) cv2.waitKey(0)该函数用于在调试过程中显示中间结果图像。cv2.waitKey(0)会等待用户按键后继续执行方便逐步观察每个处理阶段的效果。4. 图像预处理与答题卡外四边形轮廓定位模块目标从原始拍摄图像中提取答题卡的外边界四边形为后续透视矫正提供四个角点。核心思路先通过灰度化、高斯模糊、Canny 边缘检测得到干净的边缘图再查找最外层轮廓按面积降序筛选出近似四边形的轮廓作为答题卡区域。关键约束使用cv2.RETR_EXTERNAL只取最外层轮廓避免内部填涂圆圈干扰多边形逼近的顶点数必须恰好为 4。# 预处理 image cv2.imread(r./images/test_01.png) contours_img image.copy() gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, ksize(5, 5), sigmaX0) edged cv2.Canny(blurred, 75, 200) 轮廓检测 cnts cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2] cnts sorted(cnts, keycv2.contourArea, reverseTrue) docCnt None for c in cnts: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: docCnt approx break灰度化减少计算量并保留边缘信息高斯模糊抑制噪声避免 Canny 产生过多虚假边缘Canny 低阈值 75、高阈值 200 提取强边缘。按面积降序遍历轮廓用cv2.approxPolyDP做多边形逼近当顶点数为 4 时即认定为答题卡外边界。5. 四点排序工具函数与透视变换图像矫正模块目标将倾斜拍摄的答题卡校正为正视角图像并保证四个角点顺序为左上、右上、右下、左下。核心思路利用几何特性对四个角点排序——左上角xy最小、右下角xy最大、右上角y-x最小、左下角y-x最大再计算四边形边长最大值作为目标尺寸构造透视变换矩阵完成矫正。关键约束轮廓检测返回的角点顺序不确定必须先排序再变换目标宽高取四条边欧氏距离的最大值避免校正后内容丢失。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 def four_point_transform(image, pts): rect order_points(pts) (tl, tr, br, bl) rect widthA np.sqrt(((br[0] - bl[0]) ** 2) ((br[1] - bl[1]) ** 2)) widthB np.sqrt(((tr[0] - tl[0]) ** 2) ((tr[1] - tl[1]) ** 2)) maxWidth max(int(widthA), int(widthB)) heightA np.sqrt(((tr[0] - br[0]) ** 2) ((tr[1] - br[1]) ** 2)) heightB np.sqrt(((tl[0] - bl[0]) ** 2) ((tl[1] - bl[1]) ** 2)) 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 执行透视变换 warped_t four_point_transform(image, docCnt.reshape(4, 2)) warped_new warped_t.copy() warped cv2.cvtColor(warped_t, cv2.COLOR_BGR2GRAY)order_points通过求和与求差确定四个角点的几何位置four_point_transform计算原始四边形四条边的欧氏距离取最大值作为变换后图像的宽和高再通过cv2.getPerspectiveTransform计算变换矩阵最后用cv2.warpPerspective完成矫正。矫正后的彩色图warped_t用于后续绘制灰度图warped用于二值化。6. 矫正图二值化与筛选选项圆形候选轮廓模块目标从矫正后的灰度图中提取所有填涂圆圈的候选轮廓并按尺寸和形状过滤。核心思路使用 Otsu 自适应阈值配合THRESH_BINARY_INV反向二值化使填涂区域变为白色前景再次查找轮廓后通过宽高比和最小尺寸过滤出近似圆形的填涂区域。关键约束宽高均不小于 20 像素宽高比在 0.9 到 1.1 之间确保筛选出的是近似圆形的填涂区域。# 阈值处理 thresh cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] # 找到每一个圆圈轮廓 cnts cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2] 筛选圆形候选轮廓 questionCnts [] for c in cnts: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) if w 20 and h 20 and 0.9 ar 1.1: questionCnts.append(c)为什么使用THRESH_BINARY_INV反向二值化原始答题卡中填涂的圆圈是深色铅笔或墨水背景是浅色。默认的THRESH_BINARY会把浅色背景置为 255白色、深色填涂置为 0黑色。而后续的掩膜统计依赖cv2.countNonZero统计白色像素数量因此需要反向二值化把填涂区域变成白色前景、背景变成黑色这样填涂区域的非零像素数才会显著高于未填涂区域。过滤条件确保候选轮廓是近似圆形宽高比接近 1 排除细长噪声最小尺寸 20 像素排除小噪点。筛选结果还需按从上到下排序使轮廓顺序与题目行号对应。7. 轮廓排序工具函数实现模块目标将检测到的填涂轮廓按行或按列排序使轮廓顺序与题目序号对应。核心思路通过cv2.boundingRect获取每个轮廓的外接矩形根据排序方法选择x或y坐标作为排序键用reverse参数控制升序或降序。关键约束排序键必须与题目布局一致——按行排列的题目用top-to-bottomy坐标按列排列的题目用left-to-rightx坐标。def sort_contours(cnts, methodleft-to-right): reverse False i 0 if method right-to-left or method bottom-to-top: reverse True if method top-to-bottom or method bottom-to-top: i 1 boundingBoxes [cv2.boundingRect(c) for c in cnts] (cnts, boundingBoxes) zip(*sorted(zip(cnts, boundingBoxes), keylambda b: b[1][i], reversereverse)) return cnts, boundingBoxes 按行排序使轮廓顺序与题目行号对应 questionCnts sort_contours(questionCnts, methodtop-to-bottom)[0]该函数返回排序后的轮廓列表及其外接矩形。主流程中先按top-to-bottom排序得到每行题目的轮廓再在后续评分逻辑中按left-to-right对每行内部的 5 个选项排序。8. 掩码填涂检测、标准答案比对与计分判分模块目标逐行识别考生填涂的答案与标准答案比对并计算得分。核心思路为每个选项轮廓创建掩膜通过cv2.bitwise_and提取掩膜区域内的二值化像素用cv2.countNonZero统计非零像素数量像素数最大的选项即为填涂答案再与ANSWER_KEY比对答对绿色标记、答错红色标记。关键约束每行固定 5 个选项按np.arange(0, len(questionCnts), 5)切分掩膜必须与二值化图像尺寸一致。correct 0 # 每排有5个选项 for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)): cnts sort_contours(questionCnts[i:i 5])[0] # 行内按左到右排序 bubbled None for (j, c) in enumerate(cnts): # 为每个选项创建掩膜并填充 mask np.zeros(thresh.shape, dtypeuint8) cv2.drawContours(mask, [c], -1, 255, -1) # 只保留掩膜区域内的像素 thresh_mask_and cv2.bitwise_and(thresh, thresh, maskmask) total cv2.countNonZero(thresh_mask_and) if bubbled is None or total bubbled[0]: bubbled (total, j) # 与标准答案比对 color (0, 0, 255) k ANSWER_KEY[q] if k bubbled[1]: color (0, 255, 0) correct 1 cv2.drawContours(warped_new, [cnts[k]], -1, color, 3) score (correct / 5.0) * 100 print([INFO] score: {:.2f}%.format(score)) cv2.putText(warped_new, {:.2f}%.format(score), (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2) cv2.imshow(Original, image) cv2.imshow(Exam, warped_new) cv2.waitKey(0)为什么选用countNonZero统计填涂区域反向二值化后填涂区域为白色255、未填涂区域为黑色0。cv2.countNonZero统计掩膜区域内灰度值不为 0 的像素数量填涂选项的白色像素数远多于未填涂选项因此像素数最大的选项即为考生填涂的答案。这种方法无需依赖颜色阈值对铅笔、钢笔等不同填涂介质均适用。当前 Demo 的缺陷多选与空题场景当前逻辑存在两个明显缺陷多选场景逻辑只取像素数最大的选项作为唯一答案bubbled (total, j)只保留最大值无法识别多选题。若考生填涂多个选项程序只会把像素数最大的那个当作答案其余填涂选项被忽略导致判分错误。空题场景若某题未填涂任何选项所有选项的total都接近 0但bubbled仍会取像素数相对最大的选项即使该值极小程序会误判为填涂了该选项导致空题被错误判分。改进方向可设置非零像素阈值如total 某阈值才视为填涂并统计所有超过阈值的选项索引以支持多选。9. 结果展示与评分输出最后将评分结果绘制在图像上并显示。score (correct / 5.0) * 100 print([INFO] score: {:.2f}%.format(score)) cv2.putText(warped_new, {:.2f}%.format(score), (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2) cv2.imshow(Original, image) cv2.imshow(Exam, warped_new) cv2.waitKey(0)得分计算公式为正确题数 / 总题数 × 100。最终结果通过cv2.putText绘制在图像左上角并同时显示原始图像和识别结果图像方便直观对比。结果展示10. 完整代码汇总为了方便读者运行和调试这里给出完整的代码import numpy as np import cv2 ANSWER_KEY {0: 1, 1: 4, 2: 0, 3: 3, 4: 1} # 正确答案 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 def four_point_transform(image, pts): rect order_points(pts) (tl, tr, br, bl) rect widthA np.sqrt(((br[0] - bl[0]) ** 2) ((br[1] - bl[1]) ** 2)) widthB np.sqrt(((tr[0] - tl[0]) ** 2) ((tr[1] - tl[1]) ** 2)) maxWidth max(int(widthA), int(widthB)) heightA np.sqrt(((tr[0] - br[0]) ** 2) ((tr[1] - br[1]) ** 2)) heightB np.sqrt(((tl[0] - bl[0]) ** 2) ((tl[1] - bl[1]) ** 2)) 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 sort_contours(cnts, methodleft-to-right): reverse False i 0 if method right-to-left or method bottom-to-top: reverse True if method top-to-bottom or method bottom-to-top: i 1 boundingBoxes [cv2.boundingRect(c) for c in cnts] (cnts, boundingBoxes) zip(*sorted(zip(cnts, boundingBoxes), keylambda b: b[1][i], reversereverse)) return cnts, boundingBoxes def cv_show(name, img): cv2.imshow(name, img) cv2.waitKey(0) 预处理 image cv2.imread(r./images/test_01.png) contours_img image.copy() gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, ksize(5, 5), sigmaX0) cv_show(blurred, blurred) edged cv2.Canny(blurred, 75, 200) cv_show(edged, edged) 轮廓检测 cnts cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2] cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3) cv_show(contours_img, contours_img) docCnt None cnts sorted(cnts, keycv2.contourArea, reverseTrue) for c in cnts: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: docCnt approx break 执行透视变换 warped_t four_point_transform(image, docCnt.reshape(4, 2)) warped_new warped_t.copy() cv_show(warped, warped_t) warped cv2.cvtColor(warped_t, cv2.COLOR_BGR2GRAY) 阈值处理 thresh cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] cv_show(thresh, thresh) thresh_Contours thresh.copy() cnts cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2] warped_Contours cv2.drawContours(warped_t, cnts, -1, (0, 255, 0), 1) cv_show(warped_Contours, warped_Contours) questionCnts [] for c in cnts: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) if w 20 and h 20 and 0.9 ar 1.1: questionCnts.append(c) print(len(questionCnts)) questionCnts sort_contours(questionCnts, methodtop-to-bottom)[0] correct 0 for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)): cnts sort_contours(questionCnts[i:i 5])[0] bubbled None for (j, c) in enumerate(cnts): mask np.zeros(thresh.shape, dtypeuint8) cv2.drawContours(mask, [c], -1, 255, -1) cv_show(mask, mask) thresh_mask_and cv2.bitwise_and(thresh, thresh, maskmask) cv_show(thresh_mask_and, thresh_mask_and) total cv2.countNonZero(thresh_mask_and) if bubbled is None or total bubbled[0]: bubbled (total, j) color (0, 0, 255) k ANSWER_KEY[q] if k bubbled[1]: color (0, 255, 0) correct 1 cv2.drawContours(warped_new, [cnts[k]], -1, color, 3) cv_show(warpeding, warped_new) score (correct / 5.0) * 100 print([INFO] score: {:.2f}%.format(score)) cv2.putText(warped_new, {:.2f}%.format(score), (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2) cv2.imshow(Original, image) cv2.imshow(Exam, warped_new) cv2.waitKey(0)11. 运行效果与结果分析运行上述代码后程序会依次弹出多个窗口展示每个处理阶段的中间结果。最终输出识别得分并在图像上标注每道题的判定结果。从实际运行效果来看该方案能够准确识别填涂的答案并正确计算得分。绿色轮廓表示答对的题目红色轮廓表示答错的题目左上角显示最终得分百分比。需要注意的是识别效果受图像质量影响较大。如果拍摄角度过于倾斜、光线不均匀或填涂不完整可能导致识别错误。此时可以适当调整 Canny 阈值、轮廓过滤尺寸或透视变换参数来优化效果。12. 总结与扩展思考本文通过一个完整的 OpenCV 案例详细讲解了答题卡识别判卷的实现流程。核心知识点包括图像预处理灰度化、高斯模糊、Canny 边缘检测的组合应用。轮廓检测与筛选通过面积排序和多边形逼近定位目标区域。透视变换校正倾斜图像得到标准化视角。掩膜统计利用掩膜和像素统计判断填涂状态。在此基础上还可以进一步扩展支持多页答题卡批量识别提高处理效率。引入 OCR 技术识别考生信息和准考证号。优化填涂检测算法支持铅笔和钢笔等多种填涂方式。结合深度学习模型提升复杂场景下的鲁棒性。希望本文能帮助你理解 OpenCV 在文档识别领域的应用思路并激发更多实践灵感。