目录

核心思路

分步实现与代码解析

1. 环境准备与工具函数定义

2. 图片预处理

3. 轮廓提取与筛选

3. 轮廓提取与筛选

4. 透视变换(矫正倾斜答题卡)

5. 阈值处理(突出填涂区域)

6. 提取选项圆圈轮廓

7. 选项轮廓排序(按题目顺序排列)


在日常教学与考试场景中,人工批改答题卡不仅耗时耗力,还容易因主观疲劳导致误判。本篇将基于 OpenCV 实现全自动答题卡识别与改分,通过图像处理技术精准提取答案区域、比对标准答案,并自动计算得分,大幅提升批改效率与准确性。

核心思路

答题卡识别改分的核心是 “从图像中精准定位有效信息并与标准对比”,整体流程分为 9 个关键步骤:

  1. 图片预处理(去噪、增强)
  2. 边缘检测(突出答题卡轮廓)
  3. 轮廓提取与筛选(定位答题卡主体)
  4. 轮廓近似(确定答题卡四角,为透视变换做准备)
  5. 透视变换(将倾斜答题卡矫正为正视图)
  6. 阈值处理(将图像转为 “非黑即白”,突出填涂区域)
  7. 选项圆圈轮廓提取(定位每道题的 5 个选项)
  8. 答案比对(识别填涂选项,与标准答案匹配)
  9. 分数计算(统计正确率,生成最终得分)

分步实现与代码解析

项目答题卡如下:

1. 环境准备与工具函数定义

首先导入所需库,并定义图像显示函数(方便中间结果查看):

import cv2
import numpy as np

# 图像显示函数:接收窗口名和图像,按任意键关闭窗口
def cv_show(name, img):
    cv2.imshow(name, img)
    cv2.waitKey(0)  # 等待按键输入(0表示无限等待)
    cv2.destroyWindow(name)  # 关闭当前窗口

2. 图片预处理

原始图像可能存在噪声、色彩干扰,预处理的目标是 “简化图像信息,突出关键边缘”,步骤包括:

  • 灰度化:将彩色图像转为单通道灰度图,减少计算量
  • 高斯滤波:通过平滑处理去除高频噪声(如纸张纹理、拍摄噪点)
  • 边缘检测:用 Canny 算法提取图像边缘,为后续轮廓定位做准备
"""-----1. 图片预处理-----"""
# 读取答题卡图像(替换为你的图像路径)
image = cv2.imread(r'./images/answer_sheet_01.jpg')
contours_img = image.copy()  # 备份原始图像,用于后续绘制轮廓

# 1.1 灰度化
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)  # BGR转灰度(OpenCV默认读取格式为BGR)
# 1.2 高斯滤波(5x5卷积核,标准差0,平衡去噪与边缘保留)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
cv_show('Gaussian Blur (去噪后)', blurred)  # 查看去噪效果
# 1.3 Canny边缘检测(阈值1=75,阈值2=200,仅保留对比度高的边缘)
edged = cv2.Canny(blurred, 75, 200)
cv_show('Canny Edges (边缘检测结果)', edged)  # 查看边缘检测效果

3. 轮廓提取与筛选

边缘检测后,需要从边缘图中提取闭合轮廓,并筛选出 “答题卡主体轮廓”(通常为矩形,即 4 个顶点):

"""-----2. 轮廓提取与筛选-----"""
# 2.1 提取轮廓(RETR_EXTERNAL:仅保留最外层轮廓;CHAIN_APPROX_SIMPLE:简化轮廓点)
# OpenCV 3.x返回值为 (_, cnts, _),OpenCV 4.x返回值为 (cnts, _),此处兼容3.x
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 2.2 绘制所有轮廓(红色,线宽3),查看轮廓提取效果
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3)
cv_show('All Contours (所有轮廓)', contours_img)

# 2.3 筛选答题卡轮廓(按面积降序排序,优先保留大面积轮廓)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
doc_cnt = None  # 存储答题卡的最终轮廓

