简介:一份基于Python OpenCV的答题卡识别判卷完整项目,面向毕业设计、期末大作业及课程设计场景,解决自动读取答题卡、识别选择题答案与考生学号并客观评分的需求。包内包含49个文件,核心为9个Python源码模块,涵盖图像预处理、答题卡定位、选项涂点检测、学号识别及结果导出;另附22张测试图片、结果展示HTML、报告PDF与答辩PPT,以及配置文件与说明文档,压缩包仅3MB,便于快速下载与本地运行。项目源码经本地编译可运行,评审分数达98分,难度适中,适合计算机视觉初学者学习参考。代码按功能拆分为answer_card_recognition、choice_question_recognition、exam_num_recognition等多个模块,并配有训练脚本与自定义数据集工具,便于理解识别流程并二次开发。包内还提供报告和答辩PPT,可直接支撑项目讲解与成果展示。目前已有136人学习浏览,适合需要完整可运行案例的开发者下载使用。
1. 一张答题卡,为什么值得用 OpenCV 单独做一套判卷:成本、场景与适用范围
在学校社团、培训机构或者小型考试场景里,几十张答题卡送出去机读不划算,人工判又费时还容易看错。基于 Python OpenCV 实现的答题卡识别判卷,就是把答题卡拍下来,通过图像处理定位填涂区域,再和标准答案比对完成自动判分。这套方案几乎零成本,只要会 Python 基础就能改得动,而且从拍照到出分的过程完全透明,哪一步判错了都能回头看,不会像某些闭源阅卷系统一样是个黑匣子。适合正在做课设的在校生、有批量阅卷需求的小机构,以及想用 OpenCV 做完整图像处理实战的开发者。
2. 答题卡识别判卷的核心流程拆解:预处理、定位、判分为什么按这个顺序来
2.1 预处理不是“做了就行”:灰度、去噪、边缘检测的顺序直接影响定位成功率
OpenCV 处理答题卡的第一步永远是读图,但读进来的是 BGR 三通道彩色图,直接拿去做轮廓检测会带来两个问题:一是三通道数据让后续的阈值分割和边缘检测计算量变大,二是彩色图里的颜色信息对“找答题卡边界”这件事几乎没有帮助。所以第一步要做 cv2.cvtColor 转灰度,把三通道压成一通道。这里有个连老手都会犯的误区:转灰度之前先做一次缩放,把长边缩到 1000 像素左右再进流程,而不是直接拿 3000 像素的原图跑。缩放的目的是让高斯模糊和自适应阈值的邻域尺寸保持稳定,不然同一套参数在不同分辨率下表现完全不同。
转完灰度之后,照片里的噪点、扫描条纹和纸张纹理仍然会影响边缘检测。常见的做法是接一个高斯模糊,核大小通常取 (5, 5),sigma 由 OpenCV 根据核大小自动推算。这里要注意,高斯模糊的核不能太大,太大会把答题卡的边缘也抹平,导致后面找轮廓时四边形边界塌进去。核取到 (9, 9) 以上时,细线条边缘会明显变弱,我一般控制在 (5, 5) 或 (3, 3)。
边缘检测用 Canny 是教材里的标准答案,但实际项目中我会先试 Canny(blurred, 50, 150),再看答题卡边框是否完整。Canny 的两个阈值是滞后阈值,低阈值决定哪些边缘起点被保留,高阈值决定哪些算强边缘。如果答题卡和背景的对比度不高,比如浅色桌面上的白色答题卡,就要把低阈值往下调到 30 左右,否则边框断成几截,后面的轮廓近似就找不到四边形。预处理这一整段在报告里值得单独放一节,因为它是整个判卷流程里最影响成功率的一步。答辩时被问到“为什么准确率不是 100%”,十有八九要从预处理开始解释,而不是从判分逻辑里找问题。
2.2 透视矫正:答题卡歪着拍,后面的判分全是错的
用手机拍摄的答题卡几乎不可能是正对着拍的,总有透视变形。如果不做矫正,直接按像素坐标去切填涂区域,切出来的每个选项框都会偏移,偏移量在图像边缘尤其明显。这里的标准做法是先找到答题卡外轮廓,再算透视变换矩阵,把四边形区域拉正成矩形。
找外轮廓用的是 cv2.findContours,配合 RETR_EXTERNAL 只取最外层轮廓,再用 cv2.contourArea 找出面积最大的轮廓,这个轮廓理论上就是答题卡。拿到轮廓后,用 cv2.approxPolyDP 做多边形近似,当近似结果收敛到 4 个点时,就认为找到了答题卡的四个角。approximation 的精度由 epsilon 控制,取轮廓周长的 2% 是常规起点,但实际项目中我经常需要把它调到 1% 到 3% 之间。拍得不干净的答题卡外轮廓会有大量锯齿,epsilon 太小会导致近似出 5 个以上顶点,epsilon 太大又会把直角压成钝角,透视矫正出来就是歪的。
有了四个角点之后,需要按顺序排列成左上、右上、右下、左下。有个典型坑:findContours 出来的点顺序不固定,有时候是顺时针,有时候是逆时针。常规做法是用点的坐标和与差来排序:左上角是 x+y 最小的点,右下角是 x+y 最大的点,右上角是 x-y 最小或最大的点,根据轮廓方向选。排序写错,矫正出来的图像就是旋转 90 度或镜像的,判分结果自然全部错位。
cv2.getPerspectiveTransform 负责根据两组对应点算出 3x3 变换矩阵,cv2.warpPerspective 再把灰度图映射成矫正图。矫正图的目标宽高用四个角点之间的欧氏距离估算,而不是硬编码成固定尺寸,这样拍摄距离不同的答题卡都能按相同比例切分选项区域。透视矫正这一步做得好不好,最直接的验证方法是看矫正图里答题卡印刷边框是否水平且厚度均匀,如果边框一侧厚一侧薄,说明角点定位还有偏差,需要回头调整边缘检测或轮廓近似参数。
2.3 填涂判断:不是“像素多就是填了”,而是区域占比和阈值的配合
透视矫正完成后,下一步是从矫正图中提取填涂信息。这里需要做一次二值化,把像素分成前景和背景两类。最省心的是 cv2.threshold 配合 THRESH_OTSU,让算法自动算出全局分割阈值,适合光照均匀的扫描件。但手机拍摄的光照往往不均匀,OTSU 是全局阈值,一旦答题卡一侧有阴影,阴影区的空白格就会被判成已填涂,或者浅涂的铅笔痕被识别成空白。
处理光照不均更好的选择是 cv2.adaptiveThreshold,它按邻域计算局部阈值,对渐变阴影有不错的抵抗力。代价是参数敏感:blockSize 决定邻域大小,C 值决定偏移量。blockSize 太大会退化成全局阈值,太小会把填涂内部的纹理切碎。我一般从 blockSize=15、C=7 起步,根据实际拍摄效果调。如果选项格边缘在自适应阈值后出现很多碎点,说明邻域太大了,把 blockSize 降到 11 或 13 看看。
接下来是判断每个选项格是否被填涂。单个选项格区域可以用 numpy 切片直接从二值图上抠出来,然后统计非零像素占比。cv2.countNonZero 统计白色像素数量,除以这块区域的总像素数,得到的就是填涂覆盖率。覆盖率超过某个阈值判定为已填涂,低于阈值判定为未填涂。这个阈值是整套系统的核心参数,它在浅涂漏判和笔痕误判之间取平衡,后文的避坑章节会展开讲。
判分逻辑反而是整个项目里最简单的一步:把每道题的已填涂选项和标准答案列表比对,一致就得分,不一致就不得分,最后统计总分。真正值得花精力的是前面这些图像处理步骤,前面任何一步的误差都会在判分时被放大好几倍。
3. 用 Python + OpenCV 跑通最小判卷流程:核心代码、关键参数与答辩素材
3.1 从读图到透视矫正:最小可运行代码
下面这段代码实现了从读取图片到输出矫正图的全过程,是整套判卷系统最基础的一段。我习惯把它写成一个函数,而不是堆在顶层脚本里,因为后续批量判卷时每个步骤都有单独复用的需求。
import cv2 import numpy as np 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 def four_point_transform(image, pts): """透视矫正核心函数""" rect = 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]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped def preprocess(image_path): """读图、去噪、找轮廓、透视矫正""" image = cv2.imread(image_path) gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blurred, 50, 150) # 找最大轮廓并近似为四边形 cnts, _ = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) doc = max(cnts, key=cv2.contourArea) peri = cv2.arcLength(doc, True) approx = cv2.approxPolyDP(doc, 0.02 * peri, True) if len(approx) != 4: raise ValueError("未找到四边形的答题卡区域,检查预处理参数") warped = four_point_transform(gray, approx.reshape(4, 2)) return warped代码逻辑说明:这段代码先读图转灰度,再做高斯模糊和 Canny 边缘检测,然后用 findContours 找最大轮廓。找到的轮廓经过 approxPolyDP 近似,如果收敛到 4 个点,就认为是答题卡的四个角。最后调用 four_point_transform 完成透视矫正。
参数说明:高斯模糊核 (5, 5) 适合大多数手机拍摄场景;Canny 阈值 (50, 150) 在光线较好时可靠,光线差时要往下调。approxPolyDP 的 epsilon 这里取了 2% 周长,如果轮廓锯齿严重,试着降到 1%。
提示:cv2.findContours 在 OpenCV 3.x 返回三个值,4.x 返回两个值。上面代码按 4.x 写法,如果你还在用 3.x,解包要写成 cnts, _, _ = cv2.findContours(...)。建议直接统一用 opencv-python 4.x,省得跨环境踩坑。
3.2 填涂区域提取与判分:核心阈值怎么设
得到矫正图之后,需要把它二值化并逐格判断。下面这段代码演示了从矫正图到最终判分的完整过程。
def grade_sheet(warped, answer_key, config): """ answer_key: 每道题的正确选项编号,如 [1, 0, 2, 3] 表示 B, A, C, D config: 选项格区域配置,含起点坐标和格宽高 返回: (正确题数, 总题数, 每题的判定结果) """ # 自适应阈值二值化,抵消光照不均 thresh = cv2.adaptiveThreshold( warped, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 7 ) (start_x, start_y, choice_w, choice_h) = config total_questions = len(answer_key) choices = 4 # A/B/C/D selected = [] for q in range(total_questions): fill_ratios = [] for c in range(choices): x = start_x + c * choice_w y = start_y + q * choice_h roi = thresh[y:y + choice_h, x:x + choice_w] ratio = cv2.countNonZero(roi) / (choice_w * choice_h) fill_ratios.append(ratio) # 取覆盖率最高的选项作为最终答案 best = int(np.argmax(fill_ratios)) selected.append(best if fill_ratios[best] > 0.3 else -1) # 与标准答案比对 correct = 0 for i, (sel, key) in enumerate(zip(selected, answer_key)): if sel == key: correct += 1 return correct, len(answer_key), selected逻辑说明:adaptiveThreshold 把灰度矫正图转成二值图,THRESH_BINARY_INV 让填涂区域变白、空白区域变黑。然后按 config 里给出的选项格位置,用切片逐块取出每个选项区域,统计白色像素占比。占比最高的选项视为学生填涂的答案,但只有占比超过 0.3 才被接受,低于 0.3 表示该题没填涂。最后逐题比对标准答案,统计得分。
参数说明:填涂判定阈值 0.3 表示某个选项区域里至少有 30% 的像素被涂黑才认为填了;这个值对铅笔深浅和图像分辨率很敏感,需要按实际效果调。adaptiveThreshold 的 blockSize=15 是区域大小,C=7 是减去局部均值的偏移量,两者共同决定二值化对阴影的容忍度。choice_w 和 choice_h 的取值依赖答题卡模板,如果用标准印刷尺寸的答题卡,可以按像素比例推算,但最稳妥的做法还是预先读取一张干净模板图,手动标定这四个值。
3.3 报告和答辩 PPT 里值得放的三类素材
答辩时讲这套系统,不要从头到尾把代码念一遍,评委想听的是你解决了什么问题、遇到过什么坑、怎么判断方案好坏。报告里我一般会放三部分:系统流程图、关键步骤对比图、指标表。
流程图用最简单的方框箭头画清楚:读入 → 灰度化 → 高斯模糊 → Canny 边缘检测 → 轮廓定位 → 透视矫正 → 自适应阈值 → 选项格切片 → 填涂判定 → 答案比对 → 输出成绩。这张图能把整个项目的工程量讲清楚。
对比图是答辩加分的重点。放三组就够了:原图与矫正图的对比、二值化前后对比、正确填涂与浅涂或误涂的对比。每组对比图旁边写一行说明,讲为什么这一步是必要的。比如透视矫正图旁边写“拍摄角度造成约 15 度倾斜时,未矫正的选项格偏移量实测达到 4 像素,直接导致判分错位”,这一句话比十页原理都管用。
指标表里放三列数据:样本数、识别准确率、平均处理时长。哪怕你的准确率只有 92%,也比不放数据强,因为放数据代表你在用结果验证方案。比准确率更重要的是错误类型分析——错在哪里,是浅涂漏判还是边缘选项偏移,这体现的是你做了排查,而不是跑通就完事。
核心参数再整理成一张表,方便答辩时对照讲:
| 参数 | 推荐起点 | 调整方向 |
|---|---|---|
| GaussianBlur 核大小 | (5, 5) | 噪点多加至 (7, 7),但别超过 (9, 9) |
| Canny 阈值 | 50/150 | 对比度低时降到 30/100 |
| approxPolyDP epsilon | 0.02 * 周长 | 锯齿严重时降到 0.01 * 周长 |
| adaptiveThreshold blockSize | 15 | 阴影面积大时调到 21 或 25 |
| adaptiveThreshold C | 7 | 空白格误判时加大到 10 到 15 |
| 填涂判定阈值 | 0.30 | 浅涂漏判降到 0.25;笔痕误判升到 0.35 |
答辩时拿着这张表讲参数调整逻辑,比单纯贴代码有说服力得多。评委关心的是你能不能根据现象反推参数,而不是背 API。
4. 答题卡识别判卷的五个高频避坑点:现象、原因与解决方案
4.1 光照不均,右侧一列选择题全部漏判
现象:同一张答题卡,左侧题目判得正常,右侧几列填涂区域大面积漏判,有些空白格反而被判定为已填涂。
原因:全局阈值二值化无法应对渐变光照。无论 OTSU 还是固定阈值,它取的是整张图像的单一分割点。答题卡一侧有阴影时,阴影区域和正常区域的灰度分布不在一个水平线上,同一个阈值自然顾此失彼。
解决:把 cv2.threshold 换成 cv2.adaptiveThreshold,按局部邻域计算阈值。参数从 blockSize=15、C=7 起步,阴影严重的场景把 C 值加大到 10 到 15,因为 C 值越大,阈值越苛刻,空白格被误判为填涂的概率越低。如果自适应阈值后填涂区域边缘变得破碎,先做一次 cv2.morphologyEx 的 MORPH_CLOSE 闭运算,用 3x3 的核把边缘连起来。
4.2 透视矫正后答案区域偏移,标准答案全部错位
现象:透视矫正看起来成功了,答题卡边框也拉正了,但切出来的选项格和实际填涂位置对不上,越靠近图像边缘错位越明显。
原因:角点排序错误或者四边形近似精度不够。角点顺序搞错,矫正图会旋转或镜像,这种情况错位是全图的,一眼就能看出。但更隐蔽的是近似轮廓本身就不准,比如某个角点收敛到了边框内侧的一个噪点位置,矫正矩阵就是按这个错误点计算的,输出的图像自然有轻微梯形畸变。
解决:先打印 approx 的四个坐标点,核对它们是否按左上、右上、右下、左下排列。如果顺序没问题,把 approxPolyDP 的 epsilon 从 0.02 降到 0.01 再跑一次,看轮廓是否更紧贴边框。还有一种更稳的做法:不要只依赖单个最大轮廓,而是用 cv2.HoughLinesP 检测答题卡外边框直线,用两条横线和两条竖线的交点推导角点——OpenCV 检测直线在这种场景下比找轮廓更可靠,缺点是代码量明显增加,适合当进阶优化方向。
4.3 铅笔浅涂被漏判,签字笔墨痕反而被判成已填涂
现象:学生用 HB 铅笔轻轻涂的选项,覆盖率只有 20% 出头,低于判定阈值被判成未填涂;而用签字笔在选项框边缘划了一道笔痕,覆盖率也超过了阈值,变成已填涂。
原因:填涂判定只看覆盖率这一个指标,无法区分“大面积浅涂”和“小面积深色笔痕”。浅涂的特点是覆盖面积大但灰度值不低,笔痕的特点是覆盖面积小但灰度值很低。单一的覆盖率阈值在这两种情况下都会误判。
解决:把判定条件从单阈值改成双条件:覆盖率超过 0.3,且区域内灰度均值低于某个值,两者同时满足才算填涂。笔画留下的痕迹通常比较细,覆盖率大概率过不了阈值;而浅涂虽然灰度偏高,但面积能达到阈值。如果想要更强鲁棒性,可以再加一个条件:对选项区域做形态学膨胀后,白色像素的连通域数量不超过 2 个,笔痕和擦痕形成的碎片会被这个条件过滤掉。
4.4 答题卡方向放反,A/B 卷结果完全颠倒
现象:有些答题卡设计了两个方向的卷头,学生放反了也能答题,但机器判出来错题率极高,而且错的都是同一批题。
原因:判卷系统没有做方向检测,默认答题卡是正放的。当答题卡上下颠倒进入流程时,所有坐标全部镜像,每道题选到的选项和实际填涂位置正好差四个选项位,相当于当年机读卡里最经典的“读卡头装反事故”。
解决:最简单的办法是在模板的固定位置放一个定位标记。比如在答题卡右下角放一个黑色方块,判卷前先在这个区域内统计非零像素占比,如果占比低于某个阈值就认为答题卡是颠倒的,先把图像旋转 180 度再走判卷流程。另一个常见做法是检测二维码或定位角标记,这个在批量判卷场景里更可靠。
4.5 环境依赖不一致,findContours 和 contourArea 的报错现场
现象:代码在自己电脑上跑得好好的,换到实验室电脑或答辩机器上就报错。常见报错是 ValueError: not enough values to unpack (expected 3, got 2),或者反过来 expected 2, got 3,还有人会看到 NameError: name 'contourArea' is not defined。
原因:第一类报错是 OpenCV 3.x 和 4.x 的 cv2.findContours 返回值数量不一致,3.x 返回三个值,4.x 返回两个值。不同机器预装版本不同,解包代码自然翻车。第二类报错多半是命名空间问题,代码里用 from cv2 import * 导入了所有函数,但函数名拼写或模块前缀用错,本质上不是 OpenCV 的锅,而是 Python 装环境和 import 方式的问题。
解决:统一安装 opencv-python 4.x,代码里固定写 cnts, _ = cv2.findContours(...),别用三值解包。环境写进 requirements.txt 并注明版本号,答辩演示前在另一台干净机器上跑一遍 python -c "import cv2; print(cv2.version)" 确认版本一致。很多同学把这类问题归因于“看不懂的报错”,其实多数是 python 安装和 opencv 安装阶段没锁定版本导致的。环境问题归环境问题,算法问题归算法问题,分清楚再动手,效率会高很多。
5. 从单张判卷到批量阅卷:三个必须先做的鲁棒性改造
5.1 用定位标记替代“最大轮廓”,解决背景干扰问题
单张判卷时用最大轮廓找答题卡很顺手,一旦背景里出现桌面纹理、其他纸张,这个逻辑就会翻车。批量改造的第一步,是固定在答题卡的四个角或三个角设计专用定位标记,用模板匹配或颜色过滤先找到定位标记,再根据标记之间的相对关系推导四角坐标。这个改动一次到位,之后换背景、换光线都不用再调轮廓参数。
5.2 把阈值和坐标配置外置成参数文件
判卷项目的所有关键参数——自适应阈值 blockSize、填涂判定阈值、选项格坐标——都不要写死在代码里。常见做法是放到一个 config.json 里,每次换答题卡模板只改配置不改代码。这听起来是常识,但很多课设项目恰恰是改参数改到怀疑人生之后才意识到这一点。把配置和逻辑分离,本质上是在给未来的自己留后悔药。
5.3 用混淆矩阵评估判卷,而不是只看准确率
准确率回答的是“整体对了多少”,但判卷系统真正需要关注的是“哪些题经常误判”。把每个题号的判分结果和人工复核结果对照,整理出四类数据:真正确、真错误、漏判(实际涂了但系统判没涂)、误判(没涂的被判成涂了)。漏判和误判的比值决定了系统适合做初筛还是做最终判卷。我自己的经验是,正确填涂的覆盖率稳定在 0.6 以上时,判定阈值设在 0.35 到 0.4 之间,漏判和误判的比例最均衡。
批量判卷做起来之后,还有一个很多人会忽略的细节:每一张判完的答题卡都把透视矫正图和每个选项的覆盖率附到结果表里。这样学生质疑判分时,你能直接指出是哪个选项的覆盖率不达标,而不是系统黑匣子说多少分就是多少分。这也是答辩时最值得拿出来讲的一个工程细节。
我自己的习惯是每次改完参数,先把同一批 20 张答题卡跑一遍,人工复核出错的那几张,把错误截图存到按日期命名的文件夹里,下次调参直接用这批回归样本验证。看起来多花了十分钟,实际上比反复试参数省力得多。希望这套答题卡识别判卷的流程梳理能帮到你,从单张跑通到批量落地,少走我当年走过的弯路。
本文还有配套的精品资源,点击获取