英文原文來(lái)自 Bubble sheet multiple choice scanner and test grader using OMR, Python and OpenCV
說(shuō)到答題卡砚婆,滿滿的都是學(xué)生時(shí)代的回憶叉钥。本文實(shí)現(xiàn)了利用Python的計(jì)算機(jī)視覺(jué)和圖像處理技術(shù)實(shí)現(xiàn)圓點(diǎn)答題卡識(shí)別敛惊。代碼簡(jiǎn)潔,原理清晰荒澡,富有趣味姨蟋。感謝英文原作者屉凯,他的代碼和測(cè)試圖片我放在了文末。
本文綜合了一些博文的技術(shù)芬探,包括building a document scanner神得,contour sorting以及perspective transforms
實(shí)現(xiàn)答題卡識(shí)別的7步
- Step #1: 檢測(cè)到圖片中的答題卡
- Step #2: 應(yīng)用透視變換來(lái)提取圖中的答題卡(以自上向下的鳥(niǎo)瞰視圖)
- Step #3: 從透視變換后的答題卡中提取 the set of 氣泡/圓點(diǎn) (答案選項(xiàng))
- Step #4: 將題目/氣泡排序成行
- Step #5: 判斷每行中被標(biāo)記/涂的答案
- Step #6: 在我們的答案字典中查找正確的答案來(lái)判斷答題是否正確
- Step #7: 為其它題目重復(fù)上述操作
算法實(shí)現(xiàn)
讓我們新建一個(gè)Python文件test_grader.py
,然后添加以下內(nèi)容:
# 引入必要的庫(kù)
from imutils.perspective import four_point_transform
from imutils import contours
import numpy as np
import argparse
import imutils
import cv2
# 構(gòu)建命令行參數(shù)解析并分析參數(shù)
# 對(duì)應(yīng)使用方式 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())
# 構(gòu)建答案字典偷仿,鍵為題目號(hào)哩簿,值為正確答案
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}
補(bǔ)充:
vars()
接受一個(gè)對(duì)象返回它的內(nèi)建字典
vars()
當(dāng)然你需要有OpenCV和Numpy的包,但你可能沒(méi)有最新版本的imutils酝静,一個(gè)便于基本圖像處理操作的庫(kù)节榜。使用下面的命令來(lái)安裝和升級(jí)該庫(kù):
pip install --upgrade imutils
補(bǔ)充:在Windows7_64位+ Python 3.5測(cè)試,對(duì)于OpenCV的安裝有兩種方法别智,推薦第一種方法宗苍。(由于是python3,使用的是OpenCV 3.1.0)
- 使用活雷鋒編譯好的包薄榛,注意版本對(duì)應(yīng)讳窟。下載頁(yè)面選擇
opencv_python-3.1.0-cp35-cp35m-win_amd64.whl
,下載完成后在cmd命令行中使用pip install xx.whl
安裝敞恋,xx.whl
為下載的文件對(duì)應(yīng)路徑丽啡。同理可在該頁(yè)面找到numpy進(jìn)行安裝,當(dāng)然硬猫,對(duì)于科學(xué)計(jì)算庫(kù)的下載解決方案补箍,首推anaconda。 - 下載OpenCV啸蜜,直接安裝(就是解壓)后將OpenCV安裝目錄下的
\build\python\2.7\cv2.pyd
復(fù)制到Python的子目錄\Lib\site-packages
下坑雅。然后將opencv的\build\bin
目錄添加到Windows的PATH中。
在python命令行中import cv2
成功的話就是安裝好了衬横。
我們?cè)诿钚兄兄唤馕隽艘粋€(gè)參數(shù)裹粤,那就是要分析的圖片的路徑。然后定義了答案字典蜂林,在這里蛹尝,題目對(duì)應(yīng)的值是正確答案在行中的索引位置后豫,跟python中列表的索引方式相同。
# 加載圖片突那,將它轉(zhuǎn)換為灰階挫酿,輕度模糊,然后邊緣檢測(cè)愕难。
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)
我們先從磁盤(pán)中加載了圖片文件早龟,然后將它轉(zhuǎn)換為灰階,再進(jìn)行模糊處理來(lái)消除高頻噪聲猫缭。最后葱弟,使用Canny邊緣檢測(cè)器來(lái)獲取答題卡的邊緣。結(jié)果如下 :
注意猜丹,答題卡長(zhǎng)方形的四個(gè)頂點(diǎn)都要在圖中出現(xiàn)芝加,這是我們事先約定的答題卡的邊緣。
獲取輪廓非常重要射窒,因?yàn)橄乱徊轿覀儗⑺鳛閼?yīng)用透視變換的標(biāo)記(錨點(diǎn))藏杖,來(lái)獲得一個(gè)答題卡的自上而下的鳥(niǎo)瞰視圖。
補(bǔ)充:stackoverflow上的提問(wèn):邊緣檢測(cè)和輪廓檢測(cè)的區(qū)別Difference between “Edge Detection” and “Image Contours” 最佳答案
簡(jiǎn)要來(lái)說(shuō)脉顿,邊緣是極值點(diǎn)蝌麸,而輪廓一般從邊緣得來(lái),是閉合的曲線艾疟。
# 從邊緣圖中尋找輪廓来吩,然后初始化答題卡對(duì)應(yīng)的輪廓
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if imutils.is_cv2() else cnts[1]
docCnt = None
# 確保至少有一個(gè)輪廓被找到
if len(cnts) > 0:
# 將輪廓按大小降序排序
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
# 對(duì)排序后的輪廓循環(huán)處理
for c in cnts:
# 獲取近似的輪廓
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
# 如果我們的近似輪廓有四個(gè)頂點(diǎn),那么就認(rèn)為找到了答題卡
if len(approx) == 4:
docCnt = approx
break
首先我們通過(guò)cv2.findContours
從邊緣檢測(cè)的結(jié)果更進(jìn)一步得到輪廓值蔽莱。然后我們對(duì)輪廓的區(qū)域大小進(jìn)行排序弟疆,在這里我們假設(shè)答題卡就是我們圖像的焦點(diǎn),它會(huì)比圖中其它對(duì)象大盗冷,所以從大到小對(duì)輪廓進(jìn)行檢測(cè)兽间,符合長(zhǎng)方形特征的就是我們的答題卡了。
此外正塌,對(duì)于每個(gè)輪廓,我們進(jìn)行了近似恤溶,這在本質(zhì)上意味著我們簡(jiǎn)化了輪廓點(diǎn)的數(shù)量乓诽,使其成為一個(gè)“更基本的”幾何形狀。
補(bǔ)充:關(guān)于更多輪廓近似的內(nèi)容咒程,請(qǐng)看 building a mobile document scanner.
現(xiàn)在鸠天,如果docCnt
在原始圖像中畫(huà)出來(lái)它將是這樣的:
然后我們進(jìn)行透視變換
# 對(duì)原始圖像和灰度圖都進(jìn)行四點(diǎn)透視變換
paper = four_point_transform(image, docCnt.reshape(4, 2))
warped = four_point_transform(gray, docCnt.reshape(4, 2))
我們使用了four_point_transform
函數(shù),它將輪廓的(x, y) 坐標(biāo)以一種特別帐姻、可重復(fù)的方式整理稠集,并且對(duì)輪廓包圍的區(qū)域進(jìn)行透視變換奶段。暫時(shí)我們只需要知道它的變換效果就行了。
補(bǔ)充:關(guān)于更多該函數(shù)的信息剥纷,參看4 Point OpenCV getPerspective Transform Example和Ordering coordinates clockwise with Python and OpenCV
好了痹籍,現(xiàn)在我們?nèi)〉昧艘恍┻M(jìn)展。
我們從原始圖像中獲取了答題卡晦鞋,并應(yīng)用透視變換獲取90度俯視效果蹲缠。
下面要對(duì)題目進(jìn)行判斷了。
這一步開(kāi)始于二值化悠垛,或者說(shuō)是圖像的前景和后景的分離/閾值處理线定。
# 對(duì)灰度圖應(yīng)用大津二值化算法
thresh = cv2.threshold(warped, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
補(bǔ)充:大津二值化法
現(xiàn)在,我們的圖像是一個(gè)純粹二值圖像了确买。
圖像的背景是黑色的斤讥,而前景是白色的。
這二值化使得我們能夠再次應(yīng)用輪廓提取技術(shù)湾趾,以找到每個(gè)題目中的氣泡選項(xiàng)芭商。
# 在二值圖像中查找輪廓,然后初始化題目對(duì)應(yīng)的輪廓列表
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if imutils.is_cv2() else cnts[1]
questionCnts = []
# 對(duì)每一個(gè)輪廓進(jìn)行循環(huán)處理
for c in cnts:
# 計(jì)算輪廓的邊界框撑帖,然后利用邊界框數(shù)據(jù)計(jì)算寬高比
(x, y, w, h) = cv2.boundingRect(c)
ar = w / float(h)
# 為了辨別一個(gè)輪廓是一個(gè)氣泡蓉坎,要求它的邊界框不能太小,在這里邊至少是20個(gè)像素胡嘿,而且它的寬高比要近似于1
if w >= 20 and h >= 20 and ar >= 0.9 and ar <= 1.1:
questionCnts.append(c)
我們由二值圖像中的輪廓蛉艾,獲取輪廓邊界框,利用邊界框數(shù)據(jù)來(lái)判定每一個(gè)輪廓是否是一個(gè)氣泡衷敌,如果是勿侯,將它加入題目列表questionCnts
。
將我們得到的題目列表中的輪廓在圖像中畫(huà)出缴罗,得到下圖:
只有題目氣泡區(qū)域被圈出來(lái)了助琐,而其它地方?jīng)]有。
接下來(lái)就是閱卷了:
# 以從頂部到底部的方法將我們的氣泡輪廓進(jìn)行排序面氓,然后初始化正確答案數(shù)的變量兵钮。
questionCnts = contours.sort_contours(questionCnts,
method="top-to-bottom")[0]
correct = 0
# 每個(gè)題目有5個(gè)選項(xiàng),所以5個(gè)氣泡一組循環(huán)處理
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
# 從左到右為當(dāng)前題目的氣泡輪廓排序舌界,然后初始化被涂畫(huà)的氣泡變量
cnts = contours.sort_contours(questionCnts[i:i + 5])[0]
bubbled = None
首先掘譬,我們對(duì)questionCnts
進(jìn)行從上到下的排序,使得靠近頂部的一行氣泡在列表中最先出現(xiàn)呻拌。然后對(duì)每行氣泡應(yīng)用從左到右的排序葱轩,使左邊的氣泡在隊(duì)列中先出現(xiàn)。解釋下,就是氣泡輪廓按縱坐標(biāo)先排序靴拱,并排的5個(gè)氣泡輪廓縱坐標(biāo)相差不大垃喊,總會(huì)被排在一起,而且每組氣泡之間按從上到下的順序排列袜炕,然后再將每組輪廓按橫坐標(biāo)分出先后本谜。
第二步,我們需要判斷哪個(gè)氣泡被填充了妇蛀。我們可以利用二值圖像中每個(gè)氣泡區(qū)域內(nèi)的非零像素點(diǎn)數(shù)量來(lái)進(jìn)行判斷耕突。
# 對(duì)一行從左到右排列好的氣泡輪廓進(jìn)行遍歷
for (j, c) in enumerate(cnts):
# 構(gòu)造只有當(dāng)前氣泡輪廓區(qū)域的掩模圖像
mask = np.zeros(thresh.shape, dtype="uint8")
cv2.drawContours(mask, [c], -1, 255, -1)
# 對(duì)二值圖像應(yīng)用掩模圖像,然后就可以計(jì)算氣泡區(qū)域內(nèi)的非零像素點(diǎn)评架。
mask = cv2.bitwise_and(thresh, thresh, mask=mask)
total = cv2.countNonZero(mask)
# 如果像素點(diǎn)數(shù)最大眷茁,就連同氣泡選項(xiàng)序號(hào)一起記錄下來(lái)
if bubbled is None or total > bubbled[0]:
bubbled = (total, j)
下圖是對(duì)一行每一個(gè)氣泡利用掩模和原二值圖像合成的結(jié)果,顯然B是超過(guò)閾值的像素點(diǎn)最多的纵诞,從而也就是答題者選中的答案上祈。
接著就是查找答案字典,判斷正誤了浙芙。
# 初始化輪廓顏色為紅色登刺,獲取正確答案序號(hào)
color = (0, 0, 255)
k = ANSWER_KEY[q]
# 檢查由填充氣泡獲得的答案是否正確,正確則將輪廓顏色設(shè)置為綠色嗡呼。
if k == bubbled[1]:
color = (0, 255, 0)
correct += 1
# 畫(huà)出正確答案的輪廓線纸俭。
cv2.drawContours(paper, [cnts[k]], -1, color, 3)
如果氣泡作答是對(duì)的,則用綠色圈起來(lái)南窗,如果不對(duì)揍很,就用紅色圈出正確答案:
最后,我們計(jì)算分?jǐn)?shù)并展示結(jié)果万伤。
# 計(jì)算分?jǐn)?shù)并打分
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)
其它
- 為什么不使用圓形檢測(cè)窒悔?
圖中的圓形氣泡可以使用Hough circles檢測(cè)方法。但是- Hough circles的參數(shù)不好調(diào)
- 更重要的是避免用戶使用錯(cuò)誤造成的bug敌买,填寫(xiě)答題卡時(shí)不時(shí)會(huì)有填涂超出圓形邊界的現(xiàn)象简珠。
拓展和改進(jìn)
- 需要改進(jìn)的是未填充氣泡的處理邏輯,當(dāng)前我們假設(shè)每行有且僅有一個(gè)填充氣泡虹钮。進(jìn)一步聋庵,要考慮如果答題者沒(méi)有涂寫(xiě)答案或者涂寫(xiě)了多個(gè)選項(xiàng)的情況,這里的邏輯并不復(fù)雜芙粱。
全部源碼和測(cè)試圖片
- 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)
- 測(cè)試圖片
供修改的空答題卡圖