python CV 趣味项目 答题卡识别

英文原文来自 Bubble sheet multiple choice scanner and test grader using OMR, Python and OpenCV

说到答题卡,满满的都是学生时代的回忆。本文实现了利用Python的计算机视觉和图像处理技术实现圆点答题卡识别。代码简洁,原理清晰,富有趣味。感谢英文原作者,他的代码和测试图片我放在了文末。

光学划记符号辨识(OMR

OMR结果

本文综合了一些博文的技术,包括building a document scannercontour sorting以及perspective transforms

实现答题卡识别的7步

  • Step #1: 检测到图片中的答题卡
  • Step #2: 应用透视变换来提取图中的答题卡(以自上向下的鸟瞰视图)
  • Step #3: 从透视变换后的答题卡中提取 the set of 气泡/圆点 (答案选项)
  • Step #4: 将题目/气泡排序成行
  • Step #5: 判断每行中被标记/涂的答案
  • Step #6: 在我们的答案字典中查找正确的答案来判断答题是否正确
  • Step #7: 为其它题目重复上述操作

算法实现

让我们新建一个Python文件test_grader.py,然后添加以下内容:

# 引入必要的库
from imutils.perspective import four_point_transform
from imutils import contours
import numpy as np
import argparse
import imutils
import cv2
 
# 构建命令行参数解析并分析参数
# 对应使用方式 python test_grader.py --image images/test_01.png
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}

补充:vars()接受一个对象返回它的内建字典

vars()

当然你需要有OpenCV和Numpy的包,但你可能没有最新版本的imutils,一个便于基本图像处理操作的库。使用下面的命令来安装和升级该库:

pip install --upgrade imutils

补充:在Windows7_64位+ Python 3.5测试,对于OpenCV的安装有两种方法,推荐第一种方法。(由于是python3,使用的是OpenCV 3.1.0)

  1. 使用活雷锋编译好的包,注意版本对应。下载页面选择opencv_python-3.1.0-cp35-cp35m-win_amd64.whl,下载完成后在cmd命令行中使用pip install xx.whl安装,xx.whl为下载的文件对应路径。同理可在该页面找到numpy进行安装,当然,对于科学计算库的下载解决方案,首推anaconda
  2. 下载OpenCV,直接安装(就是解压)后将OpenCV安装目录下的\build\python\2.7\cv2.pyd复制到Python的子目录\Lib\site-packages下。然后将opencv的\build\bin目录添加到Windows的PATH中。
    在python命令行中import cv2成功的话就是安装好了。

我们在命令行中只解析了一个参数,那就是要分析的图片的路径。然后定义了答案字典,在这里,题目对应的值是正确答案在行中的索引位置,跟python中列表的索引方式相同。

# 加载图片,将它转换为灰阶,轻度模糊,然后边缘检测。
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)

我们先从磁盘中加载了图片文件,然后将它转换为灰阶,再进行模糊处理来消除高频噪声。最后,使用Canny边缘检测器来获取答题卡的边缘。结果如下 :


边缘检测结果

注意,答题卡长方形的四个顶点都要在图中出现,这是我们事先约定的答题卡的边缘。
获取轮廓非常重要,因为下一步我们将它作为应用透视变换的标记(锚点),来获得一个答题卡的自上而下的鸟瞰视图。

补充:stackoverflow上的提问:边缘检测和轮廓检测的区别Difference between “Edge Detection” and “Image Contours” 最佳答案
简要来说,边缘是极值点,而轮廓一般从边缘得来,是闭合的曲线。

# 从边缘图中寻找轮廓,然后初始化答题卡对应的轮廓
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if imutils.is_cv2() else cnts[1]
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

首先我们通过cv2.findContours从边缘检测的结果更进一步得到轮廓值。然后我们对轮廓的区域大小进行排序,在这里我们假设答题卡就是我们图像的焦点,它会比图中其它对象大,所以从大到小对轮廓进行检测,符合长方形特征的就是我们的答题卡了。
此外,对于每个轮廓,我们进行了近似,这在本质上意味着我们简化了轮廓点的数量,使其成为一个“更基本的”几何形状。

