Bootstrap

自考答题卡识别初级解决方案,基于 Python OpenCV

橡皮擦,一个逗趣的互联网高级网虫。

文章背景

今天是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()

对目标图像进行灰度,二值化,腐蚀操作

该步骤的重点是对图像进行形态学处理,为后续轮廓检索降低图像数据的处理量。二值化操作使用的是 算法。

上述图片的实现代码如下所示,形态学处理部分,橡皮擦只对图片进行了腐蚀操作就得到了较为满意的效果。针对不同图片该步骤的处理方案不同,需要依据图像进行处理。

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()

边缘检测,寻找轮廓,绘制轮廓

本步骤在于找到图像的轮廓区域,在获取轮廓函数调用时,注意只获取 外轮廓 即可,参数值为 。边缘检测方法采用的是 算法,可以更换为其它算法,以下代码为本步骤代码,添加到完整代码中即可。

 		# 边缘检测
  	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 坐标判断用户答案

本步骤需要依赖一个答案字典进行用户答案判断,该字典定义如下,其中字典的键值分别是 。该字典中的值依据答题卡的格式与内容进行调整,如测试图片不同,下述值也不同。

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()

此时运行代码,在控制台会得到用户涂抹的答案,第一行为轮廓左上角顶点的 坐标,第二行为判断到的用户答案。

111 9
用户答案是 D

案例总结

本案例中采用最基本 OpenCV 图像处理技巧,实现了一个初级答题卡识别系统,测试准确性时,你可以切割不同区域的试题,然后对其进行测试。

本案例可扩展的点:

  • 本案例可增加正确选项字典,除涂抹选项外,还可判断学生答案是否正确;

  • 扫描的答题卡如出现倾斜情况,需要对其进行透视变换;

  • 如果不想将答题卡按题切割,可以整体对答题卡进行处理,然后与涂抹正确选项的答题卡进行图像运算操作,直接获取作答结果与得分;

  • 边缘检测算法存在优化空间。