一、项目简介
答题卡自动识别是计算机视觉在教育场景中的经典应用,核心目标是基于扫描或拍摄的答题卡图像,自动识别考生填涂的选项、与标准答案对比,最终完成评分并统计正确率。该项目无需人工干预,可大幅提升阅卷效率,适用于考试、测验等大规模答题场景。
项目核心逻辑是 "先规范图像,再定位选项,最后判断填涂状态",融合了 OpenCV 图像处理(透视变换、二值化、轮廓检测)等关键技术,无需深度学习模型,仅通过传统图像处理即可实现高效识别,兼具易用性和实用性。
二、核心原理
项目整体流程可拆解为三大核心模块,从图像预处理到结果评分形成完整闭环,每一步都围绕 "精准提取填涂信息" 展开:
(一)模块 1:图像预处理与透视变换
核心目标
将倾斜、变形的答题卡图像(如手机拍摄的倾斜照片)矫正为正射投影图像(类似扫描件的正视图),消除视角干扰,为后续选项定位奠定基础。

实现步骤
-
灰度化与降噪 :将彩色答题卡图像转为灰度图,通过高斯滤波(
cv2.GaussianBlur())去除图像噪声,避免噪声影响边缘检测精度。 -
边缘检测 :使用 Canny 边缘检测算法(
cv2.Canny())提取图像轮廓,突出答题卡的外边界,忽略内部文字、选项等细节干扰。 -
轮廓检测与筛选 :通过
cv2.findContours()检测图像中所有轮廓,根据轮廓面积排序,筛选出面积最大的轮廓(即答题卡的外边界轮廓)。 -
多边形近似与顶点提取 :对筛选出的外边界轮廓进行多边形近似(
cv2.approxPolyDP()),提取轮廓的四个顶点(答题卡的四个角点)。 -
透视变换:
-
定义目标坐标:将矫正后的答题卡设置为规则矩形,目标顶点坐标为
[(0,0), (width,0), (width,height), (0,height)](width 和 height 为预设的答题卡尺寸)。 -
计算变换矩阵:通过
cv2.getPerspectiveTransform()根据原始顶点和目标顶点,计算 3×3 的透视变换矩阵 M。 -
执行变换:使用
cv2.warpPerspective()对原始图像执行透视变换,得到矫正后的正射投影图像。
-
(二)模块 2:选项定位与轮廓筛选
核心目标
从矫正后的答题卡中,精准定位每道题的所有选项(如 A、B、C、D、E 对应的圆形框),剔除文字、二维码等干扰项。
实现步骤
-
二值化 处理 :对矫正后的图像执行自适应二值化(
cv2.adaptiveThreshold()),将图像转为黑白二值图 ------ 填涂区域为白色(像素值 255),未填涂区域、背景为黑色(像素值 0)。 -
选项轮廓检测 :再次使用
cv2.findContours()检测二值图中的所有轮廓,这些轮廓包含选项框、文字、干扰痕迹等。 -
有效轮廓筛选:根据选项的形态特征筛选有效轮廓(以圆形选项为例):
-
大小筛选:计算轮廓的外接矩形(
cv2.boundingRect()),筛选宽(W)和高(H)均大于 20 像素(可根据实际图像调整)的轮廓,剔除过小的干扰痕迹。 -
形状筛选:圆形的外接矩形宽高比接近 1,因此筛选
0.9 ≤ W/H ≤ 1.1的轮廓,剔除矩形、不规则形状等干扰项。
-
-
轮廓排序:
-
纵向排序(按题目顺序):根据轮廓的纵坐标(Y 坐标)从上到下排序,确保轮廓按题目顺序(第 1 题、第 2 题、...)排列。
-
横向排序(按选项顺序):对每道题的多个选项轮廓,按横坐标(X 坐标)从左到右排序,确保轮廓按 A、B、C、D、E 的顺序排列。
-
(三)模块 3:填涂状态判断与评分
核心目标
判断每道题中哪个选项被填涂,与标准答案对比后计算得分。
实现步骤
-
掩码生成与区域提取:
-
对每道题的每个选项轮廓,生成与答题卡尺寸相同的空白掩码(
np.zeros_like())。 -
使用
cv2.drawContours()将当前选项轮廓绘制到掩码中(填充为白色),得到仅包含当前选项区域的掩码。
-
-
填涂状态判断:
-
对二值化图像和当前选项掩码执行 "与操作"(
cv2.bitwise_and()),提取该选项区域的图像。 -
计算提取区域中非零像素(白色像素)的数量:填涂选项的非零像素数量远大于未填涂选项,因此非零像素数量最大的选项即为考生填涂的答案。
-
-
答案对比与评分:
-
预设标准答案字典(如
answers = {0:1, 1:4, 2:0},键为题目索引,值为选项索引,0=A、1=B、2=C、3=D、4=E)。 -
对比考生答案与标准答案,统计正确题数、错误题数,计算正确率(得分 = 正确题数 / 总题数 × 满分)。
-
-
结果可视化:在矫正后的图像上,用不同颜色标注考生答案(如绿色框标注正确答案,红色框标注错误答案),并显示总分、正确率等信息。
三、代码实践
滤波

边缘检测

透视变换

二值处理

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

正确答案比较

代码
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)