OpenCV答题卡识别判卷:从轮廓检测到透视变换的完整实现
简介这是一套基于OpenCV的答题卡识别与判卷Python工程面向计算机视觉初学者、教育信息化开发人员以及需要搭建自动化评分系统的研究机构。项目聚焦标准化选择题答题卡的图像处理全流程涵盖图像预处理、轮廓检测、选项区域定位、答案识别与得分统计等关键环节。压缩包共7个文件包含1个Python主程序和6张PNG示例答题卡图像整体体积约3.08MB结构精简便于直接运行与二次开发。已有341人学习下载。通过阅读并运行代码可以掌握二值化、轮廓提取、形态学操作和像素分析等OpenCV核心技巧了解一套可落地的判卷方案实现思路为后续扩展到问卷调查、在线测试或电子学习平台中的自动评估场景打下基础。 一张答题卡丢进扫描仪几秒钟后一组分数出现在屏幕上这个场景但凡经历过考试季的人都不陌生。但真要自己写一套自动判卷程序不少人会被“图像识别”四个字劝退。其实用 OpenCV 做答题卡识别判卷核心并没有想象中那么玄。关键在于把整件事拆成几个明确的步骤从照片里找到答题卡、把歪的图片摆正、按行列切出每个选项区域、判断每个格子有没有被涂黑、最后和标准答案比对出分。这篇文章我就用 Python OpenCV 把这一整套流程完整走一遍代码可以直接复制运行。它适合想入门 OpenCV 图像处理的学生、需要批量改答题卡的老师以及正在做考试类系统的开发者。看完你不仅能跑通识别判卷也会对轮廓检测、透视变换、图像二值化这些 OpenCV 基础操作有一个非常直观的理解。1. 识别流程总览一张图片变成一份成绩单1.1 这个项目到底做了什么先说清楚这套系统的能力边界。它针对的是纸质答题卡拍照或扫描后的静态图片识别出每道题填涂了哪个选项然后和标准答案比对输出每道题的对错和总分。我设计的标准答题卡版式是这样的整个卡片是一个规整的矩形区域题目按行排列每道题有 A/B/C/D 四个选项横向排开所有选项的位置整齐均匀。这种版式在学校自制的答题卡、模拟测试卡里非常常见也是最适合用 OpenCV 几何方法处理的版式。如果是那种选项竖排、题号分栏、或者有复杂定位标记的答题卡原理一样但网格切分的参数需要调整这个到后面我会细说。1.2 处理管线的四个阶段整个识别流程可以清晰地拆成四个阶段这也是所有答题卡识别系统的通用骨架阶段核心任务用到的关键技术图像预处理把彩色图变成适合分析的灰度图去除噪点cvtColor、GaussianBlur、Canny答题卡定位从复杂背景中找出答题卡的四条边findContours、approxPolyDP透视矫正把拍歪的答题卡转成正视角的矩形图getPerspectiveTransform、warpPerspective答案提取与判分切分单元格、判断涂黑状态、比对答案网格划分、threshold 像素统计这里最关键的设计决策是先用几何变换把答题卡“摆正”再做答案提取。看似多了一步却能让后续处理变得极其简单和稳定。如果直接在倾斜的原图上按坐标切格子边缘对齐问题会让人崩溃。2. 第一步预处理和答题卡区域定位2.1 从彩色图到边缘图的每一步都有目的预处理代码看起来平平无奇但每一个操作都是在为下一步“找答题卡”扫清障碍def preprocess(self, image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edged cv2.Canny(blurred, 50, 150) kernel np.ones((3, 3), np.uint8) edged cv2.dilate(edged, kernel, iterations1) return gray, edged灰度化是必须的Canny 边缘检测只接受单通道图。高斯模糊的作用是压低纸张纹理和拍照噪点不然边缘图会像一团乱麻。Canny 的两个阈值 50 和 150 是我实测下来对普通室内光线下拍摄的纸面比较稳的组合低于 50 的弱边缘不会进入结果高于 150 的强边缘一定保留。最后的膨胀操作容易被忽略它特别重要。答题卡的边框在照片里可能因为光线或阴影断成几截膨胀可以把断开的边缘“接上”让后续轮廓检测能找到一个完整的四边形。2.2 用轮廓面积筛选找到真正的答题卡找答题卡区域的思路很简单粗暴整张照片里面积最大的四边形轮廓大概率就是答题卡本身。def find_sheet_contour(self, edged): contours, _ cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue) for cnt in contours[:5]: peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) 4: return approx return NonefindContours的返回值要注意OpenCV 4.x 版本返回两个值contours, hierarchyOpenCV 2.x 返回三个值如果你用的是旧版本需要对应调整。RETR_EXTERNAL只取最外层轮廓可以避免把答题卡内部印刷的题目框、选项框也当成候选。approxPolyDP是轮廓逼近0.02 * peri这个系数代表逼近精度系数越大得到的顶点越少。如果系数太大可能把圆形误判成三角形太小又保留太多顶点凑不出四边形。0.02 是一个在大多数场景下表现良好的经验值。这里我按轮廓面积从大到小排序遍历前 5 个轮廓找到第一个面积足够大且是四边形的轮廓返回。这个“从大到小找四边形”的策略比直接全量遍历更高效也天然排除了背景里的桌子、书本、手指等干扰物。2.3 四个角点的排序排列错整张图就废了拿到四边形的四个顶点后不能直接把原始坐标丢给透视变换。原图中轮廓顶点的顺序是随机的但getPerspectiveTransform要求输入的点按“左上 → 右上 → 右下 → 左下”的顺序排列顺序错了透视结果会翻转或扭曲。def order_points(self, pts): pts pts.reshape(4, 2) 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这个排序技巧很经典左上角的 xy 最小右下角的 xy 最大右上角的 x-y 最小左下角的 x-y 最大。我最初写代码时直接拿轮廓坐标去做透视变换结果出来的图左右颠倒花了不少时间才定位到这个细节。排序完后用两点间距离公式计算目标矩形的宽和高映射到原图输出。def four_point_transform(self, image, pts): rect self.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] ], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped透视变换的核心就是让照片里那个斜着的四边形映射到一个正矩形上。这是整个系统里最提效的一步——摆正之后答题区域的坐标就变成了在一个规整矩形里的行列坐标切割和统计都变得干净利落。3. 第二步答案提取判断一个格子有没有被涂黑3.1 均匀网格切分的前提条件答题卡被摆正后提取答案的技术路线就非常直白了按照题目数量和每题的选项数把答题区域均匀切分成网格逐格统计“涂黑比例”。def extract_answers(self, warped_gray): h, w warped_gray.shape pad_x int(w * 0.05) pad_y int(h * 0.1) cell_h (h - 2 * pad_y) // self.num_questions cell_w (w - 2 * pad_x) // self.num_options answers [] for q in range(self.num_questions): row_answers [] y_start pad_y q * cell_h for opt in range(self.num_options): x_start pad_x opt * cell_w cell warped_gray[y_start:y_start cell_h, x_start:x_start cell_w] _, thresh cv2.threshold(cell, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) thresh cv2.erode(thresh, np.ones((2, 2), np.uint8), iterations1) black_ratio cv2.countNonZero(thresh) / (cell_h * cell_w) row_answers.append(black_ratio) answers.append(row_answers) return answers这里有一个非常重要的隐含前提这套切分方案假设答题卡版式是均匀的也就是每道题占据的行高一致、每个选项占据的列宽一致。对于印刷规整的答题卡这个假设成立如果你的卡片版式复杂就需要在摆正后手动指定每个区域的坐标范围这个后面讲。pad_x和pad_y是为了避开答题卡的外边框线。边框线是连续的黑色像素如果切分时把边框包含进来每个格子都会带一条黑边严重干扰涂黑比例的统计。5% 和 10% 的边距是我根据实际答题卡版式调整的值。3.2 为什么用 Otsu 而不是固定阈值阈值的选择是答案提取成败的关键。答题卡填涂用的是铅笔铅笔灰度深浅受按压力度、笔芯软硬、扫描光线影响极大。固定阈值比如像素值小于 128 就算黑色在这种场景下非常脆弱——同一张卡上A 题涂得重B 题涂得轻一个阈值很难兼顾。Otsu 算法解决的就是这个问题。它遍历所有可能的阈值找到让前景和背景两类像素的类间方差最大的那个值相当于针对每个格子自动算出一个“最适合”的阈值。_, thresh cv2.threshold(cell, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)注意这里用了THRESH_BINARY_INV而不是THRESH_BINARY。因为我们要统计的是“涂黑的区域”而灰度图里涂黑的铅笔印记像素值低二值化后我们希望黑色印记变成白色255这样countNonZero统计的就是涂黑区域的像素个数。THRESH_BINARY_INV正好做这个反向映射。每个格子都独立计算 Otsu 阈值还能天然抵抗光照不均匀的问题。答题卡左侧亮右侧暗或者某个角落有阴影固定阈值就废了但 Otsu 在每个格子里重新计算受影响小得多。3.3 填充比例阈值怎么定每个格子统计到的black_ratio是一个 0 到 1 之间的浮点数。真正的填涂一般会让格子内 40% 以上的像素变黑而没涂的格子通常只有印刷边框造成的少量噪点比例在 5% 以下。所以我取 0.35 作为判定填涂的阈值chosen int(np.argmax(row)) if max(row) 0.35 else -1max(row) 0.35意味着这一行里至少有一个格子被涂黑然后取涂黑比例最高的那个作为作答选项。如果一行里所有格子的比例都低于 0.35就认为这道题没有作答返回 -1。关于阈值有一个经验宁可调高一点不能调低。调高了最坏的结果是把浅涂的卡判成未作答这种可以人工复核调低了会把擦不干净的橡皮印、灰尘误判成填涂错误就是实打实的。我用模拟数据测试时0.35 能完美区分清涂和未涂不同纸张上0.3 到 0.4 之间都是安全区间。4. 第三步判卷逻辑和完整可运行代码4.1 标准答案的表示方式在判卷之前需要先把标准答案存成程序能理解的结构。我用一个列表表示列表的索引是题号从 0 开始值是该题正确选项的索引0 代表 A1 代表 B2 代表 C3 代表 D。standard_answers [0, 2, 1, 3, 0, 2, 1, 3, ...]判卷逻辑非常朴素如果识别到的作答选项索引和标准答案一致这道题得分否则不得分。空题返回 -1也不得分但会在输出里标注出来。4.2 完整判卷代码把前面的代码全部整合进一个类方便管理import cv2 import numpy as np class AnswerSheetReader: def __init__(self, num_questions20, num_options4): self.num_questions num_questions self.num_options num_options def preprocess(self, image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edged cv2.Canny(blurred, 50, 150) kernel np.ones((3, 3), np.uint8) edged cv2.dilate(edged, kernel, iterations1) return gray, edged def find_sheet_contour(self, edged): contours, _ cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue) for cnt in contours[:5]: peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) 4: return approx return None def order_points(self, pts): pts pts.reshape(4, 2) 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(self, image, pts): rect self.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] ], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped def extract_answers(self, warped_gray): h, w warped_gray.shape pad_x int(w * 0.05) pad_y int(h * 0.1) cell_h (h - 2 * pad_y) // self.num_questions cell_w (w - 2 * pad_x) // self.num_options answers [] for q in range(self.num_questions): row_answers [] y_start pad_y q * cell_h for opt in range(self.num_options): x_start pad_x opt * cell_w cell warped_gray[y_start:y_start cell_h, x_start:x_start cell_w] _, thresh cv2.threshold(cell, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) thresh cv2.erode(thresh, np.ones((2, 2), np.uint8), iterations1) black_ratio cv2.countNonZero(thresh) / (cell_h * cell_w) row_answers.append(black_ratio) answers.append(row_answers) return answers def read_sheet(self, image_path, debugFalse): image cv2.imread(image_path) if image is None: raise ValueError(f无法读取图片: {image_path}) gray, edged self.preprocess(image) sheet_contour self.find_sheet_contour(edged) if sheet_contour is None: raise ValueError(未找到答题卡区域请确认图片中包含边框清晰的答题卡) warped self.four_point_transform(gray, sheet_contour) answers self.extract_answers(warped) return answers, warped def judge(self, answers, standard_answers): total len(standard_answers) score 0 details [] for i in range(total): chosen int(np.argmax(answers[i])) if max(answers[i]) 0.35 else -1 correct int(standard_answers[i]) is_correct (chosen correct) if is_correct: score 1 details.append({ question: i 1, chosen: chosen, correct: correct, confidence: round(max(answers[i]), 3), is_correct: is_correct, }) return score, total, details if __name__ __main__: reader AnswerSheetReader(num_questions20, num_options4) standard [0, 2, 1, 3, 0, 2, 1, 3, 0, 2, 1, 3, 0, 2, 1, 3, 0, 2, 1, 3] answers, warped_img reader.read_sheet(test_sheet.png, debugTrue) score, total, details reader.judge(answers, standard) print(f得分: {score} / {total}) for d in details: chosen_label chr(ord(A) d[chosen]) if d[chosen] ! -1 else 未作答 correct_label chr(ord(A) d[correct]) mark √ if d[is_correct] else x print(f第{d[question]:2d}题 | 作答: {chosen_label:3s} | 标准答案: {correct_label:3s} | 置信度: {d[confidence]:.2f} | {mark})这个类里有几个方法值得单独说明。read_sheet是流水线入口依次调用预处理、找轮廓、透视变换、提取答案judge负责判分和输出明细。我在extract_answers里对二值化后的格子做了 2x2 的腐蚀作用是把零散的小噪点去掉防止未作答的格子因为印刷杂质误判为填涂。4.3 没有实体答题卡用程序生成测试图很多读者拿到代码后跑不起来是因为手边没有答题卡照片。解决这个问题最直接的办法是先用 OpenCV 画一张模拟答题卡来做测试。这也是调试图像处理项目非常实用的一招先用代码生成可控的测试数据验证流程正确后再换真实数据。import cv2 import numpy as np def generate_demo_sheet(pathtest_sheet.png, num_questions20, num_options4): sheet_w, sheet_h 900, 1000 img np.full((sheet_h 60, sheet_w 60, 3), 200, dtypenp.uint8) cv2.rectangle(img, (30, 30), (30 sheet_w, 30 sheet_h), (255, 255, 255), -1) cv2.rectangle(img, (30, 30), (30 sheet_w, 30 sheet_h), (0, 0, 0), 3) pad_x, pad_y 60, 60 row_h (sheet_h - 2 * pad_y) // num_questions col_w (sheet_w - 2 * pad_x) // num_options np.random.seed(42) answers_to_fill np.random.randint(0, num_options, num_questions) for q in range(num_questions): y_center pad_y q * row_h row_h // 2 cv2.putText(img, f{q1:02d}, (40, y_center 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2) for opt in range(num_options): x_center pad_x opt * col_w col_w // 2 if opt answers_to_fill[q]: cv2.circle(img, (x_center, y_center), 22, (0, 0, 0), -1) else: cv2.circle(img, (x_center, y_center), 22, (0, 0, 0), 3) center (img.shape[1] // 2, img.shape[0] // 2) M cv2.getRotationMatrix2D(center, 5.0, 0.95) img cv2.warpAffine(img, M, (img.shape[1], img.shape[0])) noise np.random.normal(0, 3, img.shape).astype(np.int16) img np.clip(img.astype(np.int16) noise, 0, 255).astype(np.uint8) cv2.imwrite(path, img) print(f已生成模拟答题卡: {path}) std [int(x) for x in answers_to_fill] return std if __name__ __main__: standard_answers generate_demo_sheet(test_sheet.png) print(标准答案:, [chr(ord(A) s) for s in standard_answers])这个脚本生成的答题卡包含 20 道题、每道 4 个选项随机填涂一部分然后旋转 5 度并加模拟噪声用来验证识别程序对倾斜和噪点的抗性。程序输出的standard_answers就是后面判卷时用的标准答案这样测试数据和标准答案天然对应方便检查识别结果对不对。5. 实测效果与避坑经验5.1 我在测试中遇到的实际问题用模拟答题卡跑完整流程识别准确率基本是满分。但把代码用于真实场景时问题就会一个个冒出来这里把我踩过的坑按出现频率排个序。第一个坑是cv2.imread读不了中文路径的图片。报错还不直观图片读进来是 None但代码不会立即报错直到处理时才炸。解决办法是用cv2.imdecode读二进制流def imread_unicode(path): return cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_COLOR)第二个坑是真实答题卡边框颜色浅或者被手指挡住导致最大轮廓不是四边形。这种情况多半是预处理环节出了问题光线不足时 Canny 检测不到完整边框可以把 Canny 的低阈值从 50 降到 30 试试或者把膨胀迭代次数从 1 改成 2。第三个坑是答题卡上的定位标记。我在代码里用pad_x和pad_y避开了边缘但如果答题卡顶部或左侧有二维码、姓名栏、准考证号涂卡区光靠 uniform padding 不够需要结合像素分布动态确定答题区域的真实范围。5.2 铅笔深浅和橡皮擦拭的干扰真实判卷最闹心的是填涂识别边界情况。2B 铅笔用力涂黑色比例可能到 70% 以上识别毫无压力HB 铅笔轻轻划一下格子中心只有一片灰度不高的痕迹Otsu 算出的阈值可能把部分笔迹归为背景。我的实测建议是判卷场景尽量统一要求 2B 铅笔程序侧通过形态学闭运算把零散笔迹连成一片能显著提高浅涂的识别率。在extract_answers里可以加一行thresh cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))闭运算先膨胀后腐蚀能把铅笔痕迹内部的微小空隙填平。kernel 大小需要根据自己的答题卡格子尺寸调我在 900x1000 的测试图上用 7x7 效果不错卡片像素更大时可以适当加大。至于擦不干净的问题本质是残留石墨导致背景噪点偏高。这种情况调高填涂判定阈值治标不治本因为石墨残留的灰度分布和正常填涂差距不大。更靠谱的做法是在题目要求里强制涂错要用橡皮彻底擦干净程序侧对低于阈值但高于 0.15 的格子做个高置信度警告列表方便人工复核。5.3 不同答题卡版式的适配思路前面提到了这套网格切分方案依赖均匀布局。如果你手里的答题卡版式更复杂比如选项竖排、题目分两栏、或者有独立的题号区域直接套用会识别错位。适配的通用思路是在透视矫正之后不要直接均分而是先找到答题区域的边界参考线。可以按行/按列统计黑色像素的投影分布行投影的波峰就是每道题的分隔线列投影的波峰就是选项的分隔线。这个思路比固定网格更鲁棒代码量会增加不少但原理不难本质就是从假设均匀升级为用像素分布找真实位置。如果短期内不想重构也有一个折中的办法在extract_answers里直接传入自定义的 (x, y, w, h) 区域列表把答题卡的实际布局手工标出来。对固定版式的答题卡这个方案实施最快稳定性也最高。5.4 进阶优化方向识别率做到 95% 以上之后可以继续优化的方向有这几个一是光照不均的预处理。真实答题卡经常有阴影、反光Otsu 虽然能缓解但阴影严重的区域仍然会误判。在灰度化之后加一步 CLAHE 对比度受限自适应直方图均衡化能让阴影区域的明暗分布更均匀实测对识别率提升非常明显。clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray clahe.apply(gray)二是支持多页批量处理。把read_sheet放进一个循环对os.listdir扫描到的所有图片批量判卷然后统一导出 CSV 成绩单。结构上只需要把judge的结果聚合成一个 DataFrame实践起来很顺畅。三是摄像头实时识别。现场考试场景可以直接调起相机拍摄答题卡流程和静态图完全一样只是把cv2.imread换成cv2.VideoCapture抓帧。注意摄像头分辨率要高一些至少 1080p否则答题卡边缘细节不够Canny 检测容易失败。我在实际跑这套项目时最大的体会是识别率不是由某一个高级算法决定的而是被预处理参数、阈值设定、边距大小这些工程细节卡住的。Otsu、轮廓检测、透视变换这些手段本身都很成熟真正的差别在于你有没有针对自己那批试卷的纸张、光线、填涂习惯把参数调到合适的区间。最后再分享一个实用技巧拿到代码后不要急着用真实试卷测试先打印一两张答题卡分别用 2B 铅笔和 HB 铅笔填涂在晴天窗边、室内灯光、手机手电筒补光三种条件下各拍一张用这种小样本 多条件的测试方式跑一遍程序。这样能最快地暴露出阈值会不会过于理想化、边缘检测是否稳定、需要调哪个参数比一次拍五十张试卷再盲目调参高效得多。答题卡识别判卷这个项目技术上不复杂但对工程细节要求不低把这些细节处理好了它就是你手中一个非常可靠的小工具。本文还有配套的精品资源点击获取