基于 OpenCV 的 iOS 客戶端答題卡識別算法

原文章發(fā)布在:http://www.cuipengfei.cn/2018/04/opencv-answer-sheet-identify/

最近計劃學(xué)習(xí)一些圖像處理方面的知識,第一時間想到了功能強大的 OpenCV Lib蚓胸。

早在一年多前出來北京實習(xí)的時候挣饥,實習(xí)公司的一個短視頻處理 App 在最初的技術(shù)選型的時候就將 OpenCV 作為重要解決方案之一。無奈的是沛膳,當(dāng)初我是一個沒畢業(yè)的 iOS 小菜雞扔枫,初出茅廬又不懂的 C++,還要肩負起獨立開發(fā)的大旗锹安,實在是搞不懂也沒時間搞這么高精尖的 Lib短荐。在放棄 OpenCV Lib 以及嘗試過 AVFoundation 框架后,因此項目最終選用了 GPUImage 來實現(xiàn)叹哭,當(dāng)然這都是后話了忍宋。

這次的 OpenCV 的學(xué)習(xí),一方面是為了彌補之前的技術(shù)空白风罩,另一方面也是為了自己能在圖像處理上有所了解糠排,能站在巨人的肩膀上提升一下自身技術(shù)的維度。

目的及結(jié)果

本篇的寫作目的是記錄學(xué)習(xí) OpenCV Lib 以及運用到答題卡識別的相關(guān)過程超升,并且也是對新學(xué)習(xí)知識點的梳理和重新組織入宦。

本文的實現(xiàn)目的是:利用 OpenCV Lib 識別一張?zhí)囟ǖ拇痤}卡照片哺徊,并且識別出學(xué)生填涂的選項。

實現(xiàn)結(jié)果如下:(紅框內(nèi)為識別結(jié)果)

opencv_result

原圖如下:

opencv_origin

技術(shù)方案

需要說明的是乾闰,在學(xué)習(xí) OpenCV 的基礎(chǔ)知識時落追,無意間發(fā)現(xiàn)唐巧大神幾年前寫的 猿題庫iOS客戶端的技術(shù)細節(jié)(二):答題卡掃描算法 一文。文中提到涯肩,在文章發(fā)布時相關(guān)的識別算法還在進行專利申請淋硝,并且在專利申請結(jié)束會披露算法細節(jié),但是遺憾的是相關(guān)的算法細節(jié)并沒有公開宽菜。

不過萬幸的是,唐巧大神提供了一套不錯的解決方案竿报,我本人的算法就是按照這個思路展開的铅乡,方案如下:

  • 圖像預(yù)處理,壓縮圖像烈菌;
  • 將彩色圖像轉(zhuǎn)為灰度圖像阵幸;
  • 二值化灰度圖像棒动,識別答題卡區(qū)域弃榨;
  • 透視變換,圖像糾偏洪橘;
  • 答案區(qū)域 ROI 識別济瓢;
  • ROI 色值統(tǒng)計荠割,標(biāo)定答案。

準(zhǔn)備

考慮到 OpenCV 是基于 C/C++ 可跨平臺的通用 Lib旺矾,為了降低學(xué)習(xí)成本蔑鹦,便將整個學(xué)習(xí)和實驗集成到 iOS 的開發(fā)環(huán)境里了。前期要做如下幾方面的準(zhǔn)備工作:

  1. 下載編譯 OpenCV Lib箕宙,或者直接下載最新的 iOS OpenCV.framework 的 Release 版本嚎朽;
  2. 將自行編譯或 Release 版 OpenCV.framework 導(dǎo)入 iOS 項目工程中;
  3. 因為 OpenCV 中的 MIN 宏和 UIKit 的 MIN 宏有沖突柬帕,所以需要在 .pch 文件中哟忍,先定義 OpenCV 的頭文件,否則會有編譯錯誤陷寝;
  4. 將需要混編 C++ 和 Objective-C 的文件后綴改為 .mm;
  5. 為 UIImage 添加 Category锅很,方便與OpenCV 圖象格式的數(shù)據(jù) cv::Mat 相互轉(zhuǎn)換。
    因這些繁瑣的配置問題不是本文寫作重點凤跑,而且網(wǎng)上不乏一些詳細說明粗蔚,推薦參考 在MacOS和iOS系統(tǒng)中使用OpenCV 一文,這里就不再贅述饶火。

具體實現(xiàn)

