橡皮擦,一个逗趣的互联网高级网虫。
文章背景
今天是 2021 年 4 月 10 日,一个非常普通的日子,但是对于参加自考的学生而言,今天是全国统考的日子,而橡皮擦也参与了这次全国统考。
参加考试,就要填涂答题卡,所以今天的博客就应个景,实现一个答题卡识别案例。
目标图片来自网络,如下所示。
需求描述:识别出用户答题卡涂抹的答案,并输出 A,B,C,D。
注意:本案例需要 Python OpenCV 技术栈基础知识,所以对 OpenCV 中 API 函数的基本使用不再进行解释。
实现逻辑
拿到需求之后的第一步,就是对需求进行拆解,得到问题解决的思路。
完成需求的步骤如下:
读取图片转换成灰度图;
图像二值化处理;
腐蚀或者执行形态学操作,突出涂抹区域;
边缘检测与寻找轮廓(如果存在小轮廓,需要调节第二步与第三步);
获取外接矩形,从而得到轮廓坐标;
匹配出用户涂抹答案,并输出。
编码过程
获取目标区域
本案例中采用的解决方案是,基于坐标对图片单题切割,针对上述答题卡测试图片,按题切割为下述内容。
图片中的第一题和第二题
获取图片 ROI 为最基本的图片处理方式,为便于后续测试识别效果,可先硬编码为第一题或第二题。
import cv2 as cv
import numpy as np
def main():
src = cv.imread("img.jpg")
# 获取题目 1
roi_1 = src[38:60, 46:190]
# 获取题目 2
roi_2 = src[60:85, 46:190]
cv.imshow("roi_1", roi_1)
cv.imshow("roi_2", roi_2)
cv.waitKey(0)
if __name__ == '__main__':
main()
复制代码
对目标图像进行灰度,二值化,腐蚀操作
该步骤的重点是对图像进行形态学处理,为后续轮廓检索降低图像数据的处理量。二值化操作使用的是 OTSU
算法。
上述图片的实现代码如下所示,形态学处理部分,橡皮擦只对图片进行了腐蚀操作就得到了较为满意的效果。针对不同图片该步骤的处理方案不同,需要依据图像进行处理。
import cv2 as cv
import numpy as np
def main():
src = cv.imread("img.jpg")
# 获取题目 1
roi_1 = src[38:60, 46:190]
cv.imshow("roi_1", roi_1)
# 灰度图
gray = cv.cvtColor(roi_1, cv.COLOR_BGR2GRAY)
cv.imshow("gray", gray)
# 二值化操作
ret, thresh = cv.threshold(gray, 0, 255, cv.THRESH_BINARY_INV | cv.THRESH_OTSU)
cv.imshow("thresh", thresh)
# 腐蚀
kernel = np.ones((5, 5), np.uint8)
dst = cv.erode(thresh, kernel=kernel)
cv.imshow("dst", dst)
cv.waitKey(0)
if __name__ == '__main__':
main()
复制代码
边缘检测,寻找轮廓,绘制轮廓
本步骤在于找到图像的轮廓区域,在获取轮廓函数调用时,注意只获取 外轮廓 即可,参数值为 cv.RETR_EXTERNAL
。边缘检测方法采用的是 Canny
算法,可以更换为其它算法,以下代码为本步骤代码,添加到完整代码中即可。
# 边缘检测
edges = cv.Canny(dst, 50, 150)
# 寻找轮廓
contours, hierarchy = cv.findContours(
edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
# 绘制轮廓
cv.drawContours(roi_2, contours, -1, (0, 255, 255), 2)
复制代码
通过以上步骤,已经得到了学生涂抹区域,该区域获取的精准度不需要特别高,因后续我们是依据坐标范围进行判断,存在一定的容差空间。
通过外接矩形 x 坐标判断用户答案
本步骤需要依赖一个答案字典进行用户答案判断,该字典定义如下,其中字典的键值分别是 (选项,选项对应的 x 坐标范围)
。该字典中的值依据答题卡的格式与内容进行调整,如测试图片不同,下述值也不同。
USER_ANSWER_X = {
"A": (0, 30),
"B": (30, 60),
"C": (60, 90),
"D": (90, 120)
}
复制代码
最终实现的代码如下,对用户答案的判断,直接使用提前设定好的范围进行判断即可。
import cv2 as cv
import numpy as np
USER_ANSWER_X = {
"A": (0, 30),
"B": (30, 60),
"C": (60, 90),
"D": (90, 120)
}
def answer(x):
for item in USER_ANSWER_X.items():
if x > item[1][0] and x < item[1][1]:
return f"用户答案是 {item[0]}"
def main():
src = cv.imread("img.jpg")
# 获取题目 1
roi_1 = src[38:60, 46:190]
cv.imshow("roi_1", roi_1)
# 灰度图
gray = cv.cvtColor(roi_1, cv.COLOR_BGR2GRAY)
cv.imshow("gray", gray)
# 二值化操作
ret, thresh = cv.threshold(gray, 0, 255, cv.THRESH_BINARY_INV | cv.THRESH_OTSU)
cv.imshow("thresh", thresh)
# 腐蚀
kernel = np.ones((5, 5), np.uint8)
dst = cv.erode(thresh, kernel=kernel)
cv.imshow("dst", dst)
edges = cv.Canny(dst, 50, 150)
cv.imshow("edges", edges)
# 寻找轮廓
contours, hierarchy = cv.findContours(
edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
# 绘制轮廓
cv.drawContours(roi_1, contours, -1, (0, 255, 255), 2)
cv.imshow("roi_1", roi_1)
for cnt in contours:
# 外接矩形
x, y, w, h = cv.boundingRect(cnt)
print(x, y)
ret_answer_str = answer(x)
print(ret_answer_str)
cv.waitKey(0)
if __name__ == '__main__':
main()
复制代码
此时运行代码,在控制台会得到用户涂抹的答案,第一行为轮廓左上角顶点的 (x,y)
坐标,第二行为判断到的用户答案。
案例总结
本案例中采用最基本 OpenCV 图像处理技巧,实现了一个初级答题卡识别系统,测试准确性时,你可以切割不同区域的试题,然后对其进行测试。
本案例可扩展的点:
本案例可增加正确选项字典,除涂抹选项外,还可判断学生答案是否正确;
扫描的答题卡如出现倾斜情况,需要对其进行透视变换;
如果不想将答题卡按题切割,可以整体对答题卡进行处理,然后与涂抹正确选项的答题卡进行图像运算操作,直接获取作答结果与得分;
边缘检测算法存在优化空间。
评论