基于OpenCV的答题卡自动识别与评分系统
引言
在教育考试场景中,手动批改答题卡效率低下且容易出错。本文将介绍如何使用Python和OpenCV实现一个答题卡自动识别与评分系统,通过计算机视觉技术完成答题卡的透视校正、选项识别和得分计算。该系统可广泛应用于学校考试、培训测评等场景,大幅提升批改效率。
环境准备
- Python 3.7+
- OpenCV(
pip install opencv-python
) - NumPy(
pip install numpy
)
核心功能模块解析
整个系统分为以下核心步骤:
图像预处理 → 轮廓检测 → 透视变换 → 选项定位 → 答案匹配 → 结果输出
1. 导入依赖库
import numpy as np
import cv2
numpy
:用于数值计算和数组操作。cv2
:OpenCV库,提供图像处理和计算机视觉算法。
2. 辅助函数定义
2.1 坐标点排序函数 order_points
答题卡的四个角点需要按左上→右上→右下→左下的顺序排列,否则透视变换会出错。
该函数通过计算坐标的和与差实现排序:
def order_points(pts):rect = np.zeros((4, 2), dtype="float32")s = pts.sum(axis=1) # 计算(x+y),左上角点(x+y)最小,右下角点最大rect[0] = pts[np.argmin(s)] # 左上rect[2] = pts[np.argmax(s)] # 右下diff = np.diff(pts, axis=1) # 计算(y-x),右上角点(y-x)最小(接近0),左下角点最大rect[1] = pts[np.argmin(diff)] # 右上rect[3] = pts[np.argmax(diff)] # 左下return rect
2.2 透视变换函数 four_point_transform
通过透视变换将倾斜的答题卡校正为正视图。
关键步骤:
- 计算变换前的四个角点(
rect
)。 - 确定变换后的目标尺寸(
maxWidth
和maxHeight
)。 - 生成透视变换矩阵(
M
)并应用变换。
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]], dtype="float32")# 计算透视变换矩阵并应用M = cv2.getPerspectiveTransform(rect, dst)warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))return warped
2.3 轮廓排序函数 sort_contours
检测到的选项轮廓需要按从上到下、从左到右排序,以便逐题匹配答案。
支持多种排序方式(左→右、右→左、上→下、下→上):
def sort_contours(cnts, method="left-to-right"):reverse = Falsei = 0 # 排序依据:0为x轴(左右),1为y轴(上下)if method in ["right-to-left", "bottom-to-top"]:reverse = Trueif method in ["top-to-bottom", "bottom-to-top"]:i = 1# 计算每个轮廓的包围盒(x,y,w,h)boundingBoxes = [cv2.boundingRect(c) for c in cnts]# 按包围盒的指定维度排序(x或y坐标)(cnts, boundingBoxes) = zip(*sorted(zip(cnts, boundingBoxes), key=lambda b: b[1][i], reverse=reverse))return cnts, boundingBoxes
2.4 图像显示函数 cv_show
调试时用于显示中间结果:
def cv_show(name, img):cv2.imshow(name, img)cv2.waitKey(0)cv2.destroyAllWindows()
3. 图像预处理与轮廓检测
3.1 读取图像并灰度化
image = cv2.imread("images/test_01.png") # 替换为你的答题卡路径
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 灰度化减少计算量
3.2 高斯模糊去噪
blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 5x5核,σ=0
cv_show('blurred', blurred) # 调试:观察模糊效果
高斯模糊可消除图像中的高频噪声(如纸张纹理、光照不均),避免后续边缘检测出现伪影。
3.3 Canny边缘检测
edged = cv2.Canny(blurred, 75, 200) # 阈值75和200
cv_show('edged', edged) # 调试:观察边缘轮廓
Canny算法通过梯度计算提取图像边缘,参数75
和200
分别为低阈值和高阈值,用于区分强边缘和弱边缘。
3.4 轮廓检测与筛选
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2] # 获取外层轮廓
# 按面积降序排序(最大的轮廓通常是答题卡)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
# 寻找近似四边形(答题卡的四个角点)
docCnt = None
for c in cnts:peri = cv2.arcLength(c, True) # 计算轮廓周长approx = cv2.approxPolyDP(c, 0.02 * peri, True) # 多边形近似(精度0.02倍周长)if len(approx) == 4: # 筛选四边形docCnt = approxbreak
cv2.findContours
:检测图像中的轮廓,RETR_EXTERNAL
表示只检测外层轮廓。approxPolyDP
:通过道格拉斯-普克算法简化轮廓,保留关键顶点(答题卡的四个角点)。
4. 透视变换校正答题卡
warped_t = four_point_transform(image, docCnt.reshape(4, 2)) # 应用透视变换
cv_show('warped', warped_t) # 调试:观察校正后的答题卡
通过four_point_transform
函数,倾斜的答题卡被校正为正视图,便于后续选项定位。
5. 选项区域定位与识别
5.1 二值化处理
warped = cv2.cvtColor(warped_t, cv2.COLOR_BGR2GRAY) # 转为灰度图
thresh = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] # Otsu自适应阈值
cv_show('thresh', thresh) # 调试:观察二值化结果
THRESH_BINARY_INV
:反转二值化结果(选项填涂区域为白色,背景为黑色)。THRESH_OTSU
:自动计算最佳阈值,适应不同光照条件。
5.2 筛选选项轮廓
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) # 计算宽高比# 筛选条件:尺寸足够大且接近正方形(0.9≤宽高比≤1.1)if w >= 20 and h >= 20 and 0.9 <= ar <= 1.1:questionCnts.append(c)
print(f"检测到{len(questionCnts)}个选项轮廓")
通过宽高比(接近1)和最小尺寸(避免噪声)筛选出有效选项轮廓。
5.3 按题目分组排序
questionCnts = sort_contours(questionCnts, method="top-to-bottom")[0] # 按从上到下排序
假设每道题有5个选项,按行分组后逐题处理。
6. 答案匹配与评分
6.1 定义正确答案
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1} # 键为题号,值为正确选项索引(0~4)
根据实际题目修改ANSWER_KEY
,例如第0题正确选项是第1个(索引从0开始)。
6.2 逐题识别答案
correct = 0
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)): # 每5个选项为一题cnts = sort_contours(questionCnts[i:i+5])[0] # 当前题的5个选项(按左→右排序)bubbled = None # 记录当前题填涂最深的选项for (j, c) in enumerate(cnts): # 遍历每个选项# 创建掩膜(仅保留当前选项区域)mask = np.zeros(thresh.shape, dtype="uint8")cv2.drawContours(mask, [c], -1, 255, -1) # -1表示填充轮廓内部# 计算掩膜区域的非零像素数(填涂程度)thresh_mask_and = cv2.bitwise_and(thresh, thresh, mask=mask)total = cv2.countNonZero(thresh_mask_and)# 更新填涂最深的选项if bubbled is None or total > bubbled[0]:bubbled = (total, j)# 匹配正确答案k = ANSWER_KEY[q] # 当前题的正确选项索引if k == bubbled[1]: # 填涂选项与正确答案一致color = (0, 255, 0) # 绿色标记正确correct += 1else:color = (0, 0, 255) # 红色标记错误# 在校正后的图像上绘制结果cv2.drawContours(warped_t, [cnts[k]], -1, color, 3)
- 掩膜技术:通过
mask
仅保留当前选项的区域,统计该区域的白色像素数(填涂程度),像素数最多的选项即为填涂答案。 - 结果可视化:正确选项用绿色框标记,错误选项用红色框标记。
6.3 计算得分并输出
score = (correct / len(ANSWER_KEY)) * 100 # 总题数为ANSWER_KEY的长度
print(f"[INFO] 得分: {score:.2f}%")
# 在图像上显示得分
cv2.putText(warped_t, f"{score:.2f}%", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
cv2.imshow("Result", warped_t)
cv2.waitKey(0)
运行结果示例
假设测试图像test_01.png
是一张5题的答题卡,其中3题正确,2题错误,则输出:
检测到25个选项轮廓
[INFO] 得分: 60.00%
最终图像会显示校正后的答题卡,正确选项为绿色框,错误选项为红色框,并标注得分。
注意事项与改进方向
- 图像质量:确保答题卡光照均匀、无遮挡,否则可能导致轮廓检测失败。
- 答案键配置:需根据实际题目修改
ANSWER_KEY
字典。 - 鲁棒性优化:可添加轮廓面积过滤、倾斜角度校正等功能,适应更复杂的拍摄场景。
- 多题型支持:当前仅支持单选题,可扩展支持多选题(通过统计多个最高像素数的选项)。