简介:一套基于Python与OpenCV的答题卡识别判卷源码及配套资料,面向需要批量处理标准化答题卡的教育机构、数据分析人员,以及希望通过项目实战掌握图像识别算法的开发者与初学者。项目完整演示了从图像采集、预处理到特征提取与评分的流程,涵盖去噪、透视变换、边缘检测、填涂区域定位和答案判定等关键环节,源码结构清晰、逻辑严谨,并配有详细注释,便于读者逐行理解实现原理。包内共7个文件,包含1个Python主程序和6张测试答题卡图片,压缩包总大小3.08MB,可直接运行或二次修改,用于验证透视校正、阈值分割等步骤的实际效果。已有314人学习下载,无论是用于教学演示还是考试批量阅卷场景,都能从中获得可落地的工程参考和扩展思路。
1. 答题卡识别判卷:OpenCV 项目实战,源码直接跑
处理过 5040 张答题卡之后,你大概率会跟我一样把人工录入的念头彻底掐掉。这套基于 Python + OpenCV 的答题卡识别判卷源码,核心就一件事:把一张拍歪了、有阴影、填涂不均匀的答题卡照片,通过预处理、透视矫正、轮廓检测和像素统计,最终输出每题的作答结果并换算得分。它不依赖 OCR、不依赖深度学习,纯图像处理就能完成判卷,意味着普通笔记本也能跑,不需要 GPU。适合正在学 OpenCV 的初学者动手拆解,也适合需要批量处理标准化答题卡的开发者直接改来用。
源码包里除了完整的 get_answer.py 脚本,还包括 6 张测试图——test_01 到 test_05 加 example_test.png,每张图的光照和角度都不一样,相当于自带了一套测试集,你能清楚看到每一步处理对最终识别结果的影响。接下来我按实际跑通的流程,把每个环节的参数和处理方法拆开讲。
2. 图像预处理管线:灰度、去噪、边缘检测的先后顺序不能乱
2.1 为什么先灰度化再高斯模糊,顺序换一下效果差异明显
拿到一张答题卡照片,第一步不是急着找答案区域,而是把图像降到对后续处理最友好的状态。读图用cv2.imread,这一步有个很多人忽略的细节:OpenCV 默认读进来的是 BGR 三通道彩色图,如果你的判卷逻辑只关心填涂区域的像素值,直接用彩色图做阈值分割会因为颜色信息干扰而翻车。常见做法是先cv2.cvtColor转灰度,把三维数据压成一维,后续所有阈值操作都在单通道上进行。
灰度化之后是高斯模糊,这一步的目的是压制传感器噪声和纸张纹理。cv2.GaussianBlur的核大小 (kernel size) 我一般设 5x5,太大容易把填涂铅笔痕迹的边缘也抹掉,太小起不到去噪作用。这里有个血泪经验:模糊核不要用偶数,OpenCV 要求两个维度都是正奇数,传 4x4 会直接报错。从灰度到模糊的顺序不能反过来——如果你先模糊再灰度,等于对三通道各做一遍高斯滤波,计算量大了三倍,效果却没有提升。
import cv2 import numpy as np # 读图 -> 灰度 -> 高斯模糊 image = cv2.imread("images/test_01.png") gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0)这段代码里GaussianBlur第三个参数是高斯核在 X 方向的标准差,传 0 表示让 OpenCV 根据核大小自动计算。如果你发现处理后的图像噪点仍然明显,优先把核从 (5,5) 调到 (7,7),而不是去调标准差——在这个场景里核大小对结果的影响权重远大于 sigma 值。
2.2 Canny 边缘检测的两个阈值怎么给,直接决定透视矫正的成败
边缘检测是整套流程的咽喉,因为下一步要定位答题卡的四个角点,而角点检测依赖边缘图。cv2.Canny需要两个阈值:低阈值和高阈值。低于低阈值的边缘直接丢弃,高于高阈值的确定为强边缘,介于两者之间的只有在与强边缘相连时才会保留。这个滞后阈值机制意味着参数给不好,要么边缘断成一截截,要么噪声全被当成边缘。
# Canny 边缘检测 edged = cv2.Canny(blurred, 75, 200)高阈值 200、低阈值 75 是经过多张测试图验证过的配置,在 test_01 到 test_05 上都能稳定提取出答题卡的四边形轮廓。如果你自己的图片对比度偏低,可以把低阈值降到 50,高阈值降到 150,让更多弱边缘参与进来;如果发现纸张纹理被误检成边缘,就反方向调高。判断标准只有一个:边缘图上答题卡的四条边必须是连续、闭合的,否则后面的轮廓查找会找不到最外层四边形。
3. 透视矫正与 ROI 定位:四角点排序是翻车重灾区
3.1 findContours 的返回值从 OpenCV 3 到 4 变了,代码不能照抄老写法
拿到边缘图后,用cv2.findContours提取轮廓。这里有一个非常容易踩的版本坑:OpenCV 3 之前这个函数返回三个值,OpenCV 4 之后只返回两个值。网上大量老教程还在用_, contours, _ = cv2.findContours(...)解包三个变量,在 OpenCV 4 环境里直接报错。正确写法是:
# 提取轮廓(OpenCV 4 返回两个值) contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 按面积排序,答题卡通常是画面里最大的四边形 contours = sorted(contours, key=cv2.contourArea, reverse=True) # 取面积最大的轮廓做多边形逼近 peri = cv2.arcLength(contours[0], True) approx = cv2.approxPolyDP(contours[0], 0.02 * peri, True)RETR_EXTERNAL表示只提取最外层轮廓,避免答题卡内部题号圆圈被误检;CHAIN_APPROX_SIMPLE压缩水平、垂直和对角线段,只保存端点,减少后续计算量。approxPolyDP的第二个参数是逼近精度,取周长比例的 0.02,这个值需要根据图像分辨率微调——如果你自己拍的图是 4000x3000 级别的高清图,0.02 可能让多边形有 5 个顶点以上,需要适当调大到 0.03。
3.2 透视变换的四个点排序,错一个整个答题卡就是镜像翻转
找角点只是第一步,四个点的顺序必须固定下来:左上、右上、右下、左下。你从轮廓里拿到的点顺序是任意的,直接用会得到一张翻转或扭曲的矫正图。排序的思路是对四个点求和,x+y 最小的是左上,x+y 最大的是右下;再用 x-y 最大的是右上,x-y 最小的是左下。
def order_points(pts): # 初始化坐标矩阵 rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上:x+y 最小 rect[2] = pts[np.argmax(s)] # 右下:x+y 最大 diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] # 右上:x-y 最小 rect[3] = pts[np.argmax(diff)] # 左下:x-y 最大 return rect排序之后通过cv2.getPerspectiveTransform计算变换矩阵,再cv2.warpPerspective得到矫正后的俯视图。这里给个调试技巧:把四个角点画在图上输出看一遍,用cv2.circle在每个角点上画个红点,确认它们在图像的四角且顺序正确。假如你矫正后看到的是镜像文字,多半是 rect[1] 和 rect[3] 写反了。
3.3 划定填涂区域的网格参数,项目源码里是怎么按比例分割的
矫正后的图像上,答题区是一块固定比例的区域。源码的做法是把图像按宽度和高度等间隔切分,每个格子对应一道题的填涂区。这个方案的前提是:答题卡印刷时的版面是标准的,题号间距一致。
# 透视矫正后的图像转灰度 warped = cv2.warpPerspective(gray, M, (max_width, max_height)) # 二值化:填涂区域变白,背景变黑 thresh = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] # 假设共 5 题,每题 4 个选项,划分网格 n_questions = 5 n_options = 4 # 每行/列的高度宽度 cell_h = thresh.shape[0] // n_questions cell_w = thresh.shape[1] // n_optionsOTSU 自动阈值在这里很关键,它根据像素分布自动算最佳分割阈值,省去手动调参。但 OTSU 对光照不均敏感,如果答题卡有一半在阴影里,阈值会偏向亮度高的一侧导致填涂区域检测失败。后面避坑章节我会讲怎么处理。
4. 填涂检测与判分逻辑:像素统计比圆形检测更稳
4.1 为什么不用 HoughCircles 直接找填涂圆圈,检测项目里的实际原因是误检率
很多人第一反应是用cv2.HoughCircles检测填涂的圆形,然后再判断圆圈是否被涂黑。实际跑下来这个方案在真实场景里稳定性很差:HoughCircles 对圆的半径范围、圆心距的敏感度极高,答题卡印刷的圆圈稍微有残缺、铅笔痕迹没有完全涂满、纸张倾斜导致透视变形,都会让圆心检测漂移。当你面对几百张卡时,哪怕 1% 的误检率都意味着好几张卡要人工复核。
源码采用的是更直接的方案:对每个网格区域做像素统计,网格内白色像素占比超过阈值就认为被填涂。这个方案对光照波动、填涂不完全有天然容错,因为只要涂了,涂痕区域的白像素数量就会远超没涂的。这跟验证码识别的思路是一致的:—纯像素统计往往比几何检测稳。
4.2 单选题判分逻辑:mask 与答案字典的 zip 比对
判分环节把二值化后的图像按网格切分,每个格子cv2.countNonZero统计白色像素数,跟阈值比较得到布尔值。这套逻辑的关键是保持题目顺序和网格索引一致:
# 预置正确答案:第 0 题选 B,第 1 题选 A... ANSWER_KEY = {0: 1, 1: 0, 2: 3, 3: 2, 4: 1} # 索引对应选项 A=0, B=1, C=2, D=3 correct = 0 for q_idx in range(n_questions): # 取出该题所有选项的网格 for opt_idx in range(n_options): # 按行切分出当前选项的 ROI cell = thresh[q_idx * cell_h:(q_idx + 1) * cell_h, opt_idx * cell_w:(opt_idx + 1) * cell_w] # 统计白色像素 total = cv2.countNonZero(cell) if total > threshold: if opt_idx == ANSWER_KEY[q_idx]: correct += 1 break score = round(correct / n_questions * 100, 2)代码里threshold一般取 200 到 300 之间的值——注意不是像素占比,而是绝对白色像素数。因为每个格子大约 60x30 = 1800 像素,涂满时白色像素轻松过千,没涂时通常只有几个噪声点。如果你发现没涂的格子也被判为填涂,把 threshold 往上调到 500 就行。break放在if total > threshold里是默认考生只涂一个选项,遇到多选题需要去掉这个 break 并统计所有被涂的选项。
4.3 测试图 example_test.png 的识别效果对照,怎么判断你的参数是否合适
源码自带的 example_test.png 是全部涂满的示例卡,正确答案全选,理论上得分 100。你可以用它做参数校验:跑一遍程序,得分如果不是 100,逐题检查是哪个格子误判。打印每个格子的 countNonZero 值是最快的定位方式,哪一题的统计值异常,就把问题范围缩小到那一行的预处理或切割参数上。
5. 避坑指南:五个真实踩过的坑,含解决办法
5.1 坑一:填充色太淡导致漏判
现象:铅笔涂得很浅,判卷时明明涂了却识别为未选。
原因:灰度化后填涂区域的像素值偏高,OTSU 阈值把浅灰色铅笔痕归到了背景侧。
解决:不要直接对原始灰度图做 OTSU,先做一次 CLAHE 对比度增强,再二值化。cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))能有效拉深浅色填涂和白色背景的差距。
5.2 坑二:拍出来的图带角度,透视矫正后边角出现黑边
现象:矫正后的图像四周有黑色三角区,切网格时把黑边也算进去了。
原因:cv2.warpPerspective的变换是基于原始四角点计算的,如果原始拍摄角度太大,矫正后的图像边缘会出现未覆盖区域。
解决:计算目标宽高时不要直接用cv2.boundingRect的结果,手动加 20 到 30 像素的 padding,确保矫正后的图像完全包住答题卡内容。
5.3 坑三:findContours 找到的不是答题卡而是桌面纹理
现象:答题卡放在木纹桌面上拍,Canny 边缘图里木纹的连续线条面积比答题卡还大。
原因:RETR_EXTERNAL只管是否最外层,管不了面积排序。
解决:在找最大轮廓前,先cv2.GaussianBlur把核加大到 (9,9),木纹这类高频纹理会被抹掉;或者按cv2.contourArea筛掉面积小于图像总面积 30% 的轮廓。
5.4 坑四:程序在 Windows 上报错 “module 'cv2' has no attribute 'findContours'”
现象:代码从 OpenCV 3 教程复制,在 OpenCV 4 环境跑直接 AttributeError。
原因:OpenCV 4.x 修改了 findContours 的返回约定,旧版三返回值写法失效。
解决:用官方推荐的contours, hierarchy = cv2.findContours(...)。如果你正在维护老代码又不想改,可以在 import 时做兼容处理:try: _, contours, _ = cv2.findContours(...) except: contours, _ = cv2.findContours(...)。
5.5 坑五:对同一张图改一个参数后就全部错位
现象:只调整了 threshold 值,结果从第 3 题开始全部识别错位。
原因:改参数牵动了二值化结果,导致 countNonZero 的判定翻转,连带后续选项循环里的 break 逻辑路径改变。
解决:所有参数统一集中定义在脚本头部,不要散落在代码里;每次修改参数后,先跑 example_test.png 校验整体正确率再跑真实照片。
6. 批量报告生成与预处理脚本:把单张识别变成可复用工具
6.1 封装成函数:参数配置文件 + 批量处理循环
当你确认单张图的识别稳定后,下一步是改成批量模式。我通常的做法是:把预处理、透视矫正、判分三个环节各封装成一个函数,用配置文件管理所有阈值参数,这样新来的答题卡只需要调配置文件,不用动主逻辑。
def preprocess(image_path, config): img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, config["blur_kernel"], 0) edged = cv2.Canny(blurred, config["canny_low"], config["canny_high"]) return img, gray, edged def process_batch(image_paths, config): results = [] for path in image_paths: img, gray, edged = preprocess(path, config) # 透视矫正 + 网格切分 + 判分 # 把每张图的得分和逐题结果存进列表 results.append({"file": path, "score": score, "details": details}) return results这种结构最大的好处是排障效率高:如果批量处理 20 张图有 3 张得分异常,你可以单独把这三张的 preprocess 中间结果保存下来,对比正常和异常的 edge 图,问题出在 Canny 阈值还是透视排序,一眼就能看出来。config 用 Python dict 管理即可,不需要引入复杂的 yaml 依赖。
6.2 判分报告的 CSV 输出:直接对接 Excel 统计
批量处理的结果不能只打在控制台里,我习惯顺手写个 CSV:
with open("result.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["文件名", "第1题", "第2题", "第3题", "第4题", "第5题", "总分"]) for r in results: writer.writerow([r["file"], *r["details"], r["score"]])这样导出的文件用 Excel 或 pandas 直接打开,按分数排序、统计各题错误率都是现成的。如果你识别的是 100 题的答题卡,CSV 的列数会变多,但不影响程序逻辑——只要details列表顺序和题目顺序一致就行。
6.3 值得改装的三个方向:多选题、缺项检测、灰度化后直接跑判分
这套源码的边界很清晰:单选题、固定版面、均匀光照。往里加多选支持时,把判分循环里的break去掉,改用集合比较多选结果;缺项检测加一步全行像素统计,某行的总白色像素明显低于其他行就标记为「疑似漏涂」;如果你的答题卡是扫描仪出的图而不是手机拍的,灰度化后甚至可以跳过 Canny 和透视矫正,直接按比例切网格,速度能快三倍左右。
从那以后我每次跑这套代码,都会强制走一遍:先用 example_test.png 校准参数,再随机抽三张真实答题卡看中间结果图,最后才批量跑全量数据。因为图像处理这东西,参数是玄学,结果才是硬道理。这套源码的价值不在它本身有多复杂,而在于把完整的答题卡识别流程压缩成了一个可以直接复现的最小系统——光照、角度、填涂深浅这些问题你迟早会遇到,提前在测试图上踩一遍,比拿到真实考卷时再慌要划算得多。希望帮到你。
本文还有配套的精品资源,点击获取