for c in cnts:
    # 计算轮廓周长(True表示轮廓闭合)
    peri = cv2.arcLength(c, True)
    # 轮廓近似(0.02*peri:近似精度,值越小越接近原始轮廓)
    approx = cv2.approxPolyDP(c, 0.02 * peri, True)
    
    # 答题卡为矩形,近似后轮廓应包含4个顶点
    if len(approx) == 4:
        doc_cnt = approx
        break

# 绘制筛选出的答题卡轮廓(绿色,线宽2)
cv2.drawContours(image, [doc_cnt], -1, (0, 255, 0), 2)
cv_show('Answer Sheet Contour (答题卡轮廓)', image)

轮廓入戏:

3. 轮廓提取与筛选

边缘检测后,需要从边缘图中提取闭合轮廓,并筛选出 “答题卡主体轮廓”(通常为矩形,即 4 个顶点):

"""-----2. 轮廓提取与筛选-----"""
# 2.1 提取轮廓(RETR_EXTERNAL:仅保留最外层轮廓;CHAIN_APPROX_SIMPLE:简化轮廓点)
# OpenCV 3.x返回值为 (_, cnts, _),OpenCV 4.x返回值为 (cnts, _),此处兼容3.x
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 2.2 绘制所有轮廓(红色,线宽3),查看轮廓提取效果
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3)
cv_show('All Contours (所有轮廓)', contours_img)

# 2.3 筛选答题卡轮廓(按面积降序排序,优先保留大面积轮廓)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
doc_cnt = None  # 存储答题卡的最终轮廓

for c in cnts:
    # 计算轮廓周长(True表示轮廓闭合)
    peri = cv2.arcLength(c, True)
    # 轮廓近似(0.02*peri:近似精度,值越小越接近原始轮廓)
    approx = cv2.approxPolyDP(c, 0.02 * peri, True)
    
    # 答题卡为矩形,近似后轮廓应包含4个顶点
    if len(approx) == 4:
        doc_cnt = approx
        break

# 绘制筛选出的答题卡轮廓(绿色,线宽2)
cv2.drawContours(image, [doc_cnt], -1, (0, 255, 0), 2)
cv_show('Answer Sheet Contour (答题卡轮廓)', image)

4. 透视变换(矫正倾斜答题卡)

实际拍摄的答题卡可能存在倾斜,透视变换可将 “倾斜的矩形” 转为 “正对着镜头的矩形”,方便后续选项定位:

  • 第一步:定义order_points函数,将 4 个顶点按 “左上→右上→右下→左下” 排序
  • 第二步:定义four_point_transform函数,计算透视变换矩阵并应用变换
"""-----3. 透视变换(矫正答题卡)-----"""
def order_points(pts):
    """将4个顶点按“左上(tl)→右上(tr)→右下(br)→左下(bl)”排序"""
    rect = np.zeros((4, 2), dtype="float32")  # 初始化排序后的坐标
    
    # 1. 左上点:x+y最小;右下点:x+y最大
    s = pts.sum(axis=1)  # 每个点的x+y求和
    rect[0] = pts[np.argmin(s)]  # 左上(tl)
    rect[2] = pts[np.argmax(s)]  # 右下(br)
    
    # 2. 右上点:x-y最小;左下点:x-y最大
    diff = np.diff(pts, axis=1)  # 每个点的x-y差值(axis=1:按行计算后一列减前一列)
    rect[1] = pts[np.argmin(diff)]  # 右上(tr)
    rect[3] = pts[np.argmax(diff)]  # 左下(bl)
    
    return rect

