参考自:Bubble sheet multiple choice scanner and test grader using OMR, Python and OpenCV

一个简易的答题卡识别与分数判断小程序

修改说明:

1.不import imutils库,直接找mutils的源码,复制需要的函数的源码过来,分析算法原理

2.在jupter notebook中测试,可以方便地分阶段测试

引入必要的库

1
2
3
4
5
6
7
import numpy as np
import cv2

import matplotlib
import matplotlib.pyplot as plt
# Allow image embeding in notebook
%matplotlib inline

定义需要的函数

4边形4点排序函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ----------------------------------------------------------------------
# 【4边形4点排序函数】
# 输入:4边形任意顺序的4个顶点
# 输出:按照一定顺序的4个顶点
# https://github.com/jrosebr1/imutils/blob/master/imutils/perspective.py
# ----------------------------------------------------------------------
def order_points(pts):
rect = np.zeros((4, 2), dtype = "float32")# 按照左上、右上、右下、左下顺序初始化坐标

s = pts.sum(axis = 1)# 计算点xy的和
rect[0] = pts[np.argmin(s)]# 左上角的点的和最小
rect[2] = pts[np.argmax(s)]# 右下角的点的和最大

diff = np.diff(pts, axis = 1)# 计算点xy之间的差
rect[1] = pts[np.argmin(diff)]# 右上角的差最小
rect[3] = pts[np.argmax(diff)]# 左下角的差最小

return rect# 返回4个顶点的顺序

4点变换函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# ----------------------------------------------------------------------
# 【4点变换函数】
# 输入:原始图像+4个顶点
# 输出:变换后的图像
# https://github.com/jrosebr1/imutils/blob/master/imutils/perspective.py
# ----------------------------------------------------------------------
def four_point_transform(image, pts):
rect = order_points(pts)# 获得一致的顺序的点并分别解包他们
(tl, tr, br, bl) = rect

# 计算新图像的宽度(x)
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))# 取大者

# 计算新图像的高度(y)
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 #返回透视变换后的图像

轮廓排序函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# --------------------------------------------------------------------
# 【轮廓排序函数】
# 输入:轮廓,排序方式
# 输出:排序好的轮廓
# https://github.com/jrosebr1/imutils/blob/master/imutils/contours.py
# --------------------------------------------------------------------
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

# 构造包围框列表,并从上到下对它们进行排序
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

图像识别部分

读入图片+预处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 【1】读入图片+预处理
image = cv2.imread('omr_test_01.png')# 加载图片
#cv2.imshow("Original", image)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)# 转灰度
blurred = cv2.GaussianBlur(gray, (5, 5), 0)# 高斯模糊
edged = cv2.Canny(blurred, 75, 200)# 边缘检测
fig = plt.figure(figsize=(15, 10))
plt.subplot(1, 2, 1)
plt.imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))#plt显示是RGB顺序
plt.axis('off')
plt.subplot(1, 2, 2)
plt.imshow(edged,cmap ='gray')
plt.axis('off')
#cv2.imshow("edged", edged)
#cv2.waitKey()
1
(-0.5, 524.5, 699.5, -0.5)

mark

检测到图片中的答题卡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 【2】检测到图片中的答题卡(python2 用:cnts,_ )
_,cnts,_ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)# 从边缘图中寻找轮廓
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

透视变换来提取答题卡

1
2
3
4
5
6
7
8
# 【3】应用透视变换来提取图中的答题卡
paper = four_point_transform(image, docCnt.reshape(4, 2))# 对原始图进行四点透视变换
warped = four_point_transform(gray, docCnt.reshape(4, 2))# 对灰度图进行四点透视变换
#cv2.imshow("warped", warped)# 透视变换图
#cv2.waitKey()
fig = plt.figure(figsize=(8, 8))
plt.imshow(warped,cmap ='gray')
plt.axis('off')

mark

提取气泡/圆点

1
2
3
4
5
6
7
8
9
10
11
12
13
# 【4】从透视变换后的答题卡中提取气泡/圆点
thresh = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]# OTSU二值化
_,cnts,_ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)# 在二值图像中查找轮廓
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 ar >= 0.9 and ar <= 1.1:
questionCnts.append(c)# 存储气泡轮廓

答案判断部分

构建答案字典

1
2
# 构建答案字典,键为题目号,值为正确答案
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}

气泡排序

1
2
3
# 【5】将题目/气泡排序成行
questionCnts = sort_contours(questionCnts, method="top-to-bottom")[0]# 从顶部到底部将气泡轮廓排序
correct = 0 # 初始化正确答案数的变量

循环判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 每个题目有5个选项,所以5个气泡一组循环处理
fig = plt.figure(figsize=(15,15))
n = 1
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
cnts = sort_contours(questionCnts[i:i + 5])[0]# 从左到右为当前题目的气泡轮廓排序
bubbled = None # 初始化被涂画的气泡变量
# 【6】判断每行中被标记/涂的答案
for (j, c) in enumerate(cnts):# 对一行从左到右排列好的气泡轮廓进行遍历
mask = np.zeros(thresh.shape, dtype="uint8")# 构造只有当前气泡轮廓区域的掩模图像
cv2.drawContours(mask, [c], -1, 255, -1)

mask = cv2.bitwise_and(thresh, thresh, mask=mask)# 对二值图像应用掩模图像
total = cv2.countNonZero(mask)# 计算气泡区域内的非零像素点

#cv2.imshow("mask", mask)
#cv2.waitKey(100)
plt.subplot(5, 5, n) # 5 rows, 5 per row
plt.axis('off')
n += 1
plt.imshow(mask,cmap ='gray')

if bubbled is None or total > bubbled[0]:# 如果像素点数最大
bubbled = (total, j) # 同气泡选项序号一起记录下来

color = (0, 0, 255) # 初始化轮廓颜色为红色
k = ANSWER_KEY[q] # 获取正确答案序号

# 【7】在我答案字典中查找正确的答案来判断答题是否正确
if k == bubbled[1]: # 检查由填充气泡获得的答案是否正确
color = (0, 255, 0)# 正确则将轮廓颜色设置为绿色
correct += 1

# 画出正确答案的轮廓线。
cv2.drawContours(paper, [cnts[k]], -1, color, 3)
#cv2.waitKey()

mark

计算分数并打分

1
2
3
4
5
6
7
8
9
10
# 【8】计算分数并打分

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("Exam", paper)
#cv2.waitKey(0)
fig = plt.figure(figsize=(8, 8))
plt.imshow(cv2.cvtColor(paper, cv2.COLOR_BGR2RGB))
plt.axis('off')
1
[INFO] score: 80.00%

mark