在考试、测评等场景中,答题卡的人工批改不仅效率低下,还容易出现人为误差。所以本文将手把手教你使用 Python+OpenCV 实现一套完整的答题卡自动识别与评分系统,无需深度学习,仅通过传统图像处理技术就能完成答题卡的轮廓检测、透视校正、答案识别和自动评分。

核心思路

  1. 图像预处理:灰度化、高斯模糊、边缘检测,突出答题卡轮廓

  2. 轮廓检测:定位答题卡的四边形外轮廓

  3. 透视变换:将倾斜 / 变形的答题卡校正为标准矩形

  4. 答题区域检测:识别所有答题圆圈并按题目行排序

  5. 答案识别:通过像素面积判断涂黑的选项

  6. 自动评分:对比标准答案统计得分并可视化标注

图片准备

定义工具函数

1.导入相关库和正确的答案映射

import numpy as np
import cv2

# 正确答案映射(题目索引:正确选项索引)
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}

2.对四边形轮廓的四个点进行排序

透视变换必须保证四个点顺序固定:左上 → 右上 → 右下 → 左下

def order_points(pts):
    # 初始化4个点的坐标数组
    rect = np.zeros((4, 2), dtype="float32")

    # 按x+y的和排序:和最小的是左上,和最大的是右下
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]  # 左上
    rect[2] = pts[np.argmax(s)]  # 右下

    # 按x-y的差排序:差最小的是右上,差最大的是左下
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]  # 右上
    rect[3] = pts[np.argmax(diff)]  # 左下

    return rect

原理:

  • x+y 最小的是左上点,因为它最靠近坐标原点。

  • x+y 最大的是右下点,因为它离原点最远。

  • y-x 最小的是右上点(因为 y 比 x 小)。

  • y-x 最大的是左下点。

3.四点透视变换

将不规则四边形校正为矩形

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

4.按指定方向排序轮廓

默认从左到右

def sort_contours(cnts, method="left-to-right"):
    reverse = False
    i = 0

    # 如果是从右到左/从下到上,反转排序结果
    if method == "right-to-left" or method == "bottom-to-top":
        reverse = True
    # 如果是垂直方向排序(从上到下/从下到上),按y坐标排序
    if method == "top-to-bottom" or method == "bottom-to-top":
        i = 1

    # 计算每个轮廓的外接矩形,按x/y坐标排序
    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

5.图像显示辅助函数

简化imshow+waitKey+destroyWindow

def cv_show(name, img):
    cv2.imshow(name, img)
    cv2.waitKey(0)  # 按下任意键关闭窗口
    cv2.destroyWindow(name)

主流程

1.读取图像与预处理

image = cv2.imread(r"C:\Users\LEGION\Desktop\test_01.png")
if image is None:
    print("错误:未找到图片,请检查路径是否正确!")
    exit()

# 备份原始图像用于绘制轮廓
contours_img = image.copy()
# 转为灰度图
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 高斯模糊去噪
blurred = cv2.GaussianBlur(gray, ksize=(5, 5), sigmaX=0)
cv_show(name='1. 高斯模糊后', img=blurred)
  • 转为灰度图:减少颜色通道,降低计算量

  • 高斯模糊:去除纸张纹理、噪点,让后续边缘更干净

2.Canny 边缘检测

# Canny边缘检测
edged = cv2.Canny(blurred, threshold1=75, threshold2=200)
cv_show(name='2. Canny边缘检测后', img=edged)

Canny 算子可以精准提取答题卡的外边框,过滤内部无关信息。此时图像中只有清晰的边缘线条。

3.轮廓检测

# 轮廓检测(只检测最外层轮廓)
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
# 绘制所有检测到的轮廓(红色)
cv2.drawContours(contours_img, cnts, -1, color=(0, 0, 255), thickness=3)
cv_show(name='3. 检测到的所有轮廓', img=contours_img)
docCnt = None  # 存储答题卡的四边形轮廓

# 按轮廓面积从大到小排序,找到答题卡的外轮廓
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
for c in cnts:
    # 计算轮廓周长
    peri = cv2.arcLength(c, closed=True)
    # 轮廓近似(减少轮廓点数量)
    approx = cv2.approxPolyDP(c, 0.02 * peri, closed=True)
    # 如果近似后是4个点,判定为答题卡外轮廓
    if len(approx) == 4:
        docCnt = approx
        break