def four_point_transform(image, pts):
    """透视变换:将倾斜的答题卡转为正视图"""
    # 步骤1:获取排序后的4个顶点
    rect = order_points(pts)
    tl, tr, br, bl = rect  # 解包顶点坐标
    
    # 步骤2:计算目标图像的宽度和高度(取最大值确保覆盖完整答题卡)
    # 宽度:右下→左下 的水平距离 / 右上→左上 的水平距离
    width_a = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))
    width_b = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))
    max_width = max(int(width_a), int(width_b))  # 目标宽度
    
    # 高度:右上→右下 的垂直距离 / 左上→左下 的垂直距离
    height_a = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))
    height_b = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))
    max_height = max(int(height_a), int(height_b))  # 目标高度
    
    # 步骤3:定义目标图像的4个顶点(正视图的四个角)
    dst = np.array([
        [0, 0],                  # 左上
        [max_width - 1, 0],      # 右上(-1是因为像素索引从0开始)
        [max_width - 1, max_height - 1],  # 右下
        [0, max_height - 1]      # 左下
    ], dtype="float32")
    
    # 步骤4:计算透视变换矩阵M,应用变换得到正视图
    M = cv2.getPerspectiveTransform(rect, dst)  # 生成3x3变换矩阵
    warped = cv2.warpPerspective(image, M, (max_width, max_height))  # 执行变换
    
    return warped

# 执行透视变换(doc_cnt是答题卡的4个顶点,需转为float32格式)
warped = four_point_transform(image, doc_cnt.reshape(4, 2))
cv_show('Warped Sheet (矫正后答题卡)', warped)

5. 阈值处理(突出填涂区域)

矫正后的答题卡仍为灰度图,通过 “阈值二值化” 将图像转为 “非黑即白”,让填涂的选项(深色)与空白选项(白色)对比更强烈:

"""-----4. 阈值处理(突出填涂区域)-----"""
# 5.1 将矫正后的彩色图转为灰度图
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
# 5.2 二值化(THRESH_BINARY_INV:黑白反转;THRESH_OTSU:自动计算最佳阈值)
# 效果:填涂区域为白色(255),空白区域为黑色(0)
thresh = cv2.threshold(warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
cv_show('Thresholded (二值化结果)', thresh)

# 备份二值化图像,用于后续绘制选项轮廓
thresh_copy = thresh.copy()

6. 提取选项圆圈轮廓

答题卡每道题包含 5 个圆形选项,需从二值化图中提取这些圆圈轮廓,并筛选出 “符合选项大小” 的轮廓:

"""-----5. 提取选项圆圈轮廓-----"""
# 6.1 提取二值化图中的所有轮廓(仅保留最外层轮廓)
option_cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 6.2 绘制所有轮廓(绿色,线宽1),查看选项定位效果
warped_with_options = cv2.drawContours(warped.copy(), option_cnts, -1, (0, 255, 0), 1)
cv_show('All Option Contours (所有选项轮廓)', warped_with_options)

# 6.3 筛选“有效选项轮廓”(排除过小/过扁的干扰轮廓)
valid_option_cnts = []
for c in option_cnts:
    # 获取轮廓的边界矩形(x:左上角x坐标,y:左上角y坐标,w:宽度,h:高度)
    x, y, w, h = cv2.boundingRect(c)
    # 计算宽高比(圆形的宽高比接近1)
    aspect_ratio = w / float(h)
    
    # 筛选条件:宽度≥20px、高度≥20px、宽高比0.9~1.1(接近圆形)
    if w >= 20 and h >= 20 and aspect_ratio >= 0.9 and aspect_ratio <= 1.1:
        valid_option_cnts.append(c)

# 绘制筛选后的有效选项轮廓(红色,线宽1)
warped_valid_options = cv2.drawContours(warped.copy(), valid_option_cnts, -1, (0, 0, 255), 1)
cv_show('Valid Option Contours (有效选项轮廓)', warped_valid_options)

               

7. 选项轮廓排序(按题目顺序排列)

提取的选项轮廓可能杂乱无章,需按 “从上到下、从左到右” 排序,确保与题目顺序对应:

"""-----6. 选项轮廓排序(按题目顺序)-----"""
def sort_contours(cnts, method='left-to-right'):
    """按指定方向排序轮廓:left-to-right(左右)、top-to-bottom(上下)"""
    reverse = False  # 是否反向排序
    axis = 0         # 排序依据的轴(0:x轴,1:y轴)
    
    # 1. 确定排序方向和轴
    if method in ['right-to-left', 'bottom-to-top']:
        reverse = True  # 反向排序(从右到左/从下到上)
    if method in ['top-to-bottom', 'bottom-to-top']:
        axis = 1  # 按y轴排序(上下方向)
    
    # 2. 为每个轮廓创建“边界矩形”,按矩形的x/y轴坐标排序
    bounding_boxes = [cv2.boundingRect(c) for c in cnts]  # 每个轮廓的边界矩形
    # 按“边界矩形的指定轴”排序(zip:将轮廓与矩形绑定;sorted:按轴排序)
    cnts, bounding_boxes = zip(*sorted(
        zip(cnts, bounding_boxes),
        key=lambda b: b[1][axis],  # 排序键:矩形的axis轴坐标(x或y)
        reverse=reverse
    ))
    
    return cnts, bounding_boxes

# 7.1 先按“从上到下”排序(每道题的5个选项为一组)
sorted_option_cnts, _ = sort_contours(valid_option_cnts, method='top-to-bottom')
# 7.2 绘制排序后的轮廓(蓝色,线宽1)
warped_sorted_options = cv2.drawContours(warped.copy(), sorted_option_cnts, -1, (255, 0, 0), 1)
cv_show('Sorted Option Contours (排序后选项轮廓)', warped_sorted_options)

8. 识别填涂答案与标准答案比对
核心逻辑:通过 “掩膜 + 像素计数” 识别每道题的填涂选项,再与标准答案对比,标记对错:
• 掩膜(mask):为每个选项创建 “仅包含该选项的黑白图”
• 像素计数:填涂选项的白色像素(255)数量远多于空白选项,以此定位填涂位置

"""-----7. 答案识别与比对-----"""
# 8.1 定义标准答案(键:题序号0~4,值:正确选项序号0~4,对应每道题的5个选项)
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}
correct_count = 0  # 正确题数
warped_result = warped.copy()  # 用于绘制结果的图像