以下為整個技術(shù)方案的分步實現(xiàn)算法及效果圖鹏控,絕大部分均使用的是 OpenCV Lib 標(biāo)準(zhǔn) API致扯,具體功能以及參數(shù)說明可自行查閱官方文檔

  • 圖像預(yù)處理当辐、壓縮圖像

因 iOS 系統(tǒng)的圖片數(shù)據(jù)為 UIImage 類型抖僵,在使用 OpenCV Lib 處理圖片是需要預(yù)處理成為 cv::Mat 類型,然后將預(yù)處理后的 cv::Mat 圖像數(shù)據(jù)作為 inputMat 并對其進行壓縮處理缘揪,降低 CPU 運算負荷耍群。

// 壓縮
cv::resize(inputMat, outputMat, cv::Size(inputMat.rows / 1.5, inputMat.cols/ 1.5));


  • 將彩色圖像轉(zhuǎn)為灰度圖像

將壓縮處理后的 cv::Mat 彩色圖像數(shù)據(jù)進行灰度處理,便于接下來的二值化找筝。

// 灰度處理
cv::cvtColor(inputMat, outputMat, CV_BGR2GRAY);

處理結(jié)果:

opencv_grayMat


  • 圖像降噪蹈垢、二值化

在圖像進行二值化之前,需要對灰度圖像做一次降噪處理袖裕,用以消除圖像模糊的噪聲曹抬,提高二值化的清晰度。在對比過均值濾波急鳄、高斯濾波谤民、中值濾波后,選擇了效果稍明顯的均值濾波方式疾宏,代碼如下:

// 濾波 去噪聲
cv::blur(inputMat, outputMat, cv::Size(3,3));

// 二值化
cv::threshold(inputMat, outputMat, 100, 255, cv::THRESH_BINARY_INV);

處理結(jié)果:

opencv_binary


  • 直線檢測

對二值化圖像進行直線檢測张足,目的是檢測出答題卡的方框,為了視覺效果更加明顯坎藐,這里將檢測的直線为牍,直接繪制在壓縮的圖像上,并且顏色設(shè)置為紅色岩馍。

// 直線檢測
std::vector<cv::Vec4i> lines;
cv::HoughLinesP(outputMat, lines, 1, CV_PI/180, resizeMat.rows / 4, resizeMat.rows / 2, 5);
for (size_t i = 0; i < lines.size(); i++) {

    // 獲取直線收尾兩點
    cv::Vec4i line = lines[i];
    cv::Point point_1 = cv::Point(line[0],line[1]);
    cv::Point point_2 = cv::Point(line[2],line[3]);

    // 繪制直線
    cv::line(resizeMat, point_1, point_2, cv::Scalar(255,0,0,1));
}

處理結(jié)果:

opencv_line


  • 直線過濾

因需要識別答題卡的ROI區(qū)域(即每一道題的選項位置)吵聪,我們需要先識別出答題卡方框區(qū)域的四個頂點,以便根據(jù)四個頂點進行透視變換兼雄。

四個頂點的位置可以根據(jù)上吟逝、下、左赦肋、右四條直線块攒,兩兩相交的性質(zhì)分別求出,但是很不幸佃乘,如上圖所示囱井,進行直線檢測時通過設(shè)置合理的閾值參數(shù),能檢測處在出邊框范圍內(nèi)的很多條直線趣避,因此在計算四個交點之前庞呕,還需要先合理的過濾出上、下、左住练、右四條直線地啰。

為方便起見,這里直接在檢測出直線的時候進行過濾讲逛,過濾的規(guī)則很簡單亏吝,根據(jù)直線兩個端點分別相對于圖像中心點的位置,判斷出當(dāng)前直線屬于上下左右的哪一個方位盏混,并且只保留該方位第一條被檢測到的直線蔚鸥。

直線檢測及過濾的代碼如下:

// 直線檢測
std::vector<cv::Vec4i> lines;
cv::HoughLinesP(outputMat, lines, 1, CV_PI/180, resizeMat.rows / 4, resizeMat.rows / 2, 5);

cv::Vec4i filtLines[4];   // 過濾的線 [上,左许赃,下止喷,右]
int filtLineFlag[4] = {0};

cv::Point originPoint = cv::Point(resizeMat.rows / 2, resizeMat.cols / 2); // 圖像中心點

