opencv总结9——答题卡识别

一、项目简介

答题卡自动识别是计算机视觉在教育场景中的经典应用,核心目标是基于扫描或拍摄的答题卡图像,自动识别考生填涂的选项、与标准答案对比,最终完成评分并统计正确率。该项目无需人工干预,可大幅提升阅卷效率,适用于考试、测验等大规模答题场景。

项目核心逻辑是 "先规范图像,再定位选项,最后判断填涂状态",融合了 OpenCV 图像处理(透视变换、二值化、轮廓检测)等关键技术,无需深度学习模型,仅通过传统图像处理即可实现高效识别,兼具易用性和实用性。

二、核心原理

项目整体流程可拆解为三大核心模块,从图像预处理到结果评分形成完整闭环,每一步都围绕 "精准提取填涂信息" 展开:

(一)模块 1:图像预处理与透视变换

核心目标

将倾斜、变形的答题卡图像(如手机拍摄的倾斜照片)矫正为正射投影图像(类似扫描件的正视图),消除视角干扰,为后续选项定位奠定基础。

实现步骤
  1. 灰度化与降噪 :将彩色答题卡图像转为灰度图,通过高斯滤波(cv2.GaussianBlur())去除图像噪声,避免噪声影响边缘检测精度。

  2. 边缘检测 :使用 Canny 边缘检测算法(cv2.Canny())提取图像轮廓,突出答题卡的外边界,忽略内部文字、选项等细节干扰。

  3. 轮廓检测与筛选 :通过cv2.findContours()检测图像中所有轮廓,根据轮廓面积排序,筛选出面积最大的轮廓(即答题卡的外边界轮廓)。

  4. 多边形近似与顶点提取 :对筛选出的外边界轮廓进行多边形近似(cv2.approxPolyDP()),提取轮廓的四个顶点(答题卡的四个角点)。

  5. 透视变换

    1. 定义目标坐标:将矫正后的答题卡设置为规则矩形,目标顶点坐标为[(0,0), (width,0), (width,height), (0,height)](width 和 height 为预设的答题卡尺寸)。

    2. 计算变换矩阵:通过cv2.getPerspectiveTransform()根据原始顶点和目标顶点,计算 3×3 的透视变换矩阵 M。

    3. 执行变换:使用cv2.warpPerspective()对原始图像执行透视变换,得到矫正后的正射投影图像。

(二)模块 2:选项定位与轮廓筛选

核心目标

从矫正后的答题卡中,精准定位每道题的所有选项(如 A、B、C、D、E 对应的圆形框),剔除文字、二维码等干扰项。

实现步骤
  1. 二值化 处理 :对矫正后的图像执行自适应二值化(cv2.adaptiveThreshold()),将图像转为黑白二值图 ------ 填涂区域为白色(像素值 255),未填涂区域、背景为黑色(像素值 0)。

  2. 选项轮廓检测 :再次使用cv2.findContours()检测二值图中的所有轮廓,这些轮廓包含选项框、文字、干扰痕迹等。

  3. 有效轮廓筛选:根据选项的形态特征筛选有效轮廓(以圆形选项为例):

    1. 大小筛选:计算轮廓的外接矩形(cv2.boundingRect()),筛选宽(W)和高(H)均大于 20 像素(可根据实际图像调整)的轮廓,剔除过小的干扰痕迹。

    2. 形状筛选:圆形的外接矩形宽高比接近 1,因此筛选0.9 ≤ W/H ≤ 1.1的轮廓,剔除矩形、不规则形状等干扰项。

  4. 轮廓排序

    1. 纵向排序(按题目顺序):根据轮廓的纵坐标(Y 坐标)从上到下排序,确保轮廓按题目顺序(第 1 题、第 2 题、...)排列。

    2. 横向排序(按选项顺序):对每道题的多个选项轮廓,按横坐标(X 坐标)从左到右排序,确保轮廓按 A、B、C、D、E 的顺序排列。

(三)模块 3:填涂状态判断与评分

核心目标

判断每道题中哪个选项被填涂,与标准答案对比后计算得分。

实现步骤
  1. 掩码生成与区域提取

    1. 对每道题的每个选项轮廓,生成与答题卡尺寸相同的空白掩码(np.zeros_like())。

    2. 使用cv2.drawContours()将当前选项轮廓绘制到掩码中(填充为白色),得到仅包含当前选项区域的掩码。

  2. 填涂状态判断

    1. 对二值化图像和当前选项掩码执行 "与操作"(cv2.bitwise_and()),提取该选项区域的图像。

    2. 计算提取区域中非零像素(白色像素)的数量:填涂选项的非零像素数量远大于未填涂选项,因此非零像素数量最大的选项即为考生填涂的答案。

  3. 答案对比与评分

    1. 预设标准答案字典(如answers = {0:1, 1:4, 2:0},键为题目索引,值为选项索引,0=A、1=B、2=C、3=D、4=E)。

    2. 对比考生答案与标准答案,统计正确题数、错误题数,计算正确率(得分 = 正确题数 / 总题数 × 满分)。

  4. 结果可视化:在矫正后的图像上,用不同颜色标注考生答案(如绿色框标注正确答案,红色框标注错误答案),并显示总分、正确率等信息。