# 8.2 按“每5个选项一组”遍历(每道题对应5个选项)
for (question_idx, i) in enumerate(np.arange(0, len(sorted_option_cnts), 5)):
    # 取当前题的5个选项轮廓,按“从左到右”排序
    current_question_cnts, _ = sort_contours(sorted_option_cnts[i:i+5], method='left-to-right')
    bubbled_idx = None  # 存储当前题的填涂选项序号(0~4)
    
    # 遍历当前题的5个选项,找到填涂的选项
    for (option_idx, c) in enumerate(current_question_cnts):
        # 步骤1:为当前选项创建掩膜(仅该选项区域为白色,其余为黑色)
        mask = np.zeros(thresh.shape, dtype="uint8")
        cv2.drawContours(mask, [c], -1, 255, -1)  # -1表示填充轮廓内部
        # cv_show(f'Option {option_idx} Mask (选项{option_idx}掩膜)', mask)
        
        # 步骤2:掩膜与二值化图做“与运算”,仅保留当前选项的填涂区域
        masked_thresh = cv2.bitwise_and(thresh, thresh, mask=mask)
        # cv_show(f'Masked Thresh (选项{option_idx}与运算结果)', masked_thresh)
        
        # 步骤3:计算白色像素数量(填涂区域的像素数)
        white_pixel_count = cv2.countNonZero(masked_thresh)
        
        # 步骤4:确定填涂选项(白色像素最多的选项即为填涂选项)
        if bubbled_idx is None or white_pixel_count > bubbled_idx[0]:
            bubbled_idx = (white_pixel_count, option_idx)  # (像素数, 选项序号)
    
    # 8.3 与标准答案比对,标记对错
    correct_option_idx = ANSWER_KEY[question_idx]  # 当前题的正确选项序号
    if bubbled_idx[1] == correct_option_idx:
        # 正确:绿色轮廓(线宽2),正确题数+1
        color = (0, 255, 0)
        correct_count += 1
    else:
        # 错误:红色轮廓(线宽2)
        color = (0, 0, 255)
    
    # 绘制当前题的正确选项轮廓(标记对错)
    cv2.drawContours(warped_result, [current_question_cnts[correct_option_idx]], -1,

最终结果如下:

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