补充:关于更多轮廓近似的内容,请看 building a mobile document scanner.

现在,如果docCnt在原始图像中画出来它将是这样的:

找到的答题卡轮廓

然后我们进行透视变换

# 对原始图像和灰度图都进行四点透视变换
paper = four_point_transform(image, docCnt.reshape(4, 2))
warped = four_point_transform(gray, docCnt.reshape(4, 2))

我们使用了four_point_transform函数,它将轮廓的(x, y) 坐标以一种特别、可重复的方式整理,并且对轮廓包围的区域进行透视变换。暂时我们只需要知道它的变换效果就行了。

补充:关于更多该函数的信息,参看4 Point OpenCV getPerspective Transform ExampleOrdering coordinates clockwise with Python and OpenCV

透视变换的结果

好了,现在我们取得了一些进展。
我们从原始图像中获取了答题卡,并应用透视变换获取90度俯视效果。

下面要对题目进行判断了。
这一步开始于二值化,或者说是图像的前景和后景的分离/阈值处理。

# 对灰度图应用大津二值化算法
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 = cnts[0] if imutils.is_cv2() else cnts[1]
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)

我们由二值图像中的轮廓,获取轮廓边界框,利用边界框数据来判定每一个轮廓是否是一个气泡,如果是,将它加入题目列表questionCnts
将我们得到的题目列表中的轮廓在图像中画出,得到下图:

检测出所有气泡

只有题目气泡区域被圈出来了,而其它地方没有。

接下来就是阅卷了:

# 以从顶部到底部的方法将我们的气泡轮廓进行排序,然后初始化正确答案数的变量。
questionCnts = contours.sort_contours(questionCnts,
    method="top-to-bottom")[0]
correct = 0
 
# 每个题目有5个选项,所以5个气泡一组循环处理
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
    # 从左到右为当前题目的气泡轮廓排序,然后初始化被涂画的气泡变量
    cnts = contours.sort_contours(questionCnts[i:i + 5])[0]
    bubbled = None

首先,我们对questionCnts进行从上到下的排序,使得靠近顶部的一行气泡在列表中最先出现。然后对每行气泡应用从左到右的排序,使左边的气泡在队列中先出现。解释下,就是气泡轮廓按纵坐标先排序,并排的5个气泡轮廓纵坐标相差不大,总会被排在一起,而且每组气泡之间按从上到下的顺序排列,然后再将每组轮廓按横坐标分出先后。

每行一组对应一个题目

第二步,我们需要判断哪个气泡被填充了。我们可以利用二值图像中每个气泡区域内的非零像素点数量来进行判断。

    # 对一行从左到右排列好的气泡轮廓进行遍历
    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)
 
        # 如果像素点数最大,就连同气泡选项序号一起记录下来
        if bubbled is None or total > bubbled[0]:
            bubbled = (total, j)

下图是对一行每一个气泡利用掩模和原二值图像合成的结果,显然B是超过阈值的像素点最多的,从而也就是答题者选中的答案。


掩模图像合成效果

接着就是查找答案字典,判断正误了。

    # 初始化轮廓颜色为红色,获取正确答案序号
    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)
结果

其它

  • 为什么不使用圆形检测?
    图中的圆形气泡可以使用Hough circles检测方法。但是
    1. Hough circles的参数不好调
    2. 更重要的是避免用户使用错误造成的bug,填写答题卡时不时会有填涂超出圆形边界的现象。

拓展和改进

  • 需要改进的是未填充气泡的处理逻辑,当前我们假设每行有且仅有一个填充气泡。进一步,要考虑如果答题者没有涂写答案或者涂写了多个选项的情况,这里的逻辑并不复杂。

