扫描答题卡打分系统原理与实现

答题卡自动阅卷是计算机视觉在教育领域的经典应用。本文将详细介绍如何使用 OpenCVNumPy 从扫描的答题卡图像中自动识别作答选项、比对标准答案并计算得分。

整体流程

整个答题卡打分系统可以拆分为以下环节:

  1. 图像预处理 – 灰度化、去噪、边缘检测。

  2. 答题卡定位与透视变换 – 提取答题卡轮廓,将倾斜的图像矫正。

  3. 二值化处理 – 将答题区域转为黑白图,便于检测圆圈。

  4. 轮廓提取与筛选 – 找出所有可能的选项圆圈。

  5. 按顺序排序选项 – 保证每一题的选项从左到右。

  6. 识别涂黑的选项 – 利用掩膜统计涂黑面积。

  7. 与答案比对、计算得分 – 给出总分并标记正确/错误选项。

下面我们逐步剖析代码和原理。


你给出的代码是答题卡自动打分的核心工具函数部分,负责几项关键操作:

  1. 四点排序 + 透视变换

  2. 轮廓排序

  3. 图像展示

我来帮你逐行解析一下,让你完全理解每个函数的作用和细节。


图片准备:

一、函数准备

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) = 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

作用:
根据答题卡四个顶点的位置,将倾斜的答题卡矫正为正视图,方便后续处理。

关键步骤:

  1. 计算目标宽度、高度:

    • widthA:下边长度

    • widthB:上边长度

    • maxWidth:取两者最大值,避免变形

    • heightAheightB:左右边长度,取最大值作为目标高度

  2. 构建目标坐标:

    • 左上角 → (0,0)

    • 右上角 → (maxWidth-1,0)

    • 右下角 → (maxWidth-1,maxHeight-1)

    • 左下角 → (0,maxHeight-1)

  3. 计算透视矩阵
    cv2.getPerspectiveTransform 得到变换矩阵 M

  4. 执行透视变换
    cv2.warpPerspective 得到矫正后的图像。


4. sort_contours – 轮廓排序

def sort_contours(cnts, method='left-to-right'):
    reverse = False
    i = 0
    if method == 'right-to-left' or method == 'bottom-to-top':
        reverse = True
    if method == 'top-to-bottom' or method == 'bottom-to-top':
        i = 1
    boundingBoxes = [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.imshowcv2.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 = approx
        break

# 执行透视变换
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 += 1
    cv2.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和批量处理功能,支持学校实际批改场景。

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