简介:这是一套面向计算机视觉初学者与需要批量处理标准化考试场景开发者的OpenCV实战资料,围绕答题卡自动识别与判卷展开,可用于教育机构考试评分、数据分析等需要高效处理大量答题卡的场合。资源包共7个文件,以6张PNG测试图片和1个Python源码脚本为主,压缩包约3.08MB,图片用于模拟真实答题卡输入,脚本则串联图像采集、去噪、透视变换、边缘检测到填涂区域定位与答案判定的完整流程。源码结构清晰、注释详尽,便于理解每一步实现原理,读者可据此掌握图像预处理、特征提取与填涂深浅分析等关键方法,并直接运行测试图片验证效果。目前已有315人学习,适合作为OpenCV图像识别入门练手项目,也为有经验者提供可扩展的答题卡识别算法参考。
1. 答题卡识别判卷:一张 A4 纸背后的完整视觉流水线
考场收上来一摞答题卡,人工判卷最怕两件事:涂卡太浅机器读不出,涂改擦不干净留下灰印。用 OpenCV 做答题卡识别判卷,本质是把「找卡 → 校正 → 找选项 → 判涂没涂 → 对答案算分」这条流水线用代码固化下来。它适合两类人:一类是想找一个完整 OpenCV 项目练手的 Python 学习者,另一类是手里真有批量判卷需求、想先跑通最小可用版本的开发者。这个方向不需要深度学习,纯传统图像处理就能做到 95% 以上的识别率,前提是拍摄角度别太离谱、光照别太极端。下面按我实际做过的顺序,把每一步的参数和坑讲清楚。
2. 从拍照到二值图:答题卡识别的预处理链路
2.1 为什么先做灰度、高斯模糊、边缘检测这三步
拿到一张手机拍的答题卡,第一反应不是直接找轮廓,而是先把彩色信息丢掉。答题卡的核心信息是「黑色铅笔痕迹 vs 白色纸面」,颜色本身没有用,转灰度能减少三分之二的计算量。转完灰度不能直接 Canny,因为纸张纹理、拍摄噪点会产生大量假边缘,所以中间要插一步高斯模糊。
import cv2 import numpy as np # 读图,注意路径不要带中文,Windows 下容易出问题 image = cv2.imread("answer_sheet.jpg") # 缩小到固定宽度,避免不同分辨率导致后续参数失效 image = cv2.resize(image, (600, int(image.shape[0] * 600 / image.shape[1]))) gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 高斯核用 5x5,太大糊掉选项边缘,太小去不掉噪点 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # Canny 双阈值,低阈值 75 高阈值 200 是 OpenCV 官方示例的常用组合 edged = cv2.Canny(blurred, 75, 200)这段代码里三个参数最值得说。高斯核(5,5)是经验值,如果你拍的卡本身很清晰、噪点少,用(3,3)保留更多细节;如果光线差噪点多,可以上(7,7),但别超过 9,否则选项框的边会被抹平。Canny 的75/200这对阈值,低阈值决定「多弱的边算边」,高阈值决定「多强的边才起点」。答题卡轮廓清晰,用这个组合基本够;如果发现轮廓断断续续,把低阈值降到 50 试试。
提示:
cv2.imread读进来是 BGR 不是 RGB,后面如果要做颜色判断别搞反通道。
2.2 找答题卡轮廓:面积排序和四边形逼近
边缘图出来之后,要找的是答题卡那张纸的外框。思路是找所有轮廓,按面积从大到小排,取最大的那个近似四边形。
# 只取外轮廓,RETR_EXTERNAL 能过滤掉纸面内部的选项框干扰 cnts, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 按面积降序,最大的通常是答题卡本身 cnts = sorted(cnts, key=cv2.contourArea, reverse=True) sheet_cnt = None for c in cnts[:5]: # 只看前 5 个,避免遍历全部浪费时间 peri = cv2.arcLength(c, True) # 0.02 是逼近精度系数,越小越贴近原始轮廓 approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) == 4: # 四边形才可能是答题卡 sheet_cnt = approx breakapproxPolyDP的第二个参数0.02 * peri是核心。系数太大,四边形会被简化成三角形;系数太小,边角的小抖动会让它变成五边形六边形。0.02 是大量项目验证过的平衡点。如果循环跑完sheet_cnt还是 None,说明没找到四边形,常见原因是背景太乱或者卡没拍全,这时候要么换图要么放宽到len(approx) == 4改成<= 4。
2.3 透视变换:把歪着拍的卡掰正
找到四个角之后,不管拍摄角度多歪,都要把它映射成一个规规矩矩的矩形,否则后面按坐标切选项会全错。这一步叫透视变换,是整条链路里最不能省的一步。
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 rect = order_points(sheet_cnt.reshape(4, 2)) (tl, tr, br, bl) = rect # 计算变换后的宽高 width = max(int(np.linalg.norm(br - bl)), int(np.linalg.norm(tr - tl))) height = max(int(np.linalg.norm(tr - br)), int(np.linalg.norm(tl - bl))) dst = np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(image, M, (width, height))order_points这个函数是标准写法,靠坐标和与坐标差来定位四个角,比按轮廓顺序取点稳得多。变换后的warped就是一张正视图,后面所有操作都在它上面做。这里有个血泪经验:width和height一定要用int转一下,浮点数传给warpPerspective在某些 OpenCV 版本上会报类型错误。
3. 定位选项区域:阈值分割与轮廓筛选
3.1 自适应阈值为什么比固定阈值更适合答题卡
正视图拿到后,要把它变成黑白分明的二值图,才能区分「涂了」和「没涂」。固定阈值比如cv2.threshold(gray, 127, 255, ...)在光照均匀时能用,但手机拍的照片往往一边亮一边暗,固定阈值会把暗的那边整片判成黑色。自适应阈值按每个像素周围的小区域算阈值,能扛住光照不均。
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 自适应阈值:邻域 11x11,C 值 2 thresh = cv2.adaptiveThreshold(warped_gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)ADAPTIVE_THRESH_GAUSSIAN_C表示邻域内用高斯加权算阈值,比均值法更平滑。11是邻域大小,必须是奇数,太小会引入噪点,太大局部适应性变差。2是从算出的阈值里减去的常数,用来微调,值越大黑色区域越少。THRESH_BINARY_INV让涂黑的部分变成白色前景,方便后面找轮廓。
3.2 用轮廓面积和宽高比筛出选项圆圈
二值图里会有很多轮廓:选项、题号、边框线。要挑出真正的选项,靠两个特征——面积在合理区间、宽高比接近 1(因为选项通常是圆或方)。
cnts, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) question_cnts = [] for c in cnts: (x, y, w, h) = cv2.boundingRect(c) ar = w / float(h) # 面积 200~3000,宽高比 0.8~1.2,这两个区间要按实际卡调整 if 200 < cv2.contourArea(c) < 3000 and 0.8 <= ar <= 1.2: question_cnts.append(c)面积区间是最需要按实际图片调的参数。卡的分辨率越高,选项的像素面积越大。我一般会先跑一遍,把cv2.contourArea(c)打印出来看看分布,再定区间。宽高比 0.8~1.2 是假设选项接近正方形,如果你的卡选项是扁的,这个范围要相应放宽。
3.3 按坐标给选项排序:从上到下、从左到右
轮廓找出来是无序的,必须按位置排好,才能对应到「第几题第几个选项」。常见做法是先按 y 坐标分行,行内再按 x 坐标排。
# 先按 y 坐标排序 question_cnts = sorted(question_cnts, key=lambda c: cv2.boundingRect(c)[1]) # 简单分行:y 差值小于阈值的算同一行 rows = [] current_row = [question_cnts[0]] for c in question_cnts[1:]: if abs(cv2.boundingRect(c)[1] - cv2.boundingRect(current_row[-1])[1]) < 20: current_row.append(c) else: rows.append(sorted(current_row, key=lambda c: cv2.boundingRect(c)[0])) current_row = [c] rows.append(sorted(current_row, key=lambda c: cv2.boundingRect(c)[0]))这里的20是行间距阈值,单位是像素。如果你的卡行距大,这个值要调大。分行逻辑是简化版,实际项目里更稳的做法是用聚类或者按 y 坐标的直方图找波谷,但上面这段对规整的答题卡足够用。
4. 判定涂卡结果:像素统计与阈值决策
4.1 非零像素计数法:为什么它比面积法更稳
判断一个选项有没有被涂,最直接的方法是数这个区域里有多少白色像素(二值图里涂黑的部分是白色)。涂了的选项白色像素多,没涂的少。
def is_marked(thresh_region, threshold_ratio=0.3): # 统计非零像素数量 nonzero = cv2.countNonZero(thresh_region) total = thresh_region.shape[0] * thresh_region.shape[1] ratio = nonzero / float(total) return ratio > threshold_ratiothreshold_ratio是判定阈值,0.3 表示白色像素占比超过 30% 就算涂了。这个值不能设太低,否则铅笔灰印会被误判;也不能太高,否则涂得浅的会被漏判。我一般会在 0.25 到 0.4 之间调,具体看铅笔硬度和涂卡力度。相比直接算轮廓面积,像素计数对形状不规则的涂抹更宽容。
4.2 处理多涂和漏涂:业务逻辑层的兜底
图像处理只能告诉你「哪些选项被涂了」,但一张卡上可能出现一题涂了两个选项、或者一个都没涂的情况。这些要在业务逻辑里处理。
def judge_question(marked_list, answer): # marked_list 是这一题被判定为涂了的选项索引列表 if len(marked_list) == 0: return "漏涂", 0 if len(marked_list) > 1: return "多涂", 0 if marked_list[0] == answer: return "正确", 1 return "错误", 0这段逻辑看着简单,但实际判卷时「多涂」和「漏涂」必须单独标记出来,不能简单算错,因为考试规则里这两种情况处理方式不同。把判定结果和得分分开返回,后面统计总分和生成报告都方便。
4.3 批量判卷:把单张流程封装成函数
单张跑通之后,要能批量处理一整个文件夹的答题卡。封装的时候注意把标准答案和图片路径分开传参。
import os def process_sheet(image_path, answer_key): image = cv2.imread(image_path) # ... 前面所有预处理和定位步骤 ... # 假设 rows 已经排好,answer_key 是形如 [0, 2, 1, 3, ...] 的列表 results = [] for i, row in enumerate(rows): marked = [] for j, c in enumerate(row): x, y, w, h = cv2.boundingRect(c) region = thresh[y:y+h, x:x+w] if is_marked(region): marked.append(j) status, score = judge_question(marked, answer_key[i]) results.append((i + 1, status, score)) return results # 批量处理 folder = "sheets/" answer_key = [0, 1, 2, 3, 0, 1, 2, 3] # 按实际题目填 for fname in os.listdir(folder): if fname.endswith(".jpg"): res = process_sheet(os.path.join(folder, fname), answer_key) print(fname, sum(r[2] for r in res))answer_key的索引要和选项排序对应,0 代表 A,1 代表 B,以此类推。批量处理时建议加个 try-except,单张图失败不要影响整个文件夹。
5. 避坑与排查:答题卡识别最常见的 5 个翻车现场
5.1 轮廓找到了但透视变换后是歪的
现象:warped出来的图明显倾斜,选项区域对不上。原因:order_points拿到的四个点顺序错了,或者sheet_cnt本身不是答题卡而是背景里的某个四边形。解决:在order_points之后打印四个点的坐标,确认左上角的 x 和 y 都是最小值;如果sheet_cnt可疑,把cnts[:5]里每个轮廓的面积和顶点数都打印出来看。
5.2 选项轮廓数量对不上题目数
现象:8 道题应该找到 32 个选项轮廓,结果只找到 28 个。原因:面积区间或宽高比筛得太严,把边缘的选项过滤掉了;或者二值化时某些选项没被正确分割。解决:先把筛选条件去掉,把所有轮廓画在原图上保存出来看,确认选项是否都在;然后逐步收紧条件,找到能保留全部选项的区间。
5.3 涂得浅的选项被判成没涂
现象:明明涂了,但is_marked返回 False。原因:threshold_ratio设太高,或者自适应阈值的C值太大导致浅色痕迹被抹掉。解决:把threshold_ratio降到 0.2 试试;同时把自适应阈值的C从 2 降到 1 或 0,保留更多细节。如果还不行,考虑在二值化之前做一次直方图均衡化。
5.4 多张图跑同一套参数,有的成功有的失败
现象:A 图能识别,B 图报错或结果全错。原因:不同图片的分辨率、光照、拍摄角度差异大,固定参数不通用。解决:在预处理第一步统一 resize 到固定宽度,这是最有效的归一化手段;如果光照差异大,加一步cv2.equalizeHist做直方图均衡。参数不要写死在函数里,提到外面做成配置。
5.5 判卷结果和人工对不上但找不到原因
现象:总分差了 2 分,不知道哪题错了。原因:选项排序和标准答案的索引没对齐,或者某题多涂被算成了错误但人工按正确算。解决:把每题的判定状态(正确/错误/多涂/漏涂)和得分逐题打印出来,和人工判卷结果逐题对比。我一般会生成一张标注图,把判定为涂了的选项用绿框标出,一眼就能看出哪里判错了。
6. 把识别率从 90% 推到 99%:三个我常用的进阶技巧
第一个技巧是给选项区域加边距收缩。cv2.boundingRect拿到的框往往比实际选项大一圈,边缘的纸面噪点会被算进非零像素。我一般会把框往里缩 10% 到 15% 再统计。
x, y, w, h = cv2.boundingRect(c) margin_x, margin_y = int(w * 0.15), int(h * 0.15) region = thresh[y + margin_y:y + h - margin_y, x + margin_x:x + w - margin_x]这个改动看着小,但对涂得不满的选项效果明显,因为去掉了边缘的干扰像素,涂了和没涂的像素占比差距被拉大。
第二个技巧是双阈值投票。单次判定容易在临界值附近翻车,可以对同一个选项用两个不同的threshold_ratio各判一次,比如 0.25 和 0.35,两次结果一致才采纳,不一致就标记为「存疑」交给人工复核。这样虽然增加了一点人工量,但能把误判率压得很低。
第三个技巧是模板对齐。如果你的答题卡是固定版式,可以预先存一张空白卡的二值图作为模板,把待判图对齐到模板上再切选项区域。这样选项坐标完全固定,不用每次找轮廓,速度和稳定性都提升一个档次。代价是换一种版式的卡就要重新做模板。
| 技巧 | 适用场景 | 代价 |
|---|---|---|
| 边距收缩 | 涂卡不满、边缘噪点多 | 几乎无 |
| 双阈值投票 | 对准确率要求高 | 增加人工复核量 |
| 模板对齐 | 固定版式、批量大 | 换版式需重做模板 |
这三个技巧我一般按顺序上:先加边距收缩,不够再加双阈值投票,如果批量很大且版式固定,直接上模板对齐。实际项目里,边距收缩加双阈值投票基本能把识别率稳定在 98% 以上,剩下的 2% 靠人工复核兜底。
做这个项目最大的体会是:图像处理部分的代码量不大,真正花时间的是调参数和看中间结果。我养成了一个习惯,每做一步就把中间图cv2.imwrite存下来,出问题的时候按顺序翻图,比盯着代码猜快得多。希望帮到你。
本文还有配套的精品资源,点击获取