for (size_t i = 0; i < lines.size(); i++) {
    cv::Vec4i line = lines[i];
    cv::Point point_1 = cv::Point(line[0],line[1]);
    cv::Point point_2 = cv::Point(line[2],line[3]);

// 過濾線
    if (point_1.y > originPoint.y && point_2.y > originPoint.y &&       filtLineFlag[0] == 0) {
        filtLines[0] = line;
        filtLineFlag[0] = 1;
        cv::line(resizeMat, point_1, point_2, cv::Scalar(255,0,0,1));
    }

    if (point_1.x < originPoint.x && point_2.x < originPoint.x &&       filtLineFlag[1] == 0) {
        filtLines[1] = line;
        filtLineFlag[1] = 1;
        cv::line(resizeMat, point_1, point_2, cv::Scalar(255,0,0,1));
    }

    if (point_1.y < originPoint.y && point_2.y < originPoint.y &&       filtLineFlag[2] == 0) {
        filtLines[2] = line;
        filtLineFlag[2] = 1;
        cv::line(resizeMat, point_1, point_2, cv::Scalar(255,0,0,1));
    }

    if (point_1.x > originPoint.x && point_2.x > originPoint.x && filtLineFlag[3] == 0) {
        filtLines[3] = line;
        filtLineFlag[3] = 1;
        cv::line(resizeMat, point_1, point_2, cv::Scalar(255,0,0,1));
    }
    cv::line(resizeMat, point_1, point_2, cv::Scalar(255,0,0,1));
}


  • 計算四個頂點

上面通過簡單的過濾算法,得到了不同方位的四條直線混聊,并存放在 cv::Vec4i filtLines[4] 的容器內(nèi)弹谁,容器內(nèi)的線條和對應(yīng)方位為:[上,左技羔,下,右]卧抗。

接下來變可以分別取出對應(yīng)位置的兩條線段計算交點藤滥,計算交點需要使用簡單的數(shù)學(xué)公式,代碼如下社裆,不再贅述:

// 計算直線交點
cv::Point CrossPointWithLine(cv::Vec4i & line1, cv::Vec4i & line2) {

    int l1_1_x = line1[0];
    int l1_1_y = line1[1];
    int l1_2_x = line1[2];
    int l1_2_y = line1[3];

    float a = (l1_1_y - l1_2_y) / ((l1_1_x - l1_2_x) == 0 ? 1.0 :(l1_1_x - l1_2_x));
    float b = l1_1_y - l1_1_x * a;

    int l2_1_x = line2[0];
    int l2_1_y = line2[1];
    int l2_2_x = line2[2];
    int l2_2_y = line2[3];

    float c = (l2_1_y - l2_2_y) / ((l2_1_x - l2_2_x) == 0 ? 1.0 : (l2_1_x - l2_2_x));
    float d = l2_1_y - l2_1_x * c;

    float x = (d - b) / (a - c);
    float y = (a*d - b*c) / (a - c);

    return cv::Point(x,y);
}

使用容器將計算的交點拙绊,按照位置有序存儲:

std::vector<cv::Point> filtPoints; // 存放計算的焦點

filtPoints.push_back(CrossPointWithLine(filtLines[0], filtLines[1]));
filtPoints.push_back(CrossPointWithLine(filtLines[0], filtLines[3]));
filtPoints.push_back(CrossPointWithLine(filtLines[1], filtLines[2]));
filtPoints.push_back(CrossPointWithLine(filtLines[3], filtLines[2]));

已計算的四個交點為圓心畫圓,查看效果(四個頂點的圓畫的有點小泳秀,湊合看吧??):

opencv_crossPoint


  • 圖像糾偏

根據(jù)上述的四個頂點标沪,構(gòu)造透視變換的變換矩陣,利用 OpenCV Lib 的透視變換嗜傅,對灰度圖像金句,進行圖像糾偏處理。

// 構(gòu)造變換矩陣
cv::Point2f src_vertices[4];
src_vertices[0] = filtPoints[0];
src_vertices[1] = filtPoints[1];
src_vertices[2] = filtPoints[2];
src_vertices[3] = filtPoints[3];

cv::Point2f dst_vertices[4];
dst_vertices[0] = cv::Point(0,resizeMat.cols);
dst_vertices[1] = cv::Point(resizeMat.rows,resizeMat.cols);
dst_vertices[2] = cv::Point(0, 0);
dst_vertices[3] = cv::Point(resizeMat.rows,0);

// 透視變換
cv::Mat transform = cv::getPerspectiveTransform(src_vertices,dst_vertices);
cv::warpPerspective(grayMat, output, transform, cv::Size(resizeMat.rows, resizeMat.cols));

