计算机视觉(opencv)实战二十四——扫描答题卡打分
扫描答题卡打分系统原理与实现
答题卡自动阅卷是计算机视觉在教育领域的经典应用。本文将详细介绍如何使用 OpenCV 和 NumPy 从扫描的答题卡图像中自动识别作答选项、比对标准答案并计算得分。
整体流程
整个答题卡打分系统可以拆分为以下环节:
图像预处理 – 灰度化、去噪、边缘检测。
答题卡定位与透视变换 – 提取答题卡轮廓,将倾斜的图像矫正。
二值化处理 – 将答题区域转为黑白图,便于检测圆圈。
轮廓提取与筛选 – 找出所有可能的选项圆圈。
按顺序排序选项 – 保证每一题的选项从左到右。
识别涂黑的选项 – 利用掩膜统计涂黑面积。
与答案比对、计算得分 – 给出总分并标记正确/错误选项。
下面我们逐步剖析代码和原理。
你给出的代码是答题卡自动打分的核心工具函数部分,负责几项关键操作:
四点排序 + 透视变换
轮廓排序
图像展示
我来帮你逐行解析一下,让你完全理解每个函数的作用和细节。
图片准备:
一、函数准备
1. 导入工具包与答案定义
import numpy as np
import cv2
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1} # 正确答案
NumPy:用于数组和数学运算(特别是点坐标计算)。
OpenCV:用于图像处理。
ANSWER_KEY:字典,表示每道题的正确选项。
例如{0: 1}
表示第0题的正确答案是第1个选项。
2. order_points – 四点排序
def order_points(pts):# 一共4个坐标点rect = np.zeros((4, 2), dtype="float32") # 用来存储排序之后的坐标# 按顺序找到对应坐标0123分别是 左上,右上,右下,左下s = pts.sum(axis=1) # 对pts矩阵的每一行进行求和操作。(x+y)rect[0] = pts[np.argmin(s)] # 左上角 -> x+y 最小rect[2] = pts[np.argmax(s)] # 右下角 -> x+y 最大diff = np.diff(pts, axis=1) # 对pts矩阵的每一行进行求差操作。(y - x)rect[1] = pts[np.argmin(diff)] # 右上角 -> y-x 最小rect[3] = pts[np.argmax(diff)] # 左下角 -> y-x 最大return rect
作用:
接收一个四边形的四个点,返回按 左上、右上、右下、左下 顺序排列的新数组。
这是透视变换的前提:如果点顺序错了,透视矫正就会失败。
原理:
x+y
最小的是左上点,因为它最靠近坐标原点。x+y
最大的是右下点,因为它离原点最远。y-x
最小的是右上点(因为 y 比 x 小)。y-x
最大的是左下点。
3. four_point_transform – 透视变换
def four_point_transform(image, pts):rect = order_points(pts)(tl, tr, br, bl) = rectwidthA = 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
作用:
根据答题卡四个顶点的位置,将倾斜的答题卡矫正为正视图,方便后续处理。
关键步骤:
计算目标宽度、高度:
widthA
:下边长度widthB
:上边长度maxWidth
:取两者最大值,避免变形heightA
、heightB
:左右边长度,取最大值作为目标高度
构建目标坐标:
左上角 → (0,0)
右上角 → (maxWidth-1,0)
右下角 → (maxWidth-1,maxHeight-1)
左下角 → (0,maxHeight-1)
计算透视矩阵:
用cv2.getPerspectiveTransform
得到变换矩阵M
。执行透视变换:
用cv2.warpPerspective
得到矫正后的图像。
4. sort_contours – 轮廓排序
def sort_contours(cnts, method='left-to-right'):reverse = Falsei = 0if method == 'right-to-left' or method == 'bottom-to-top':reverse = Trueif method == 'top-to-bottom' or method == 'bottom-to-top':i = 1boundingBoxes = [cv2.boundingRect(c) for c in cnts](cnts, boundingBoxes) = zip(*sorted(zip(cnts, boundingBoxes),key=lambda b: b[1][i],reverse=reverse))return cnts, boundingBoxes
作用:
对轮廓按照 左右或上下顺序 排列,保证题目和选项的顺序一致。
i=0
→ 按x
坐标排序 (left-to-right)i=1
→ 按y
坐标排序 (top-to-bottom)
返回值是排序后的轮廓和它们的外接矩形信息。
5. cv_show – 显示图像
def cv_show(name, img):cv2.imshow(name, img)cv2.waitKey(0)
封装了
cv2.imshow
和cv2.waitKey
,用于调试时查看每个阶段的图像效果。便于逐步验证每个处理步骤的结果。
要不要我帮你把这部分工具函数和后续的预处理、识别、打分流程整合在一起,写成一个可以直接运行的完整脚本?这样你可以直接跑通从图片到得分的全流程。
二、图像预处理
# 预处理
image = cv2.imread("./images/test_01.png")
contours_img = image.copy()
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
cv_show('blurred', blurred)
edged = cv2.Canny(blurred, 75, 200)
cv_show('edged', edged)
关键步骤:
灰度化:将彩色图像转为灰度图,降低维度,减少噪声干扰。
高斯模糊:平滑图像,去除高频噪声,否则后续边缘检测会产生虚假边缘。
Canny边缘检测:提取出答题卡外轮廓,为后续轮廓检测打下基础。
三、答题卡定位与透视变换
答题卡可能存在拍摄倾斜,需要通过轮廓检测找到卡片边界并执行透视变换。
# 轮廓检测
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3)
cv_show('contours_img', contours_img)docCnt = None
# 根据轮廓大小进行排序,准备透视变换
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
for c in cnts: # 遍历每一个轮廓peri = cv2.arcLength(c, True)approx = cv2.approxPolyDP(c, 0.02 * peri, True) # 轮廓近似if len(approx) == 4:docCnt = approxbreak# 执行透视变换
warped_t = four_point_transform(image, docCnt.reshape(4, 2))
warped_new = warped_t.copy()
cv_show('warped', warped_t)
原理解析:
轮廓检测:通过
cv2.findContours
找出所有外部轮廓。面积排序:假设答题卡是图像中最大的矩形物体。
多边形逼近:用
approxPolyDP
将轮廓近似为多边形,只保留四个点的轮廓。透视变换:利用四点坐标执行投影变换,得到俯视图,避免倾斜影响。
四、二值化处理
warped = cv2.cvtColor(warped_t, cv2.COLOR_BGR2GRAY)# 阈值处理
thresh = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
cv_show('thresh', thresh)
thresh_Contours = thresh.copy()
核心思想:
将图像转为黑白二值图,背景变白,涂黑的选项变黑。
使用
cv2.THRESH_BINARY_INV
反转颜色,方便后续统计像素点。
五、轮廓提取与筛选
# 找到每一个圆圈轮廓
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
warped_Contours = cv2.drawContours(warped_t, cnts, -1, (0, 255, 0), 1)
cv_show('warped_Contours', warped_Contours)questionCnts = []
for c in cnts: # 遍历轮廓并计算比例和大小(x, y, w, h) = cv2.boundingRect(c)ar = w / float(h)# 根据实际情况指定标准if w >= 20 and h >= 20 and 0.9 <= ar <= 1.1:questionCnts.append(c)
print(len(questionCnts))
关键点:
我们只保留大小合理、接近正方形的轮廓,以排除杂点。
通过宽高比(aspect ratio)筛选圆形/方形的选项框。
六、按顺序排序选项
questionCnts = sort_contours(questionCnts, method="top-to-bottom")[0]
先按纵向排序,保证从第一题到最后一题。
每道题再按横向排序,保证选项顺序与答题卡一致。
七、识别涂黑的选项
原理:
掩膜(mask):只保留当前选项的像素区域。
统计黑色像素数量:涂得越满,非零像素数越大。
选择涂得最满的选项:作为该题的最终答案。
八、与答案比对并计算分数
绿色标记正确选项,红色标记错误选项。
根据题目数量计算总分。
# 按照从上到下进行排序
questionCnts = sort_contours(questionCnts, method="top-to-bottom")[0]
correct = 0
# 每排有5个选项
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):cnts = sort_contours(questionCnts[i:i + 5])[0] # 排序bubbled = None# 遍历每一个结果for (j, c) in enumerate(cnts):# 使用mask来判断结果mask = np.zeros(thresh.shape, dtype="uint8")cv2.drawContours(mask, [c], -1, color=255, thickness=-1) # -1表示填充cv_show('mask', mask)# 通过计算非零点数量来算是否选择这个答案# 利用掩膜(mask)进行“与”操作,只保留mask位置中的内容thresh_mask_and = cv2.bitwise_and(thresh, thresh, mask=mask)cv_show('thresh_mask_and', thresh_mask_and)total = cv2.countNonZero(thresh_mask_and) # 统计灰度值不为0的像素数if bubbled is None or total > bubbled[0]: # 通过阈值判断,保存灰度值最大的序号bubbled = (total, j)# 对比正确答案color = (0, 0, 255)k = ANSWER_KEY[q]if k == bubbled[1]: # 判断正确color = (0, 255, 0)correct += 1cv2.drawContours(warped_new, [cnts[k]], -1, color, thickness=3) # 绘图cv_show('warpeding', warped_new)
score = (correct / 5.0) * 100
九、结果展示
最后将分数写在图像上,显示原图与判分结果。
print("[INFO] score: {:.2f}%".format(score))
cv2.putText(warped_new, "{:.2f}%".format(score), org=(10, 30),fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=0.9, color=(0, 0, 255), thickness=2)
cv2.imshow("Original", mat=image)
cv2.imshow("Exam", mat=warped_new)
cv2.waitKey(0)
运行结果:
十、总结
这套答题卡打分系统的核心优势:
通用性强:可处理拍照角度不同、大小不同的答题卡。
鲁棒性高:通过轮廓筛选和涂黑面积统计,能抵抗一定程度的噪声。
易扩展:可以轻松修改
ANSWER_KEY
,支持更多题目和多种题型。
未来可进一步优化:
适配多行多列答题卡自动分区。
使用深度学习模型识别异常涂卡(多选、未选)。
增加UI和批量处理功能,支持学校实际批改场景。