全部源码和测试图片

  • test_grader.py
# USAGE
# python test_grader.py --image test_01.png

# import the necessary packages
from imutils.perspective import four_point_transform
from imutils import contours
import numpy as np
import argparse
import imutils
import cv2

# construct the argument parse and parse the arguments
ap = argparse.ArgumentParser()
ap.add_argument("-i", "--image", required=True,
    help="path to the input image")
args = vars(ap.parse_args())

# define the answer key which maps the question number
# to the correct answer
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}

# load the image, convert it to grayscale, blur it
# slightly, then find edges
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)

# find contours in the edge map, then initialize
# the contour that corresponds to the document
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if imutils.is_cv2() else cnts[1]
docCnt = None

# ensure that at least one contour was found
if len(cnts) > 0:
    # sort the contours according to their size in
    # descending order
    cnts = sorted(cnts, key=cv2.contourArea, reverse=True)

    # loop over the sorted contours
    for c in cnts:
        # approximate the contour
        peri = cv2.arcLength(c, True)
        approx = cv2.approxPolyDP(c, 0.02 * peri, True)

        # if our approximated contour has four points,
        # then we can assume we have found the paper
        if len(approx) == 4:
            docCnt = approx
            break

# apply a four point perspective transform to both the
# original image and grayscale image to obtain a top-down
# birds eye view of the paper
paper = four_point_transform(image, docCnt.reshape(4, 2))
warped = four_point_transform(gray, docCnt.reshape(4, 2))

# apply Otsu's thresholding method to binarize the warped
# piece of paper
thresh = cv2.threshold(warped, 0, 255,
    cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]

# find contours in the thresholded image, then initialize
# the list of contours that correspond to questions
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if imutils.is_cv2() else cnts[1]
questionCnts = []

# loop over the contours
for c in cnts:
    # compute the bounding box of the contour, then use the
    # bounding box to derive the aspect ratio
    (x, y, w, h) = cv2.boundingRect(c)
    ar = w / float(h)

    # in order to label the contour as a question, region
    # should be sufficiently wide, sufficiently tall, and
    # have an aspect ratio approximately equal to 1
    if w >= 20 and h >= 20 and ar >= 0.9 and ar <= 1.1:
        questionCnts.append(c)

# sort the question contours top-to-bottom, then initialize
# the total number of correct answers
questionCnts = contours.sort_contours(questionCnts,
    method="top-to-bottom")[0]
correct = 0

# each question has 5 possible answers, to loop over the
# question in batches of 5
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
    # sort the contours for the current question from
    # left to right, then initialize the index of the
    # bubbled answer
    cnts = contours.sort_contours(questionCnts[i:i + 5])[0]
    bubbled = None

    # loop over the sorted contours
    for (j, c) in enumerate(cnts):
        # construct a mask that reveals only the current
        # "bubble" for the question
        mask = np.zeros(thresh.shape, dtype="uint8")
        cv2.drawContours(mask, [c], -1, 255, -1)

        # apply the mask to the thresholded image, then
        # count the number of non-zero pixels in the
        # bubble area
        mask = cv2.bitwise_and(thresh, thresh, mask=mask)
        total = cv2.countNonZero(mask)

        # if the current total has a larger number of total
        # non-zero pixels, then we are examining the currently
        # bubbled-in answer
        if bubbled is None or total > bubbled[0]:
            bubbled = (total, j)

    # initialize the contour color and the index of the
    # *correct* answer
    color = (0, 0, 255)
    k = ANSWER_KEY[q]

    # check to see if the bubbled answer is correct
    if k == bubbled[1]:
        color = (0, 255, 0)
        correct += 1

    # draw the outline of the correct answer on the test
    cv2.drawContours(paper, [cnts[k]], -1, color, 3)

# grab the test taker
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)
  • 测试图片
test_01.png
test_02.png
test_03.png
test_04.png
test_05.png

供修改的空答题卡图


example_test.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容