计算机视觉——Opencv(答题卡识别并打分)
import cv2# 正确答案映射(题目索引:正确选项索引)
在考试、测评等场景中,答题卡的人工批改不仅效率低下,还容易出现人为误差。所以本文将手把手教你使用 Python+OpenCV 实现一套完整的答题卡自动识别与评分系统,无需深度学习,仅通过传统图像处理技术就能完成答题卡的轮廓检测、透视校正、答案识别和自动评分。
核心思路
-
图像预处理:灰度化、高斯模糊、边缘检测,突出答题卡轮廓
-
轮廓检测:定位答题卡的四边形外轮廓
-
透视变换:将倾斜 / 变形的答题卡校正为标准矩形
-
答题区域检测:识别所有答题圆圈并按题目行排序
-
答案识别:通过像素面积判断涂黑的选项
-
自动评分:对比标准答案统计得分并可视化标注
图片准备

定义工具函数
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 边缘检测

所有外轮廓

校正后的标准答题卡

二值化反相效果

答题区域圆圈轮廓

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

更多推荐



所有评论(0)