4.进行透视变换

# 执行透视变换,校正答题卡为正矩形
warped_t = four_point_transform(image, docCnt.reshape(4, 2))
warped_new = warped_t.copy()
cv_show(name='4. 透视校正后的答题卡', img=warped_t)

# 转为灰度图用于后续阈值处理
warped = cv2.cvtColor(warped_t, cv2.COLOR_BGR2GRAY)

这是最重要的一步。无论原图如何倾斜、变形,经过这一步后,答题卡都会变成俯视、方正、标准的矩形。这是后续识别准确率的核心保障。

5.二值化处理

# 二值化处理(反相+OTSU自动阈值)
thresh = cv2.threshold(warped, thresh=0, maxval=255,
                       type=cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
cv_show(name='5. 二值化后', img=thresh)
thresh_Contours = thresh.copy()

使用反相二值化 + OTSU 自动阈值

  • 涂黑的选项 → 白色

  • 背景纸张 → 黑色让计算机更容易识别 “哪个选项被填涂”。

6.识别答题卡圆圈并计算分数

# 检测所有答题区域的圆圈轮廓
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
# 绘制答题区域轮廓(绿色)
warped_Contours = cv2.drawContours(warped_t, cnts, -1, color=(0, 255, 0), thickness=1)
cv_show(name='6. 答题区域轮廓', img=warped_Contours)

# 筛选出符合条件的答题圆圈(圆形,尺寸符合)
questionCnts = []
for c in cnts:
    (x, y, w, h) = cv2.boundingRect(c)
    ar = w / float(h)
    # 筛选条件:宽高≥20像素,宽高比接近1(圆形)
    if w >= 20 and h >= 20 and 0.9 <= ar <= 1.1:
        questionCnts.append(c)

# 输出筛选后的答题圆圈数量
print(f"检测到的答题圆圈数量:{len(questionCnts)}")

# (可选)按行排序答题区域,统计得分
if len(questionCnts) > 0:
    # 按从上到下排序所有答题行
    questionCnts, _ = sort_contours(questionCnts, method="top-to-bottom")
    correct = 0  # 正确答题数

    # 每5个圆圈为一行(对应一道题的5个选项)
    for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
        # 对当前题的5个选项按从左到右排序
        cnts, _ = sort_contours(questionCnts[i:i + 5])
        bubbled = None  # 存储被选中的选项

        # 遍历每个选项,通过像素面积判断是否被涂黑
        for (j, c) in enumerate(cnts):
            # 计算轮廓的掩膜面积(涂黑区域的像素数)
            mask = np.zeros(thresh.shape, dtype="uint8")
            cv2.drawContours(mask, [c], -1, 255, -1)
            # 计算掩膜内的非零像素数
            total = cv2.countNonZero(cv2.bitwise_and(thresh, thresh, mask=mask))

            # 找到像素数最多的选项(被涂黑的选项)
            if bubbled is None or total > bubbled[0]:
                bubbled = (total, j)

        # 对比正确答案,统计得分
        color = (0, 0, 255)  # 错误:红色
        if ANSWER_KEY[q] == bubbled[1]:
            color = (0, 255, 0)  # 正确:绿色
            correct += 1

        # 在答题卡上绘制答题结果(绿色=正确,红色=错误)
        cv2.drawContours(warped_new, [cnts[bubbled[1]]], -1, color, 2)

    # 计算得分并显示
    score = (correct / len(ANSWER_KEY)) * 100
    print(f"答对题数:{correct}/{len(ANSWER_KEY)}")
    print(f"最终得分:{score:.2f}分")

    # 在图像上标注得分
    cv2.putText(warped_new, f"Score: {score:.2f}", (10, 30),
                cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
    cv_show(name='7. 答题结果标注', img=warped_new)

# 释放所有窗口资源
cv2.destroyAllWindows()

像素最多的圆圈,就是考生选的答案。这种方法简单、稳定、抗干扰。

对比标准答案ANSWER_KEY

  • 正确 → 绿色圈

  • 错误 → 红色圈最后计算得分并显示在图像上。

运行结果

运行代码后,会依次弹出 7 个窗口:

高斯模糊后图像:

Canny 边缘检测

所有外轮廓

校正后的标准答题卡

二值化反相效果

答题区域圆圈轮廓

最终标注结果(正确 / 错误 + 分数)

Logo

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

更多推荐