三、代码实践

滤波

边缘检测

透视变换

二值处理

计算掩码(找到每个圆形的轮廓,霍夫变换实现圆形的轮廓检测的效果并不好)

正确答案比较

代码

python 复制代码
#导入工具包
import numpy as np
import argparse
import imutils
import cv2

# 设置参数
ap = argparse.ArgumentParser()
ap.add_argument("-i", "--image", required=True,
    help="path to the input image")
args = vars(ap.parse_args())

# 正确答案
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}

def order_points(pts):
    # 一共4个坐标点
    rect = np.zeros((4, 2), dtype = "float32")

    # 按顺序找到对应坐标0123分别是 左上,右上,右下,左下
    # 计算左上,右下
    s = pts.sum(axis = 1)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]

    # 计算右上和左下
    diff = np.diff(pts, axis = 1)
    rect[1] = pts[np.argmin(diff)]
    rect[3] = pts[np.argmax(diff)]

    return rect

def four_point_transform(image, pts):
    # 获取输入坐标点
    rect = order_points(pts)
    (tl, tr, br, bl) = rect

    # 计算输入的w和h值
    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
    
# 从上到下进行排序    
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
def cv_show(name,img):
        cv2.imshow(name, img)
        cv2.waitKey(0)
        cv2.destroyAllWindows()  

# 预处理
image = cv2.imread(args["image"])
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)

# 轮廓检测
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE)[1]  # [1]表示我只需要这个轮廓就行了
cv2.drawContours(contours_img,cnts,-1,(0,0,255),3) 
cv_show('contours_img',contours_img)
docCnt = None

# 确保检测到了
if len(cnts) > 0:
    # 根据轮廓大小进行排序
    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 = four_point_transform(gray, docCnt.reshape(4, 2))
cv_show('warped',warped)
# Otsu's 阈值处理
thresh = cv2.threshold(warped, 0, 255,
    cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] 
cv_show('thresh',thresh)
thresh_Contours = thresh.copy()
# 找到每一个圆圈轮廓
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE)[1]
cv2.drawContours(thresh_Contours,cnts,-1,(0,0,255),3) 
cv_show('thresh_Contours',thresh_Contours)
questionCnts = []

# 遍历
for c in cnts:
    # 计算比例和大小
    (x, y, w, h) = cv2.boundingRect(c)
    ar = w / float(h)

    # 根据实际情况指定标准
    if w >= 20 and h >= 20 and ar >= 0.9 and ar <= 1.1:
        questionCnts.append(c)

# 按照从上到下进行排序
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, 255, -1) #-1表示填充
        cv_show('mask',mask)
        # 通过计算非零点数量来算是否选择这个答案
        mask = cv2.bitwise_and(thresh, thresh, mask=mask)
        total = cv2.countNonZero(mask)

        # 通过阈值判断
        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, [cnts[k]], -1, color, 3)

score = (correct / 5.0) * 100
print("[INFO] score: {:.2f}%".format(score))
cv2.putText(warped, "{:.2f}%".format(score), (10, 30),
    cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
cv2.imshow("Original", image)
cv2.imshow("Exam", warped)
cv2.waitKey(0)
相关推荐
高洁018 小时前
10分钟了解向量数据库(3
人工智能·深度学习·机器学习·transformer·知识图谱
IvorySQL8 小时前
让源码安装不再困难:IvorySQL 一键安装脚本的实现细节解析
数据库·人工智能·postgresql·开源
民乐团扒谱机8 小时前
【微实验】数模美赛备赛MATLAB实战:一文速通各种“马尔可夫”(Markov Model)
开发语言·人工智能·笔记·matlab·数据挖掘·马尔科夫链·线性系统
MistaCloud9 小时前
Pytorch深入浅出(十三)之模型微调
人工智能·pytorch·python·深度学习
雨大王5129 小时前
工业AI大模型如何重塑汽车焊接与质检流程?
人工智能·汽车
MARS_AI_9 小时前
当AI客服开始“察言观色”:以云蝠智能为例,大模型如何定义呼叫
人工智能
AI小怪兽9 小时前
基于YOLO11的航空安保与异常无人机检测系统(Python源码+数据集+Pyside6界面)
开发语言·人工智能·python·yolo·计算机视觉·无人机
_codemonster9 小时前
计算机视觉入门到实战系列(二)认识各种卷积核
人工智能·计算机视觉
KG_LLM图谱增强大模型9 小时前
颠覆传统问诊:懂医生的主动式智能预问诊多智能体系统,开启医疗AI新范式
人工智能·知识图谱·智能体