答题卡识别改分项目
import cv2# 图像显示函数:接收窗口名和图像,按任意键关闭窗口cv2.waitKey(0) # 等待按键输入(0表示无限等待)cv2.destroyWindow(name) # 关闭当前窗口。
目录
在日常教学与考试场景中,人工批改答题卡不仅耗时耗力,还容易因主观疲劳导致误判。本篇将基于 OpenCV 实现全自动答题卡识别与改分,通过图像处理技术精准提取答案区域、比对标准答案,并自动计算得分,大幅提升批改效率与准确性。
核心思路
答题卡识别改分的核心是 “从图像中精准定位有效信息并与标准对比”,整体流程分为 9 个关键步骤:
- 图片预处理(去噪、增强)
- 边缘检测(突出答题卡轮廓)
- 轮廓提取与筛选(定位答题卡主体)
- 轮廓近似(确定答题卡四角,为透视变换做准备)
- 透视变换(将倾斜答题卡矫正为正视图)
- 阈值处理(将图像转为 “非黑即白”,突出填涂区域)
- 选项圆圈轮廓提取(定位每道题的 5 个选项)
- 答案比对(识别填涂选项,与标准答案匹配)
- 分数计算(统计正确率,生成最终得分)
分步实现与代码解析
项目答题卡如下:
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,
最终结果如下:
更多推荐
所有评论(0)