在我们的日常生活中有很多自动化阅卷的需求,比如我们的期中考试、期末考试、中考、高考、四级、六级等等。总而言之,现实场景中我们有很多的考试,但是每一次考完试后,焦虑的不仅仅是学生,还有老师!老师们需要夜以继日的去阅卷,尽快得出学生的考试成绩,这是一个费人费力但又是刚需的一个任务。庆幸的是当前这个任务基本上已经被自动化啦,本文的任务就是来理解这其中的奥秘!
百度的定义-网上阅卷全称是网上阅卷系统,是指以计算机网络技术和电子扫描技术为依托,实现客观题自动阅卷,主观题网上评卷的一种现代计算机系统。
这里面有几点需要进行说明:
正如文章开头所讲,现实的场景中我们有各种各样的考试,老师们需要进行大量的阅卷工作,因而解决这个问题具有重大的意义。
步骤1-检测图片中的答题卡;
步骤2-采取透视变换取得答题卡自上而下的俯视图.;
步骤3-从第二步结果中获取泡泡集(例如可能的答案选项);
步骤4-按行进行排序处理;
步骤5-确认每行用户填写的答案
步骤6-查找正确的答案,判断用户选择是否正确
步骤7-遍历所有的问题,重复以上的步骤。
# coding=utf-8
# 导入一些python包
from imutils.perspective import four_point_transform
from imutils import contours
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}
# 读取输入图片
image = cv2.imread(args["image"])
# 输入图片灰度化
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 进行高斯滤波
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
# 进行边缘检测处理
edged = cv2.Canny(blurred, 75, 200)
# 在边缘图像中发现轮廓
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
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)
# 如果我们在近似轮廓中发现4个点,表明我们发现了答题卡
if len(approx) == 4:
docCnt = approx
break
# 将变换关系应用到原始的输入图像和灰度图像中,从而获得一个答题卡的鸟瞰图
paper = four_point_transform(image, docCnt.reshape(4, 2))
warped = four_point_transform(gray, docCnt.reshape(4, 2))
# 在warped图像上面使用Otsu方法进行阈值分割
thresh = cv2.threshold(warped, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
# 在二值图像中寻找轮廓
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
questionCnts = []
# 遍历每一个轮廓
for c in cnts:
# 计算轮廓的BB,并根据BB获得横纵比
(x, y, w, h) = cv2.boundingRect(c)
ar = w / float(h)
# 为了将轮廓标记为问题,区域应足够宽、足够高,且长宽比约等于1
# 即选择满足条件的结果
if w >= 20 and h >= 20 and ar >= 0.9 and ar <= 1.1:
questionCnts.append(c)
# 对问题轮廓进行排序
questionCnts = contours.sort_contours(questionCnts,
method="top-to-bottom")[0]
correct = 0
# 对于每一个问题而言,有5个可能的答案,遍历每一个可能的答案
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
# 对每一个问题的5个答案进行排序
cnts = contours.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)
# 将mask应用到阈值图像中,然后计算气泡区域中的非零像素数
mask = cv2.bitwise_and(thresh, thresh, mask=mask)
total = cv2.countNonZero(mask)
# 如果它大于该非0像素,我们认为它是答案
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(paper, [cnts[k]], -1, color, 3)
# 计算正确率并打印得分
score = (correct / 5.0) * 100
print("[INFO] score: {:.2f}%".format(score))
# 显示原始图片和测试后的结果并显示得分
cv2.putText(paper, "{:.2f}%".format(score), (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
cv2.imshow("Original", image)
cv2.imshow("Exam", paper)
cv2.waitKey(0)
上图展示了整个算法运行的一些中间输出结果,其中第1行第1列表示输入的原图,对应于程序中的image;第1行第2列表示灰度化的结果,对应于程序中的gray;第1行第3列表示边缘检测的结果,对应于程序中的edged;第1行第4列是对原图中所有检测到的轮廓的可视化,我们可以观察到基本上所有的答题区域都可以检测到;第2行第1列表示的使用坐标变换后的原图,即从原图中裁剪出来的答题卡图片,对应于程序中的paper;第2行第2列表示的灰度化的图片的变换结果,对应于程序中的warped;第2行第3列表示的在paper中检测到的所有轮廓,我们需要的答题区域都可以检测出来,答过的结果和没有答过的结果差距较大;第2行第2列表示最终的输出结果,可以看出该答题卡的答案和正确的答案完全符合,因此得100分。
上图展示了整个mask的生成过程,使用.gif动态图进行展示,我们可以观察到整个mask从上到下,从左到右遍历所有的答题位置,并判断当前的结果是否和标准答案相同。
# 添加中间结果可视化
# coding=utf-8
# 导入一些python包
from imutils.perspective import four_point_transform
from imutils import contours
import numpy as np
import argparse
import imageio
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}
# 读取输入图片
image = cv2.imread(args["image"])
image1 = cv2.imread(args["image"])
# 输入图片灰度化
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imwrite("vis\gray.png", gray)
# 进行高斯滤波
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
cv2.imwrite("vis\blurred.png", blurred)
# 进行边缘检测处理
edged = cv2.Canny(blurred, 75, 200)
cv2.imwrite("vis\edged.png", edged)
ret,binary = cv2.threshold(gray.copy(), 127, 255, cv2.THRESH_BINARY)
contour, hierarchy = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(image1,contour,-1,(0,255,0),3)
cv2.imwrite("vis\Contours1.png", image1)
# 在边缘图像中发现轮廓
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
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)
# 如果我们在近似轮廓中发现4个点,表明我们发现了答题卡
if len(approx) == 4:
docCnt = approx
break
# 将变换关系应用到原始的输入图像和灰度图像中,从而获得一个答题卡的鸟瞰图
paper = four_point_transform(image, docCnt.reshape(4, 2))
paper1 = four_point_transform(image, docCnt.reshape(4, 2))
cv2.imwrite("vis\paper1.png", paper1)
warped = four_point_transform(gray, docCnt.reshape(4, 2))
cv2.imwrite("vis\warped.png", warped)
# 在warped图像上面使用Otsu方法进行阈值分割
thresh = cv2.threshold(warped, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
# 在二值图像中寻找轮廓
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
questionCnts = []
ret1,binary1 = cv2.threshold(warped.copy(), 127, 255, cv2.THRESH_BINARY)
contour, hierarchy = cv2.findContours(binary1, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(paper1,contour,-1,(255,0,0),3)
cv2.imwrite("vis\Contours2.png", paper1)
# 遍历每一个轮廓
for c in cnts:
# 计算轮廓的BB,并根据BB获得横纵比
(x, y, w, h) = cv2.boundingRect(c)
ar = w / float(h)
# 为了将轮廓标记为问题,区域应足够宽、足够高,且长宽比约等于1
# 即选择满足条件的结果
if w >= 20 and h >= 20 and ar >= 0.9 and ar <= 1.1:
questionCnts.append(c)
# 对问题轮廓进行排序
questionCnts = contours.sort_contours(questionCnts,
method="top-to-bottom")[0]
correct = 0
# 对于每一个问题而言,有5个可能的答案,遍历每一个可能的答案
img_mask = []
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
# 对每一个问题的5个答案进行排序
cnts = contours.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)
# 将mask应用到阈值图像中,然后计算气泡区域中的非零像素数
mask = cv2.bitwise_and(thresh, thresh, mask=mask)
total = cv2.countNonZero(mask)
img_mask.append(mask)
# 如果它大于该非0像素,我们认为它是答案
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(paper, [cnts[k]], -1, color, 3)
# 计算正确率并打印得分
score = (correct / 5.0) * 100
print("[INFO] score: {:.2f}%".format(score))
# 显示原始图片和测试后的结果并显示得分
cv2.putText(paper, "{:.2f}%".format(score), (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
cv2.imshow("Original", image)
cv2.imwrite("vis\paper.png", paper)
cv2.imshow("Exam", paper)
imageio.mimsave("vis\mask.gif", img_mask)
cv2.waitKey(0)
上面几幅图展示了整个算法的实际测试效果,整体来看,整个算法的准确率高,基本可以满足实时场景的需要。
尽管从上面的测试结果来看该算法比较鲁棒、可靠,但是在现实场景中会遇到一些其它的问题,比如
1、空白答题卡
对于这种情况而言,当计算cv2.countNonZero时,我们可以设置一个最小的阈值(108行),如果这个值足够大,我们就可以将泡泡标记为"已填涂".相反,如果total太小,我们就跳过那个泡泡.如果直到行结尾,没有泡泡具有足够大的阈值计数,就将该问题标记为测试者"跳过".
2、每个问题填写了多个答案
对于问题2而言,我们采用类似的方法,我们追踪一个问题具有total的泡泡数量是否超过预定义值,我们将其标记为无效问题或者是错误答案。
3、对于复杂的答题卡该如何处理呢?
上图仅仅是答题卡的一种,对于答题卡而言,基本每一个考试会有一种类型,对于这些类型我们不可能分别设计一个算法吧,这是基于深度学习的方法就具有独特的优势啦。具体的效果大家可以去实现并测试一些欧。
1、参考博客
[1] 该博客是本人原创博客,如果您对该博客感兴趣,想要转载该博客,请与我联系(qq邮箱:[email protected]),我会在第一时间回复大家,谢谢大家的关注。
[2] 由于个人能力有限,该博客可能存在很多的问题,希望大家能够提出改进意见。
[3] 如果您在阅读本博客时遇到不理解的地方,希望您可以联系我,我会及时的回复您,和您交流想法和意见,谢谢。
[4] 本文测试的图片可以通过该链接进行下载。测试图片链接- 提取码:mj3s
[5] 本人业余时间承接各种本科毕设设计和各种小项目,包括图像处理(数据挖掘、机器学习、深度学习等)、matlab仿真、python算法及仿真等,有需要的请加QQ:1575262785详聊!!!