本篇文章介紹EasyPR裡新的定位功能:顏色定位與偏斜扭正。希望這篇文件可以幫助開發者與使用者更好的理解EasyPR的設計思想。
讓我們先看一下示例圖片,這幅圖片中的車牌通過顏色的定位法進行定位並從偏斜的視角中扭正為正視角(請看右圖的左上角)。
圖1 新版本的定位效果
下面內容會對這兩個特性的實現過程展開具體的介紹。首先介紹顏色定位的原理,然後是偏斜扭正的實現細節。
由於本文較長,為方便讀者,以下是本文的目錄:
一.顏色定位
1.1起源
1.2方法
1.3不足與改善
二.偏斜扭正
2.1分析
2.2ROI擷取
2.3擴大化旋轉
2.4偏斜判斷
2.5仿射變換
2.6總結
三.總結
在前面的介紹裡,我們使用了Sobel查詢垂直邊緣的方法,成功定位了許多車牌。但是,Sobel法最大的問題就在於面對垂直邊緣交錯的情況下,無法準確地定位車牌。例如下圖。為了解決這個問題,可以考慮使用顏色資訊進行定位。
圖2 顏色定位與Sobel定位的比較
如果將顏色定位與Sobel定位加以結合的話,可以使車牌的定位準確率從75%上升到94%。
關於顏色定位首先我們想到的解決方案就是:利用RGB值來判斷。
這個想法聽起來很自然:如果我們想找出一幅影像中的藍色部分,那麼我們只需要檢查RGB分量(RGB分量由Red分量--紅色,Green分量--綠色,Blue分量--藍色共同組成)中的Blue分量就可以了。一般來說,Blue分量是個0到255的值。如果我們設定一個閾值,並且檢查每個畫素的Blue分量是否大於它,那我們不就可以得知這些畫素是不是藍色的了麼?這個想法雖然很好,不過存在一個問題,我們該怎麼來選擇這個閾值?這是第一個問題。
即便我們用一些方法決定了閾值以後,那麼下面的一個問題就會讓人抓狂,顏色是組合的,即便藍色屬性在255(這樣已經很‘藍’了吧),只要另外兩個分量配合(例如都為255),你最後得到的不是藍色,而是黑色。
這還只是區分藍色的問題,黃色更麻煩,它是由紅色和綠色組合而成的,這意味著你需要考慮兩個變數的配比問題。這些問題讓選擇RGB顏色作為判斷的難度大到難以接受的地步。因此必須另想辦法。
為了解決各種顏色相關的問題,人們發明了各種顏色模型。其中有一個模型,非常適合解決顏色判斷的問題。這個模型就是HSV模型。
圖3 HSV顏色模型
HSV模型是根據顏色的直觀特性建立的一種圓錐模型。與RGB顏色模型中的每個分量都代表一種顏色不同的是,HSV模型中每個分量並不代表一種顏色,而分別是:色調(H),飽和度(S),亮度(V)。
H分量是代表顏色特性的分量,用角度度量,取值範圍為0~360,從紅色開始按逆時針方向計算,紅色為0,綠色為120,藍色為240。S分量代表顏色的飽和資訊,取值範圍為0.0~1.0,值越大,顏色越飽和。V分量代表明暗資訊,取值範圍為0.0~1.0,值越大,色彩越明亮。
H分量是HSV模型中唯一跟顏色本質相關的分量。只要固定了H的值,並且保持S和V分量不太小,那麼表現的顏色就會基本固定。為了判斷藍色車牌顏色的範圍,可以固定了S和V兩個值為1以後,調整H的值,然後看顏色的變化範圍。通過一段摸索,可以發現當H的取值範圍在200到280時,這些顏色都可以被認為是藍色車牌的顏色範疇。於是我們可以用H分量是否在200與280之間來決定某個畫素是否屬於藍色車牌。黃色車牌也是一樣的道理,通過觀察,可以發現當H值在30到80時,顏色的值可以作為黃色車牌的顏色。
這裡的顏色表來自於這個網站。
下圖顯示了藍色的H分量變化範圍。
圖4 藍色的H分量區間
下圖顯示了黃色的H分量變化範圍。
圖5 黃色的H分量區間
光判斷H分量的值是否就足夠了?
事實上是不足的。固定了H的值以後,如果移動V和S會帶來顏色的飽和度和亮度的變化。當V和S都達到最高值,也就是1時,顏色是最純正的。降低S,顏色越發趨向於變白。降低V,顏色趨向於變黑,當V為0時,顏色變為黑色。因此,S和V的值也會影響最終顏色的效果。
我們可以設定一個閾值,假設S和V都大於閾值時,顏色才屬於H所表達的顏色。
在EasyPR裡,這個值是0.35,也就是V屬於0.35到1且S屬於0.35到1的一個範圍,類似於一個矩形。對V和S的閾值判斷是有必要的,因為很多車牌周身的車身,都是H分量屬於200-280,而V分量或者S分量小於0.35的。通過S和V的判斷可以排除車牌周圍車身的干擾。
圖6 V和S的區間
明確了使用HSV模型以及用閾值進行判斷以後,下面就是一個顏色定位的完整過程。
第一步,將影像的顏色空間從RGB轉為HSV,在這裡由於光照的影響,對於影像使用直方圖均衡進行預處理;
第二步,依次遍歷影像的所有畫素,當H值落在200-280之間並且S值與V值也落在0.35-1.0之間,標記為白色畫素,否則為黑色畫素;
第三步,對僅有白黑兩個顏色的二值圖參照原先車牌定位中的方法,使用閉操作,取輪廓等方法將車牌的外接矩形擷取出來做進一步的處理。
圖7 藍色定位效果
以上就完成了一個藍色車牌的定位過程。我們把對影像中藍色車牌的尋找過程稱為一次與藍色模板的匹配過程。程式碼中的函式稱之為colorMatch。一般說來,一幅影像需要進行一次藍色模板的匹配,還要進行一次黃色模板的匹配,以此確保藍色和黃色的車牌都被定位出來。
黃色車牌的定位方法與其類似,僅僅只是H閾值範圍的不同。事實上,黃色定位的效果一般好的出奇,可以在非常複雜的環境下將車牌極為準確的定位出來,這可能源於現實世界中黃色非常醒目的原因。
圖8 黃色定位效果
從實際效果來看,顏色定位的效果是很好的。在通用資料測試集裡,大約70%的車牌都可以被定位出來(一些顏色定位不了的,我們可以用Sobel定位處理)。
在程式碼中有些細節需要注意:
一. opencv為了保證HSV三個分量都落在0-255之間(確保一個char能裝的下),對H分量除以了2,也就是0-180的範圍,S和V分量乘以了255,將0-1的範圍擴充套件到0-255。我們在設定閾值的時候需要參照opencv的標準,因此對引數要進行一個轉換。
二. 是v和s取值的問題。對於暗的圖來說,取值過大容易漏,而對於亮的圖,取值過小則容易跟車身混淆。因此可以考慮最適應的改變閾值。
三. 是模板問題。目前的做法是針對藍色和黃色的匹配使用了兩個模板,而不是統一的模板。統一模板的問題在於擔心藍色和黃色的干擾問題,例如黃色的車與藍色的牌的干擾,或者藍色的車和黃色牌的干擾,這裡面最典型的例子就是一個帶有藍色車牌的黃色計程車,在很多城市裡這已經是“標準配置”。因此需要將藍色和黃色的匹配分別用不同的模板處理。
瞭解完這三個細節以後,下面就是程式碼部分。
//! 根據一幅影像與顏色模板獲取對應的二值圖 //! 輸入RGB影像, 顏色模板(藍色、黃色) //! 輸出灰度圖(只有0和255兩個值,255代表匹配,0代表不匹配) Mat colorMatch(const Mat& src, Mat& match, const Color r, const bool adaptive_minsv) { // S和V的最小值由adaptive_minsv這個bool值判斷 // 如果為true,則最小值取決於H值,按比例衰減 // 如果為false,則不再自適應,使用固定的最小值minabs_sv // 預設為false const float max_sv = 255; const float minref_sv = 64; const float minabs_sv = 95; //blue的H範圍 const int min_blue = 100; //100 const int max_blue = 140; //140 //yellow的H範圍 const int min_yellow = 15; //15 const int max_yellow = 40; //40 Mat src_hsv; // 轉到HSV空間進行處理,顏色搜尋主要使用的是H分量進行藍色與黃色的匹配工作 cvtColor(src, src_hsv, CV_BGR2HSV); vector<Mat> hsvSplit; split(src_hsv, hsvSplit); equalizeHist(hsvSplit[2], hsvSplit[2]); merge(hsvSplit, src_hsv); //匹配模板基色,切換以查詢想要的基色 int min_h = 0; int max_h = 0; switch (r) { case BLUE: min_h = min_blue; max_h = max_blue; break; case YELLOW: min_h = min_yellow; max_h = max_yellow; break; } float diff_h = float((max_h - min_h) / 2); int avg_h = min_h + diff_h; int channels = src_hsv.channels(); int nRows = src_hsv.rows; //影像資料列需要考慮通道數的影響; int nCols = src_hsv.cols * channels; if (src_hsv.isContinuous())//連續儲存的資料,按一行處理 { nCols *= nRows; nRows = 1; } int i, j; uchar* p; float s_all = 0; float v_all = 0; float count = 0; for (i = 0; i < nRows; ++i) { p = src_hsv.ptr<uchar>(i); for (j = 0; j < nCols; j += 3) { int H = int(p[j]); //0-180 int S = int(p[j + 1]); //0-255 int V = int(p[j + 2]); //0-255 s_all += S; v_all += V; count++; bool colorMatched = false; if (H > min_h && H < max_h) { int Hdiff = 0; if (H > avg_h) Hdiff = H - avg_h; else Hdiff = avg_h - H; float Hdiff_p = float(Hdiff) / diff_h; // S和V的最小值由adaptive_minsv這個bool值判斷 // 如果為true,則最小值取決於H值,按比例衰減 // 如果為false,則不再自適應,使用固定的最小值minabs_sv float min_sv = 0; if (true == adaptive_minsv) min_sv = minref_sv - minref_sv / 2 * (1 - Hdiff_p); // inref_sv - minref_sv / 2 * (1 - Hdiff_p) else min_sv = minabs_sv; // add if ((S > min_sv && S < max_sv) && (V > min_sv && V < max_sv)) colorMatched = true; } if (colorMatched == true) { p[j] = 0; p[j + 1] = 0; p[j + 2] = 255; } else { p[j] = 0; p[j + 1] = 0; p[j + 2] = 0; } } } //cout << "avg_s:" << s_all / count << endl; //cout << "avg_v:" << v_all / count << endl; // 獲取顏色匹配後的二值灰度圖 Mat src_grey; vector<Mat> hsvSplit_done; split(src_hsv, hsvSplit_done); src_grey = hsvSplit_done[2]; match = src_grey; return src_grey; }
以上說明了顏色定位的設計思想與細節。那麼顏色定位是不是就是萬能的?答案是否定的。在色彩充足,光照足夠的情況下,顏色定位的效果很好,但是在面對光線不足的情況,或者藍色車身的情況時,顏色定位的效果很糟糕。下圖是一輛藍色車輛,可以看出,車牌與車身內容完全重疊,無法分割。
圖9 失效的顏色定位
碰到失效的顏色定位情況時需要使用原先的Sobel定位法。
目前的新版本使用了顏色定位與Sobel定位結合的方式。首先進行顏色定位,然後根據條件使用Sobel進行再次定位,增加整個系統的適應能力。
為了加強魯棒性,Sobel定位法可以用兩階段的查詢。也就是在已經被Sobel定位的圖塊中,再進行一次Sobel定位。這樣可以增加準確率,但會降低了速度。一個折衷的方案是讓使用者決定一個引數m_maxPlates的值,這個值決定了你在一幅圖裡最多定位多少車牌。系統首先用顏色定位出候選車牌,然後通過SVM模型來判斷是否是車牌,最後統計數量。如果這個數量大於你設定的引數,則認為車牌已經定位足夠了,不需要後一步處理,也就不會進行兩階段的Sobel查詢。相反,如果這個數量不足,則繼續進行Sobel定位。
綜合定位的程式碼位於CPlateDectec中的的成員函式plateDetectDeep中,以下是plateDetectDeep的整體流程。
圖10 綜合定位全部流程
有沒有顏色定位與Sobel定位都失效的情況?有的。這種情況下可能需要使用第三類定位技術--字元定位技術。這是EasyPR發展的一個方向,這裡不展開討論。
解決了顏色的定位問題以後,下面的問題是:在定位以後,我們如何把偏斜過來的車牌扭正呢?
圖11 偏斜扭轉效果
這個過程叫做偏斜扭轉過程。其中一個關鍵函式就是opencv的仿射變換函式。但在具體實施時,有很多需要解決的問題。
在任何新的功能開發之前,技術預研都是第一步。
在這篇文件介紹了opencv的仿射變換功能。效果見下圖。
圖12 仿射變換效果
仔細看下,貌似這個功能跟我們的需求很相似。我們的偏斜扭轉功能,說白了,就是把對影像的觀察視角進行了一個轉換。
不過這篇文章裡的程式碼基本來自於另一篇官方文件。官方文件裡還有一個例子,可以矩形扭轉成平行四邊形。而我們的需求正是將平行四邊形的車牌扭正成矩形。這麼說來,只要使用例子中對應的反函式,應該就可以實現我們的需求。從這個角度來看,偏斜扭轉功可以實現。確定了可行性以後,下一步就是思考如何實現。
在原先的版本中,我們對定位出來的區域會進行一次角度判斷,當角度小於某個閾值(預設30度)時就會進行全圖旋轉。
這種方式有兩個問題:
一是我們的策略是對整幅影像旋轉。對於opencv來說,每次旋轉操作都是一個矩形的乘法過程,對於非常大的影像,這個過程是非常消耗計算資源的;
二是30度的閾值無法處理示例圖片。事實上,示例圖片的定位區域的角度是-50度左右,已經大於我們的閾值了。為了處理這樣的圖片,我們需要把我們的閾值增大,例如增加到60度,那麼這樣的結果是帶來候選區域的增多。
兩個因素結合,會大幅度增加處理時間。為了不讓處理速度下降,必須想辦法規避這些影響。
一個方法是不再使用全圖旋轉,而是區域旋轉。其實我們在獲取定位區域後,我們並不需要定位區域以外的影像。
倘若我們能劃出一塊小的區域包圍定位區域,然後我們僅對定位區域進行旋轉,那麼計算量就會大幅度降低。而這點,在opencv裡是可以實現的,我們對定位區域RotatedRect用boundingRect()方法獲取外接矩形,再使用Mat(Rect ...)方法擷取這個區域圖塊,從而生成一個小的區域影像。於是下面的所有旋轉等操作都可以基於這個區域影像進行。
在這些設計決定以後,下面就來思考整個功能的架構。
我們要解決的問題包括三類,第一類是正的車牌,第二類是傾斜的車牌,第三類是偏斜的車牌。前兩類是前面說過的,第三類是本次新增的功能需求。第二類傾斜車牌與第三類車牌的區別見下圖。
圖13 兩類不同的旋轉
通過上圖可以看出,正視角的旋轉圖片的觀察角度仍然是正方向的,只是由於路的不平或者攝像機的傾斜等原因,導致矩形有一定傾斜。這類圖塊的特點就是在RotataedRect內部,車牌部分仍然是個矩形。偏斜視角的圖片的觀察角度是非正方向的,是從側面去看車牌。這類圖塊的特點是在RotataedRect內部,車牌部分不再是個矩形,而是一個平行四邊形。這個特性決定了我們需要區別的對待這兩類圖片。
一個初步的處理思路就是下圖。
圖14 分析實現流程
簡單來說,整個處理流程包括下面四步:
1.感興趣區域的擷取
2.角度判斷
3.偏斜判斷
4.仿射變換
接下來按照這四個步驟依次介紹。
如果要使用區域旋轉,首先我們必須從原圖中擷取出一個包含定位區域的圖塊。
opencv提供了一個從影像中擷取感興趣區域ROI的方法,也就是Mat(Rect ...)。這個方法會在Rect所在的位置,擷取原圖中一個圖塊,然後將其賦值到一個新的Mat影像裡。遺憾的是這個方法不支援RotataedRect,同時Rect與RotataedRect也沒有繼承關係。因此布不能直接呼叫這個方法。
我們可以使用RotataedRect的boudingRect()方法。這個方法會返回一個RotataedRect的最小外接矩形,而且這個矩形是一個Rect。因此將這個Rect傳遞給Mat(Rect...)方法就可以擷取出原圖的ROI圖塊,並獲得對應的ROI影像。
需要注意的是,ROI圖塊和ROI影像的區別,當我們給定原圖以及一個Rect時,原圖中被Rect包圍的區域稱為ROI圖塊,此時圖塊裡的座標仍然是原圖的座標。當這個圖塊裡的內容被拷貝到一個新的Mat裡時,我們稱這個新Mat為ROI影像。ROI影像裡僅僅只包含原來圖塊裡的內容,跟原圖沒有任何關係。所以圖塊和影像雖然顯示的內容一樣,但座標系已經發生了改變。在從ROI圖塊到ROI影像以後,點的座標要計算一個偏移量。
下一步的工作中可以僅對這個ROI影像進行處理,包括對其旋轉或者變換等操作。
示例圖片中的擷取出來的ROI影像如下圖:
圖15 擷取後的ROI影像
在擷取中可能會發生一個問題。如果直接使用boundingRect()函式的話,在執行過程中會經常發生這樣的異常。OpenCV Error: Assertion failed (0 <= roi.x && 0 <= roi.width && roi.x + roi.width <= m.cols && 0 <= roi.y && 0 <= roi.height && roi.y + roi.height <= m.rows) incv::Mat::Mat,如下圖。
圖16 不安全的外接矩形函式會丟擲異常
這個異常產生的原因在於,在opencv2.4.8中(不清楚opencv其他版本是否沒有這個問題),boundingRect()函式計算出的Rect的四個點的座標沒有做驗證。這意味著你計算一個RotataedRect的最小外接矩形Rect時,它可能會給你一個負座標,或者是一個超過原圖片外界的座標。於是當你把Rect作為引數傳遞給Mat(Rect ...)的話,它會提示你所要擷取的Rect中的座標越界了!
解決方案是實現一個安全的計算最小外接矩形Rect的函式,在boundingRect()結果之上,對角點座標進行一次判斷,如果值為負數,就置為0,如果值超過了原始Mat的rows或cols,就置為原始Mat的這些rows或cols。
這個安全函式名為calcSafeRect(...),下面是這個函式的程式碼。
//! 計算一個安全的Rect //! 如果不存在,返回false bool CPlateLocate::calcSafeRect(const RotatedRect& roi_rect, const Mat& src, Rect_<float>& safeBoundRect) { Rect_<float> boudRect = roi_rect.boundingRect(); // boudRect的左上的x和y有可能小於0 float tl_x = boudRect.x > 0 ? boudRect.x : 0; float tl_y = boudRect.y > 0 ? boudRect.y : 0; // boudRect的右下的x和y有可能大於src的範圍 float br_x = boudRect.x + boudRect.width < src.cols ? boudRect.x + boudRect.width - 1 : src.cols - 1; float br_y = boudRect.y + boudRect.height < src.rows ? boudRect.y + boudRect.height - 1 : src.rows - 1; float roi_width = br_x - tl_x; float roi_height = br_y - tl_y; if (roi_width <= 0 || roi_height <= 0) return false; // 新建一個mat,確保地址不越界,以防mat定位roi時拋異常 safeBoundRect = Rect_<float>(tl_x, tl_y, roi_width, roi_height); return true; }
好,當我通過calcSafeRect(...)獲取了一個安全的Rect,然後通過Mat(Rect ...)函式擷取了這個感興趣影像ROI以後。下面的工作就是對這個新的ROI影像進行操作。
首先是判斷這個ROI影像是否要旋轉。為了降低工作量,我們不對角度在-5度到5度區間的ROI進行旋轉(注意這裡講的角度針對的生成ROI的RotataedRect,ROI本身是水平的)。因為這麼小的角度對於SVM判斷以及字元識別來說,都是沒有影響的。
對其他的角度我們需要對ROI進行旋轉。當我們對ROI進行旋轉以後,接著把轉正後的RotataedRect部分從ROI中擷取出來。
但很快我們就會碰到一個新問題。讓我們看一下下圖,為什麼我們擷取出來的車牌區域最左邊的“川”字和右邊的“2”字發生了形變?為了搞清這個原因,作者仔細地研究了旋轉與擷取函式,但很快發現了形變的根源在於旋轉後的ROI影像。
仔細看一下旋轉後的ROI影像,是否左右兩側不再完整,像是被截去了一部分?
圖17 旋轉後影像被截斷
要想理解這個問題,需要理解opencv的旋轉變換函式的特性。作為旋轉變換的核心函式,affinTransform會要求你輸出一個旋轉矩陣給它。這很簡單,因為我們只需要給它一個旋轉中心點以及角度,它就能計算出我們想要的旋轉矩陣。旋轉矩陣的獲得是通過如下的函式得到的:
Mat rot_mat = getRotationMatrix2D(new_center, angle, 1);
在獲取了旋轉矩陣rot_mat,那麼接下來就需要呼叫函式warpAffine來開始旋轉操作。這個函式的引數包括一個目標影像、以及目標影像的Size。目標影像容易理解,大部分opencv的函式都會需要這個引數。我們只要新建一個Mat即可。那麼目標影像的Size是什麼?在一般的觀點中,假設我們需要旋轉一個影像,我們給opencv一個原始影像,以及我需要在某個旋轉點對它旋轉一個角度的需求,那麼opencv返回一個影像給我即可,這個影像的Size或者說大小應該是opencv返回給我的,為什麼要我來告訴它呢?
你可以試著對一個正方形進行旋轉,仔細看看,這個正方形的外接矩形的大小會如何變化?當旋轉角度還小時,一切都還好,當角度變大時,明顯我們看到的外接矩形的大小也在擴增。在這裡,外接矩形被稱為視框,也就是我需要旋轉的正方形所需要的最小區域。隨著旋轉角度的變大,視框明顯增大。
圖18 矩形旋轉後所需視框增大
在影像旋轉完以後,有三類點會獲得不同的處理,一種是有原影像對應點且在視框內的,這些點被正常顯示;一類是在視框內但找不到原影像與之對應的點,這些點被置0值(顯示為黑色);最後一類是有原影像與之對應的點,但不在視框內的,這些點被悲慘的拋棄。
圖19 旋轉後三類不同點的命運
這就是旋轉後不同三類點的命運,也就是新生成的影像中一些點呈現黑色(被置0),一些點被截斷(被拋棄)的原因。如果把視框調整大點的話,就可以大幅度減少被截斷點的數量。所以,為了保證旋轉後的影像不被截斷,因此我們需要計算一個合理的目標影像的Size,讓我們的感興趣區域得到完整的顯示。
下面的程式碼使用了一個極為簡單的策略,它將原始影像與目標影像都進行了擴大化。首先新建一個尺寸為原始影像1.5倍的新影像,接著把原始影像對映到新影像上,於是我們得到了一個顯示區域(視框)擴大化後的原始影像。顯示區域擴大以後,那些在原影像中沒有值的畫素被置了一個初值。
接著呼叫warpAffine函式,使用新影像的大小作為目標影像的大小。warpAffine函式會將新影像旋轉,並用目標影像尺寸的視框去顯示它。於是我們得到了一個所有感興趣區域都被完整顯示的旋轉後影像。
這樣,我們再使用getRectSubPix()函式就可以獲得想要的車牌區域了。
圖20 擴大化旋轉後影像不再被截斷
以下就是旋轉函式rotation的程式碼。
//! 旋轉操作 bool CPlateLocate::rotation(Mat& in, Mat& out, const Size rect_size, const Point2f center, const double angle) { Mat in_large; in_large.create(in.rows*1.5, in.cols*1.5, in.type()); int x = in_large.cols / 2 - center.x > 0 ? in_large.cols / 2 - center.x : 0; int y = in_large.rows / 2 - center.y > 0 ? in_large.rows / 2 - center.y : 0; int width = x + in.cols < in_large.cols ? in.cols : in_large.cols - x; int height = y + in.rows < in_large.rows ? in.rows : in_large.rows - y; /*assert(width == in.cols); assert(height == in.rows);*/ if (width != in.cols || height != in.rows) return false; Mat imageRoi = in_large(Rect(x, y, width, height)); addWeighted(imageRoi, 0, in, 1, 0, imageRoi); Point2f center_diff(in.cols/2, in.rows/2); Point2f new_center(in_large.cols / 2, in_large.rows / 2); Mat rot_mat = getRotationMatrix2D(new_center, angle, 1); /*imshow("in_copy", in_large); waitKey(0);*/ Mat mat_rotated; warpAffine(in_large, mat_rotated, rot_mat, Size(in_large.cols, in_large.rows), CV_INTER_CUBIC); /*imshow("mat_rotated", mat_rotated); waitKey(0);*/ Mat img_crop; getRectSubPix(mat_rotated, Size(rect_size.width, rect_size.height), new_center, img_crop); out = img_crop; /*imshow("img_crop", img_crop); waitKey(0);*/ return true; }
當我們對ROI進行旋轉以後,下面一步工作就是把RotataedRect部分從ROI中擷取出來,這裡可以使用getRectSubPix方法,這個函式可以在被旋轉後的影像中擷取一個正的矩形圖塊出來,並賦值到一個新的Mat中,稱為車牌區域。
下步工作就是分析擷取後的車牌區域。車牌區域裡的車牌分為正角度和偏斜角度兩種。對於正的角度而言,可以看出車牌區域就是車牌,因此直接輸出即可。而對於偏斜角度而言,車牌是平行四邊形,與矩形的車牌區域不重合。
如何判斷一個影像中的圖形是否是平行四邊形?
一種簡單的思路就是對影像二值化,然後根據二值化影像進行判斷。影像二值化的方法有很多種,假設我們這裡使用一開始在車牌定位功能中使用的大津閾值二值化法的話,效果不會太好。因為大津閾值是自適應閾值,在完整的影像中二值出來的平行四邊形可能在小的區域性影像中就不再是。最好的辦法是使用在前面定位模組生成後的原圖的二值影像,我們通過同樣的操作就可以在原圖中擷取一個跟車牌區域對應的二值化影像。
下圖就是一個二值化車牌區域獲得的過程。
圖21 二值化的車牌區域
接下來就是對二值化車牌區域進行處理。為了判斷二值化影像中白色的部分是平行四邊形。一種簡單的做法就是從影像中選擇一些特定的行。計算在這個行中,第一個全為0的串的長度。從幾何意義上來看,這就是平行四邊形斜邊上某個點距離外接矩形的長度。
假設我們選擇的這些行位於二值化影像高度的1/4,2/4,3/4處的話,如果是白色圖形是矩形的話,這些串的大小應該是相等或者相差很小的,相反如果是平行四邊形的話,那麼這些串的大小應該不等,並且呈現一個遞增或遞減的關係。通過這種不同,我們就可以判斷車牌區域裡的圖形,究竟是矩形還是平行四邊形。
偏斜判斷的另一個重要作用就是,計算平行四邊形傾斜的斜率,這個斜率值用來在下面的仿射變換中發揮作用。我們使用一個簡單的公式去計算這個斜率,那就是利用上面判斷過程中使用的串大小,假設二值化影像高度的1/4,2/4,3/4處對應的串的大小分別為len1,len2,len3,車牌區域的高度為Height。一個計算斜率slope的計算公式就是:(len3-len1)/Height*2。
Slope的直觀含義見下圖。
圖22 slope的幾何含義
需要說明的,這個計算結果在平行四邊形是右斜時是負值,而在左斜時則是正值。於是可以根據slope的正負判斷平行四邊形是右斜或者左斜。在實踐中,會發生一些公式不能應對的情況,例如像下圖這種情況,斜邊的部分割槽域發生了內凹或者外凸現象。這種現象會導致len1,len2或者len3的計算有誤,因此slope也會不準。
圖23 內凹現象
為了實現一個魯棒性更好的計算方法,可以用(len2-len1)/Height*4與(len3-len1)/Height*2兩者之間更靠近tan(angle)的值作為solpe的值(在這裡,angle代表的是原來RotataedRect的角度)。
多采取了一個slope備選的好處是可以避免單點的內凹或者外凸,但這仍然不是最好的解決方案。在最後的討論中會介紹一個其他的實現思路。
完成偏斜判斷與斜率計算的函式是isdeflection,下面是它的程式碼。
//! 是否偏斜 //! 輸入二值化影像,輸出判斷結果 bool CPlateLocate::isdeflection(const Mat& in, const double angle, double& slope) { int nRows = in.rows; int nCols = in.cols; assert(in.channels() == 1); int comp_index[3]; int len[3]; comp_index[0] = nRows / 4; comp_index[1] = nRows / 4 * 2; comp_index[2] = nRows / 4 * 3; const uchar* p; for (int i = 0; i < 3; i++) { int index = comp_index[i]; p = in.ptr<uchar>(index); int j = 0; int value = 0; while (0 == value && j < nCols) value = int(p[j++]); len[i] = j; } //cout << "len[0]:" << len[0] << endl; //cout << "len[1]:" << len[1] << endl; //cout << "len[2]:" << len[2] << endl; double maxlen = max(len[2], len[0]); double minlen = min(len[2], len[0]); double difflen = abs(len[2] - len[0]); //cout << "nCols:" << nCols << endl; double PI = 3.14159265; double g = tan(angle * PI / 180.0); if (maxlen - len[1] > nCols/32 || len[1] - minlen > nCols/32 ) { // 如果斜率為正,則底部在下,反之在上 double slope_can_1 = double(len[2] - len[0]) / double(comp_index[1]); double slope_can_2 = double(len[1] - len[0]) / double(comp_index[0]); double slope_can_3 = double(len[2] - len[1]) / double(comp_index[0]); /*cout << "slope_can_1:" << slope_can_1 << endl; cout << "slope_can_2:" << slope_can_2 << endl; cout << "slope_can_3:" << slope_can_3 << endl;*/ slope = abs(slope_can_1 - g) <= abs(slope_can_2 - g) ? slope_can_1 : slope_can_2; /*slope = max( double(len[2] - len[0]) / double(comp_index[1]), double(len[1] - len[0]) / double(comp_index[0]));*/ //cout << "slope:" << slope << endl; return true; } else { slope = 0; } return false; }
俗話說:行百里者半九十。前面已經做了如此多的工作,應該可以實現偏斜扭轉功能了吧?但在最後的道路中,仍然有問題等著我們。
我們已經實現了旋轉功能,並且在旋轉後的區域中擷取了車牌區域,然後判斷車牌區域中的圖形是一個平行四邊形。下面要做的工作就是把平行四邊形扭正成一個矩形。
圖24 從平行四邊形車牌到矩形車牌
首先第一個問題就是解決如何從平行四邊形變換成一個矩形的問題。opencv提供了一個函式warpAffine,就是仿射變換函式。注意,warpAffine不僅可以讓影像旋轉(前面介紹過),也可以進行仿射變換,真是一個多才多藝的函式。o
通過仿射變換函式可以把任意的矩形拉伸成其他的平行四邊形。opencv的官方文件裡給了一個示例,值得注意的是,這個示例演示的是把矩形變換為平行四邊形,跟我們想要的恰恰相反。但沒關係,我們先看一下它的使用方法。
圖25 opencv官網上對warpAffine使用的示例
warpAffine方法要求輸入的引數是原始影像的左上點,右上點,左下點,以及輸出影像的左上點,右上點,左下點。注意,必須保證這些點的對應順序,否則仿射的效果跟你預想的不一樣。通過這個方法介紹,我們可以大概看出,opencv需要的是三個點對(共六個點)的座標,然後建立一個對映關係,通過這個對映關係將原始影像的所有點對映到目標影像上。
圖26 warpAffine需要的三個對應座標點
再回來看一下我們的需求,我們的目標是把車牌區域中的平行四邊形對映為一個矩形。讓我們做個假設,如果我們選取了車牌區域中的平行四邊形車牌的三個關鍵點,然後再確定了我們希望將車牌扭正成的矩形的三個關鍵點的話,我們是否就可以實現從平行四邊形車牌到矩形車牌的扭正?
讓我們畫一幅影像來看看這個變換的作用。有趣的是,把一個平行四邊形變換為矩形會對包圍平行四邊形車牌的區域帶來影響。
例如下圖中,藍色的實線代表扭轉前的平行四邊形車牌,虛線代表扭轉後的。黑色的實線代表矩形的車牌區域,虛線代表扭轉後的效果。可以看到,當藍色車牌被扭轉為矩形的同時,黑色車牌區域則被扭轉為平行四邊形。
注意,當車牌區域扭變為平行四邊形以後,需要顯示它的視框增大了。跟我們在旋轉影像時碰到的情形一樣。
圖27 平行四邊形的扭轉帶來的變化
讓我們先實際嘗試一下仿射變換吧。
根據仿射函式的需要,我們計算平行四邊形車牌的三個關鍵點座標。其中左上點的值(xdiff,0)中的xdiff就是根據車牌區域的高度height與平行四邊形的斜率slope計算得到的:
xidff = Height * abs(slope)
為了計算目標矩形的三個關鍵點座標,我們首先需要把扭轉後的原點座標調整到平行四邊形車牌區域左上角位置。見下圖。
圖28 原影像的座標計算
依次推算關鍵點的三個座標。它們應該是
plTri[0] = Point2f(0 + xiff, 0); plTri[1] = Point2f(width - 1, 0); plTri[2] = Point2f(0, height - 1); dstTri[0] = Point2f(xiff, 0); dstTri[1] = Point2f(width - 1, 0); dstTri[2] = Point2f(xiff, height - 1);
根據上圖的座標,我們開始進行一次仿射變換的嘗試。
opencv的warpAffine函式不會改變變換後影像的大小。而我們給它傳遞的目標影像的大小僅會決定視框的大小。不過這次我們不用擔心視框的大小,因為根據圖27看來,哪怕視框跟原始影像一樣大,我們也足夠顯示扭正後的車牌。
看看仿射的效果。暈,好像效果不對,視框的大小是足夠了,但是影像往右偏了一些,導致最右邊的字母沒有顯示全。
圖29 被偏移的車牌區域
這次的問題不再是目標影像的大小問題了,而是視框的偏移問題。仔細觀察一下我們的視框,倘若我們想把車牌全部顯示的話,視框往右偏移一段距離,是不是就可以解決這個問題呢?為保證新的視框中心能夠正好與車牌的中心重合,我們可以選擇偏移xidff/2長度。正如下圖所顯示的一樣。
圖30 考慮偏移的座標計算
視框往右偏移的含義就是目標影像Mat的原點往右偏移。如果原點偏移的話,那麼仿射後影像的三個關鍵點的座標要重新計算,都需要減去xidff/2大小。
重新計算的對映點座標為下:
plTri[0] = Point2f(0 + xiff, 0); plTri[1] = Point2f(width - 1, 0); plTri[2] = Point2f(0, height - 1); dstTri[0] = Point2f(xiff/2, 0); dstTri[1] = Point2f(width - 1 - xiff + xiff/2, 0); dstTri[2] = Point2f(xiff/2, height - 1);
再試一次。果然,視框被調整到我們希望的地方了,我們可以看到所有的車牌區域了。這次解決的是warpAffine函式帶來的視框偏移問題。
圖31 完整的車牌區域
關於座標調整的另一個理解就是當中心點保持不變時,平行四邊形扭正為矩形時恰好是左上的點往左偏移了xdiff/2的距離,左下的點往右偏移了xdiff/2的距離,形成一種對稱的平移。可以使用ps或者inkspace類似的向量製圖軟體看看“斜切”的效果,
如此一來,就完成了偏斜扭正的過程。需要注意的是,向左傾斜的車牌的視框偏移方向與向右傾斜的車牌是相反的。我們可以用slope的正負來判斷車牌是左斜還是右斜。
通過以上過程,我們成功的將一個偏斜的車牌經過旋轉變換等方法扭正過來。
讓我們回顧一下偏斜扭正過程。我們需要將一個偏斜的車牌扭正,為了達成這個目的我們首先需要對影像進行旋轉。因為旋轉是個計算量很大的函式,所以我們需要考慮不再用全圖旋轉,而是區域旋轉。在旋轉過程中,會發生影像截斷問題,所以需要使用擴大化旋轉方法。旋轉以後,只有偏斜視角的車牌才需要扭正,正視角的車牌不需要,因此還需要一個偏斜判斷過程。如此一來,偏斜扭正的過程需要旋轉,區域擷取,擴大化,偏斜判斷等等過程的協助,這就是整個流程中有這麼多步需要處理的原因。
下圖從另一個視角回顧了偏斜扭正的過程,主要說明了偏斜扭轉中的兩次“擷取”過程。
圖32 偏斜扭正全過程
- 首先我們獲取RotatedRect,然後對每個RotatedRect獲取外界矩形,也就是ROI區域。外接矩形的計算有可能獲得不安全的座標,因此需要使用安全的獲取外界矩形的函式。
- 獲取安全外接矩形以後,在原圖中擷取這部分割槽域,並放置到一個新的Mat裡,稱之為ROI影像。這是本過程中第一次擷取,使用Mat(Rect ...)函式。
- 接下來對ROI影像根據RotatedRect的角度展開旋轉,旋轉的過程中使用了放大化旋轉法,以此防止車牌區域被截斷。
- 旋轉完以後,我們把已經轉正的RotatedRect部分擷取出來,稱之為車牌區域。這是本過程中第二次擷取,與第一次不同,這次擷取使用getRectSubPix()方法。
- 接下里使用偏斜判斷函式來判斷車牌區域裡的車牌是否是傾斜的。
- 如果是,則繼續使用仿射變換函式wrapAffine來進行扭正處理,處理過程中要注意三個關鍵點的座標。
- 最後使用resize函式將車牌區域統一化為EasyPR的車牌大小。
整個過程有一個統一的函式--deskew。下面是deskew的程式碼。
//! 抗扭斜處理 int CPlateLocate::deskew(const Mat& src, const Mat& src_b, vector<RotatedRect>& inRects, vector<CPlate>& outPlates) { for (int i = 0; i < inRects.size(); i++) { RotatedRect roi_rect = inRects[i]; float r = (float)roi_rect.size.width / (float)roi_rect.size.height; float roi_angle = roi_rect.angle; Size roi_rect_size = roi_rect.size; if (r < 1) { roi_angle = 90 + roi_angle; swap(roi_rect_size.width, roi_rect_size.height); } if (roi_angle - m_angle < 0 && roi_angle + m_angle > 0) { Rect_<float> safeBoundRect; bool isFormRect = calcSafeRect(roi_rect, src, safeBoundRect); if (!isFormRect) continue; Mat bound_mat = src(safeBoundRect); Mat bound_mat_b = src_b(safeBoundRect); Point2f roi_ref_center = roi_rect.center - safeBoundRect.tl(); Mat deskew_mat; if ((roi_angle - 5 < 0 && roi_angle + 5 > 0) || 90.0 == roi_angle || -90.0 == roi_angle) { deskew_mat = bound_mat; } else { // 角度在5到60度之間的,首先需要旋轉 rotation Mat rotated_mat; Mat rotated_mat_b; if (!rotation(bound_mat, rotated_mat, roi_rect_size, roi_ref_center, roi_angle)) continue; if (!rotation(bound_mat_b, rotated_mat_b, roi_rect_size, roi_ref_center, roi_angle)) continue; // 如果圖片偏斜,還需要視角轉換 affine double roi_slope = 0; if (isdeflection(rotated_mat_b, roi_angle, roi_slope)) { //cout << "roi_angle:" << roi_angle << endl; //cout << "roi_slope:" << roi_slope << endl; affine(rotated_mat, deskew_mat, roi_slope); } else deskew_mat = rotated_mat; } Mat plate_mat; plate_mat.create(HEIGHT, WIDTH, TYPE); if (deskew_mat.cols >= WIDTH || deskew_mat.rows >= HEIGHT) resize(deskew_mat, plate_mat, plate_mat.size(), 0, 0, INTER_AREA); else resize(deskew_mat, plate_mat, plate_mat.size(), 0, 0, INTER_CUBIC); /*if (1) { imshow("plate_mat", plate_mat); waitKey(0); destroyWindow("plate_mat"); }*/ CPlate plate; plate.setPlatePos(roi_rect); plate.setPlateMat(plate_mat); outPlates.push_back(plate); } } return 0; }
最後是改善建議:
角度偏斜判斷時可以用白色區域的輪廓來確定平行四邊形的四個點,然後用這四個點來計算斜率。這樣算出來的斜率的可能魯棒性更好。
本篇文件介紹了顏色定位與偏斜扭轉等功能。其中顏色定位屬於作者一直想做的定位方法,而偏斜扭轉則是作者以前認為不可能解決的問題。這些問題現在都基本被攻克了,並在這篇文件中闡述,希望這篇文件可以幫助到讀者。
作者希望能在這片文件中不僅傳遞知識,也傳授我在摸索過程中積累的經驗。因為光知道怎麼做並不能加深對車牌識別的認識,只有經歷過失敗,瞭解哪些思想嘗試過,碰到了哪些問題,是如何解決的,才能幫助讀者更好地認識這個系統的內涵。
最後,作者很感謝能夠閱讀到這裡的讀者。如果看完覺得好的話,還請輕輕點一下贊,你們的鼓勵就是作者繼續行文的動力。
對EasyPR做下說明:EasyPR,一個開源的中文車牌識別系統,程式碼託管在github。其次,在前面的部落格文章中,包含EasyPR至今的開發文件與介紹。在後續的文章中,作者會介紹EasyPR中字元分割與識別等相關內容,歡迎繼續閱讀。
版權說明:
本文中的所有文字,圖片,程式碼的版權都是屬於作者和部落格園共同所有。歡迎轉載,但是務必註明作者與出處。任何未經允許的剽竊以及爬蟲抓取都屬於侵權,作者和部落格園保留所有權利。
參考文獻:
1.http://blog.csdn.net/xiaowei_cqu/article/details/7616044
2.http://docs.opencv.org/doc/tutorials/imgproc/imgtrans/warp_affine/warp_affine.html