效果如下:

opencv_transform


  • 設(shè)置選項區(qū)域 ROI(感興趣區(qū)域)

觀察糾偏后的灰度圖像的規(guī)律吕嘀,可以按照每5道題設(shè)置一個 ROI违寞,并標(biāo)定出相應(yīng)的位置,這里需要特別注意的是偶房,上下左右以及 ROI 間隔的設(shè)置需要根據(jù)整個糾偏后的圖像大小的比例來確定趁曼,為了簡單起見,這里直接寫成固定值棕洋。算法如下:

// 設(shè)置ROI, 使用容器記錄ROI
std::vector<cv::Rect> ROIRect;

int leading = 40, trailing = 15, top = 5, bottom = 5, margin_col = 50, margin_row = 10, width = 0, height = 0, row = 8, col = 4;
width = (grayMat.cols - leading - trailing - margin_col * (col - 1)) / col;
height = (grayMat.rows - top - bottom - margin_row * (row - 1)) / row;

for (int i = 0; i < row; i++) {
    for (int j = 0; j < col; j++) {
        cv::Rect rect = cv::Rect(j * (width + margin_col) + leading, i * (height + margin_row + 0.7) + top, width, height);
        ROIRect.push_back(rect);

        cv::rectangle(writeMat, rect, cv::Scalar(255,0,0,1));
    }
}

將劃分的 ROI 使用方框標(biāo)記挡闰,效果如下:

opencv_ROIRect


  • 根據(jù)選項區(qū)域,設(shè)置每道題的 ROI

這一步對選項區(qū)域進一步拆分,計算出每一道題的 ROI摄悯≡藜荆可以和上面計算區(qū)域的方法合并,直接進行每道題的 ROI 拆分射众,能有效減少循環(huán)及計算次數(shù)碟摆,降低 CPU 負荷,這大概也是最后識別耗時比唐巧大佬多出0.03秒的原因之一叨橱,這里不再深究典蜕。

// 遍歷ROI,設(shè)置并記錄每道題的ROI
std::vector<cv::Rect> ROIItemRect;
for (int i = 0; i < ROIRect.size(); i++) {
    cv::Rect rect = ROIRect[i];
    int height = 0, margin_height = 0;
    height = (rect.height - margin_height * 4) / 5;
    for (int k = 0; k < 5; k++) {
        cv::Rect itemRect = cv::Rect(rect.x, rect.y + (height + margin_height) * k, rect.width, height);
        ROIItemRect.push_back(itemRect);

        cv::rectangle(writeMat, itemRect, cv::Scalar(255,0,0,1));
    }
}

效果如下:

opencv_ROIItemRect


  • 二值化糾偏后的灰度圖像罗洗,便于接下來的色值統(tǒng)計

這里對糾偏后的灰度圖進行二值化操作愉舔,其實不是必須的,為了性能提升減少耗時才做伙菜,在進行上面的圖像糾偏時可以直接對第一次二值化的圖像進行糾偏操作轩缤。但是在實現(xiàn)的過程中因為要控制每一步的顯示效果,這里多做了一次處理贩绕,可以忽略火的。

// 二值化 灰度圖
cv::Mat binaryMat;
cv::threshold(grayMat, binaryMat, 100, 255, cv::THRESH_BINARY);


  • 分割選項,統(tǒng)計色值淑倾,計算有效作答

對糾偏后的二值化圖像馏鹤,按照上述計算的每道題的 ROI,按照選項橫向均等劃分為 5 個區(qū)域娇哆,對應(yīng)答題卡中的 ABCDE 五個選項湃累,分別對每道題的 ROI 的 5 個區(qū)域的像素進行色值統(tǒng)計,統(tǒng)計出色值等于 0 的像素點個數(shù)碍讨,個數(shù)超過該選項總像素點的 25% 時即為有效作答治力,并且 log 出當(dāng)前的題號和選項值。算法如下:

