python CV 趣味項(xiàng)目 答題卡識(shí)別

英文原文來(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è)試圖片我放在了文末。

光學(xué)劃記符號(hào)辨識(shí)(OMR

OMR結(jié)果

本文綜合了一些博文的技術(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)

  1. 使用活雷鋒編譯好的包,注意版本對(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
  2. 下載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)

我們先從磁盤中加載了圖片文件,然后將它轉(zhuǎn)換為灰階,再進(jìn)行模糊處理來(lái)消除高頻噪聲。最后,使用Canny邊緣檢測(cè)器來(lái)獲取答題卡的邊緣。結(jié)果如下 :


邊緣檢測(cè)結(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在原始圖像中畫出來(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 ExampleOrdering coordinates clockwise with Python and OpenCV

透視變換的結(jié)果

好了,現(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
將我們得到的題目列表中的輪廓在圖像中畫出,得到下圖:

檢測(cè)出所有氣泡

只有題目氣泡區(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)前題目的氣泡輪廓排序,然后初始化被涂畫的氣泡變量
    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)分出先后。

每行一組對(duì)應(yīng)一個(gè)題目

第二步,我們需要判斷哪個(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
 
    # 畫出正確答案的輪廓線。
    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)
結(jié)果

其它

  • 為什么不使用圓形檢測(cè)?
    圖中的圓形氣泡可以使用Hough circles檢測(cè)方法。但是
    1. Hough circles的參數(shù)不好調(diào)
    2. 更重要的是避免用戶使用錯(cuò)誤造成的bug,填寫答題卡時(shí)不時(shí)會(huì)有填涂超出圓形邊界的現(xiàn)象。

拓展和改進(jìn)

  • 需要改進(jìn)的是未填充氣泡的處理邏輯,當(dāng)前我們假設(shè)每行有且僅有一個(gè)填充氣泡。進(jìn)一步,要考慮如果答題者沒(méi)有涂寫答案或者涂寫了多個(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è)試圖片
test_01.png
test_02.png
test_03.png
test_04.png
test_05.png

供修改的空答題卡圖


example_test.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容