for (int i = 0; i < ROIItemRect.size(); i++) {  // 遍歷每道題
    cv::Rect rect = ROIItemRect[i];
    // 分割選項
    int width = rect.width / 5;
    for (int k = 0; k < 5; k++) {
        cv::Rect itemRect = cv::Rect(rect.x + width * k, rect.y, width, rect.height);
        cv::Mat roiMat = binaryMat(itemRect);   // 截取ROI

        cv::rectangle(writeMat, itemRect, cv::Scalar(255,0,0,1));

        int count = 0;  // 統(tǒng)計色值
        for (int x = 0; x < roiMat.rows; x++) {
            for (int y = 0; y < roiMat.cols; y++) {

                if (roiMat.at<uchar>(x,y) == 0) {
                    count ++;
                }
            }
        }

        // 超過 25% 算作有效答案
        if (count > roiMat.rows * roiMat.cols * 0.25) {
              switch (k) {
                  case 0:
                      NSLog(@"第 %d 題:A",i + 1);
                  break;
                  case 1:
                      NSLog(@"第 %d 題:B",i + 1);
                  break;
                  case 2:
                      NSLog(@"第 %d 題:C",i + 1);
                  break;
                  case 3:
                      NSLog(@"第 %d 題:D",i + 1);
                  break;
                  case 4:
                      NSLog(@"第 %d 題:E",i + 1);
                  break;

                  default:
                  break;
            }
          continue;
        }
    }
}

最終的識別結(jié)果如下:

opencv_result



總結(jié)

這套解決方案的實現(xiàn)思路來源于唐巧大神的猿題庫iOS客戶端的技術(shù)細節(jié)(二):答題卡掃描算法 一文勃黍,本文實現(xiàn)的算法沒有經(jīng)過過多的測試宵统,僅能保證這張圖片的識別率在95%以上。另外覆获,在 CPU 運算耗時上面榜田,這些計算方式?jīng)]有進行優(yōu)化也不是最優(yōu)解。

唐巧大神沒有開源此算法锻梳,我這菜雞代碼大家湊合看吧箭券,源代碼不包含 opencv2.framework,請自行下載后添加進項目中疑枯。答題卡識別 Demo 地址:https://github.com/githubError/AnwserSheetIdentify

如有疑問辩块,請聯(lián)系我:http://www.cuipengfei.cn/

-EOF-

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子废亭,更是在濱河造成了極大的恐慌国章,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,324評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件豆村,死亡現(xiàn)場離奇詭異液兽,居然都是意外死亡,警方通過查閱死者的電腦和手機掌动,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,356評論 3 392
  • 文/潘曉璐 我一進店門四啰,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人粗恢,你說我怎么就攤上這事柑晒。” “怎么了眷射?”我有些...
    開封第一講書人閱讀 162,328評論 0 353
  • 文/不壞的土叔 我叫張陵匙赞,是天一觀的道長。 經(jīng)常有香客問我妖碉,道長涌庭,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,147評論 1 292
  • 正文 為了忘掉前任欧宜,我火速辦了婚禮坐榆,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘鱼鸠。我一直安慰自己猛拴,他們只是感情好羹铅,可當(dāng)我...
    茶點故事閱讀 67,160評論 6 388
  • 文/花漫 我一把揭開白布蚀狰。 她就那樣靜靜地躺著,像睡著了一般职员。 火紅的嫁衣襯著肌膚如雪麻蹋。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,115評論 1 296
  • 那天焊切,我揣著相機與錄音扮授,去河邊找鬼。 笑死专肪,一個胖子當(dāng)著我的面吹牛刹勃,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播嚎尤,決...
    沈念sama閱讀 40,025評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼荔仁,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起乏梁,我...
    開封第一講書人閱讀 38,867評論 0 274
  • 序言:老撾萬榮一對情侶失蹤次洼,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后遇骑,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體卖毁,經(jīng)...
    沈念sama閱讀 45,307評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,528評論 2 332
  • 正文 我和宋清朗相戀三年落萎,在試婚紗的時候發(fā)現(xiàn)自己被綠了亥啦。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,688評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡模暗,死狀恐怖禁悠,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情兑宇,我是刑警寧澤碍侦,帶...
    沈念sama閱讀 35,409評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站隶糕,受9級特大地震影響瓷产,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜枚驻,卻給世界環(huán)境...
    茶點故事閱讀 41,001評論 3 325
  • 文/蒙蒙 一濒旦、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧再登,春花似錦尔邓、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,657評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至沽损,卻和暖如春灯节,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背绵估。 一陣腳步聲響...
    開封第一講書人閱讀 32,811評論 1 268
  • 我被黑心中介騙來泰國打工炎疆, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人国裳。 一個月前我還...
    沈念sama閱讀 47,685評論 2 368
  • 正文 我出身青樓形入,卻偏偏與公主長得像,于是被迫代替她去往敵國和親缝左。 傳聞我的和親對象是個殘疾皇子亿遂,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,573評論 2 353

推薦閱讀更多精彩內(nèi)容