1. 程式人生 > >對傾斜的影象進行修正——基於opencv 透視變換

對傾斜的影象進行修正——基於opencv 透視變換

這篇文章主要解決這樣一個問題:

有一張傾斜了的圖片(當然是在Z軸上也有傾斜,不然直接旋轉得了o(╯□╰)o),如何儘量將它糾正到端正的狀態。

而要解決這樣一個問題,可以用到透視變換。

關於透視變換的原理,網上已經有一大推了,這裡就不再做介紹了。

這篇文章的乾貨是:

  1. 對OpenCV晦澀難懂的透視變換介面的使用細節的描述;
  2. 基於兩套自己提出的自動選擇頂點進行透視變換的可以執行的 完整程式碼

關於乾貨的第1點,相信很多同學在使用OpenCV透視變換介面的時候,一定google了不少東西吧。。。

而關於乾貨的第2點,應該更能引起大家的共鳴吧。就像我當初想做這個的時候,信心滿滿地去搜了很多部落格,然而發現絕大部分部落格或者教程中,關於透視變換的舉例無非是如下兩種:

  1. 是把一張端正的影象進行扭曲,比如下面這樣:

可以說對要做的工作毫無卵用。。。

  1. 把上圖中變換後的圖片恢復成原圖。look here

    可以說剛看到可以這樣子的時候,大家應該是非常激動的。。。趕緊去看看程式碼裡面用了什麼方法,然後看啊看,發現仿射變換的4個關鍵點是手動確定的。。。又可以說毫無卵用了。。畢竟每張圖片都要通過手動的方法來確定4個關鍵點,還是很容易讓人崩潰的。。。

於是乎,我決定,自己設計一套演算法,來自動確定這4個關鍵點的座標。當然,由於才疏學淺,我的這套演算法當然可謂是漏洞百出,權當抱磚引玉,歡迎大家提出更好的思路,一起交流~~

乾貨來啦~~~

OpenCV的透視變換介面

API:


  
  1. void warpPerspective(InputArray src,
  2. OutputArray dst,
  3. InputArray M,Size dsize,
  4. intflags=INTER_LINEAR,
  5. int borderMode=BORDER_CONSTANT,
  6. const Scalar&borderValue=Scalar()
  7. )

引數含義:
InputArray src:輸入的影象;
OutputArray dst:輸出的影象;
InputArray M:透視變換的矩陣;
Size dsize:輸出影象的大小;
int flags=INTER_LINEAR:輸出影象的插值方法。

其中的透視變換矩陣還需要函式findHomography的計算來得到一個單對映矩陣。findHomography的函式介面如下:


  
  1. Mat findHomography(InputArray srcPoints,
  2. InputArray dstPoints,
  3. int method=0,
  4. doubleransacReprojThreshold =3,
  5. OutputArray mask = noArray ()
  6. )

引數含義:
InputArray srcPoints:輸入影象的頂點;
InputArray dstPoints:輸出影象的頂點。

關於自動計算仿射變換頂點的兩種演算法實現

以下處理的原圖如下:


基於邊緣提取

在OpenCV中,表示直線的資料結構一般是Vec4i,這本身是一個vector[1]結構,包含了4個元素,分別對應直線起點和終點的橫縱座標,在工程程式碼裡,用vector<Vec4i>來表示經過直線提取後的的直線簇:

vector<Vec4i> lines;
  

首先,對原圖進行邊緣檢測,為了使邊緣檢測和直線提取的結果儘可能主要體現在輪廓方面,工程程式碼裡,將Canny邊緣檢測的threshold1設定為一個帶初值的變數,並設定最多檢測出的直線條數,迭代地通過增加threshold1的值,去減少每次檢測出的直線條數,通過工程程式碼也能體現出來:


  
  1. const int maxLinesNum = 12; //最多檢測出的直線條數
  2. while ( this->lines.size() >= maxLinesNum)
  3. {
  4. this->cannyThreshold += 2;
  5. Canny( this->srcImage, this->midImage, this->cannyThreshold,
  6. this->cannyThreshold * factor);
  7. threshold( this->midImage, this->midImage, 128, 255, THRESH_BINARY);
  8. cvtColor( this->midImage, this->edgeDetect,CV_GRAY2RGB);
  9. HoughLinesP( this->midImage, this->lines, 1,CV_PI / 180, 50, 100, 100);
  10. }
  11. ```

可以看出,只要本次檢測出的直線條數大於12條,那麼就增加Canny函式的threshold1的值,使下次檢測出的直線條數減少,知道第一次小於12條,才退出迴圈。另外,由於一些照片拍攝的情形過於複雜,有許多環境噪聲的干擾不可避免,因此,演算法裡還加入了一個濾波器,這個濾波器可以有效地對過於貼近影象邊緣的平行直線進行過濾:


  
  1. lines. erase(remove_if(
  2. lines. begin(),lines. end(),
  3. [](Vec4i line)
  4. { return abs( line[ 0] - line[ 2]) < 10 || abs( line[ 1] - line[ 3]) < 10; }
  5. ),
  6. lines. end());

通過以上步驟的處理後,就可以得到下圖:


至此,左上、右上、左下、右下這四個頂點已經被包含在了紫色的線條之中,下一步的工作就是從這些紫色的線條中解析出這四個頂點。

在解析這四個點之前,還需要對這些紫色的線條進行一次處理:將所有點從這些線段中剝離出來。剝離的方法很直觀:由於每條線段包含了兩個點,因此點的個數最多是線段數的兩倍(考慮到有的線段共用了頂點),因此新建一個用於儲存所有點的vector,將他的大小初始化為lines這個vector大小的兩倍:

vector<Point> points(lines.size() * 2);//各個線段的起止點,然後根據對應關係直接將直線的起始點存入
  

points這個vector[3]中:


  
  1. for (size_t i = 0; i < lines.size(); ++i) //將Vec4i轉為point
  2. {
  3. points[ i * 2 ].x = linesi;
  4. points[ i * 2 ].y = linesi;
  5. points[ i * 2 + 1 ].x = linesi;
  6. points[ i * 2 + 1 ].y = linesi;
  7. }

這樣就完成了對各個起始點的剝離。為了提高之後計算的效率,並且合併一些由於直線提取的誤差所產生的同一個點分離的情況,再對這些已經剝離了的點進行一次過濾:


  
  1. vector<Point> candidates( candidate) ;
  2. vector<Point> filter( candidate) ;
  3. for ( auto i = candidates.begin() ; i !=candidates.end();)
  4. for ( auto j = filter.begin() ; j != filter.end(); ++j)
  5. {
  6. if( abs(( i).x - ( j).x) < 5 &&
  7. abs(( i).y - ( j).y) < 5 &&
  8. abs(( i).x - ( j).x) > 0 &&
  9. abs(( i).y - ( j).y) > 0
  10. )
  11. i= filter. erase( i) ;
  12. else
  13. ++i ;
  14. }
  15. return filter ;

這次過濾是非常有必要進行的,由於直線提取的閾值不可能適用於各種情形下拍攝的照片,因此有些照片的直線提取結果中,某些看上去是一條線段,實際上是由兩條甚至更多條線段合併而成,如果直接把他們剝離成點用於演算法後面的計算的話,由於後面的計算時間複雜度是O(N^2),盲目的計算會消耗非常多的時間,而這些消耗是沒有必要的。這次過濾後,重合的點將被刪除,而原本邏輯上是同一個點而計算後成為不同點的那些點將被合併為一個點。在經過這次過濾後,再對剩餘點進行一次排序,排序的依據是這些點到(0,0)點的距離(影象處理中的(0,0)點一般是左上角的點,橫座標向右增加,縱座標向下增加):


  
  1. sort( points. begin(), points. end(),
  2. []( const Point& lhs, const Point& rhs)
  3. { return lhs .x + lhs .y < rhs .x + rhs .y; }
  4. );

經過這次處理後,points中的所有點都是有序排列了。

為了保證對左上、右上、左下、右下這四個點計算結果的精確性,我設計了兩種方法來分別計算這四個點的座標,並且在保證經過兩種方法的計算後,各自的誤差滿足一定條件後,取兩種計算結果的平均值,作為最終的計算結果。這兩種方法中有部分思想是一致的:在絕大多數正常拍攝的照片中,左上、和右下這兩個頂點是容易提取的。不難發現,左上這個頂點是距離原點最近的點,右下這個頂點是距離原點最遠的點。在經過上述過濾和排序步驟後,我們得到過濾後的點,就可以直接從中取出左上、右下這兩個點:


  
  1. vector<Point> temp = this->axisSort(lines);
  2. Point leftTop, rightDown; //左上和右下可以直接判斷
  3. leftTop.x = temp[ 0].x;
  4. leftTop.y = temp[ 0].y;
  5. rightDown.x = temp[temp.size() - 1].x;
  6. rightDown.y = temp[temp.size() - 1].y;

下面分別介紹兩種方法計算左下和右上這兩個點的思路。

第一種思路相對簡單。

具體思想是,將“右上”、“左下”定義為點簇而非具體的某個點。在除開左上和右下這兩個點外的所有點中,經行兩次過濾:第一次過濾可以選出右上的點簇,利用的是在剩餘的點中,如果某個點的橫座標大於左上點的橫座標並且縱座標小於右下點的縱座標,那麼將這個點歸到“右上”這個點簇中,如下圖所示;如果某個點的縱座標大於左上點的縱座標並且橫座標小於右下點的橫座標,那麼將這個點歸到“左下”這個點簇中,如下圖所示。



工程中的程式碼如下:


  
  1. vector<Point>rightTop(temp.size());
  2. vector<Point>leftDown(temp.size());//左下和右上有多個點可能符合
  3. for (auto & i : temp)[2]
  4. if (i.x > leftTop.x&& i.y < rightDown.y)
  5. rightTop.push_back(i);
  6. for (auto & i : temp)
  7. if (i.y > leftTop.y&& i.x < rightDown.x)
  8. leftDown.push_back(i);

經過這個步驟後,就將所有滿足條件的點分別歸到了“左下”和“右上”這兩個點簇中。那麼接下來,如何從這兩個點簇中選出真正的左上點和右下點呢。這就要用到一個矩形中最長的線段是對角線這個性質了。即使原圖由於拍攝原因可能已經產生了畸變,但是在“左下”和“右上”這兩個點簇中,能構成最長線段的點仍然是真正的右上點和左下點。於是在“左下”和“右上”這兩個點簇中從容器起始位置進行遍歷,不斷更新最長距離和此距離對應的兩個容器中的元素位置,直到這兩個位置到達兩個容器的末尾,就停止更新。此時記錄下的元素位置所對應的點,就是真正的左下點和右上點,如工程程式碼所示:


  
  1. int maxDistance = (rightTop [0].x - leftDown [0].x) *(rightTop [0].x - leftDown [0].x)
  2. + (rightTop [0].y - leftDown [0].y) *(rightTop [0].y - leftDown [0].y);
  3. for ( size_t i = 0; i < rightTop.size(); ++i)
  4. for ( size_t j = 0; j < leftDown.size(); ++j)
  5. if (
  6. (rightTop [i].x - leftDown [j].x) * (rightTop [i].x -leftDown [j].x)
  7. + (rightTop [i].y - leftDown [j].y) * (rightTop [i].y -leftDown [j].y)
  8. > maxDistance
  9. )
  10. {
  11. maxDistance = (rightTop [i].x - leftDown [j].x) * (rightTop [i].x - leftDown [j].x)
  12. + (rightTop [i].y - leftDown [j].y) *(rightTop [i].y - leftDown [j].y);
  13. rightTopFlag= i;
  14. leftDownFlag= j;
  15. }
下面介紹第二種方法。

通常,輸入影象在視覺直觀上可以分成端正、向左傾斜、向右傾斜這三種狀態。之所以很難通過通常的想法來確定一個影象的左下點和右上點,是因為通常的想法下,左下點應該是橫座標最小且縱座標最大,右上點應該是橫座標最大且縱座標最小。然而,這種判斷只適用於“端正”這種狀態,如下圖所示。但是對於“向右傾斜”和“向左傾斜”這兩種狀態,這種直觀的判斷就失效了,如下圖所示。在“向右傾斜”這種狀態下,左下點實際上是橫座標最小而縱座標卻不是最小,右上點實際上是橫座標最大而縱座標不是最小;在“向左傾斜”這種狀態下,左下點實際上是縱座標最大而橫座標卻不是最小,右上點實際上是縱座標最小而橫座標卻不是最大。


端正狀態下的左下點和右上點
向右傾斜狀態下的左下點和右上點
向左傾斜狀態下的左下點和右上點


如果不對影象的狀態進行區分就直接計算左下點和右上點,是非常困難的。但是,如果將圖片分成上述三種狀態後再對左下點和右上點進行計算,那麼將會容易得多。如果輸入圖片本身就是“端正”狀態,可以對左上點和右下點進行直接判斷,下面介紹在“向右傾斜”和“想做傾斜”這兩種狀態下,對這兩個點計算的方法。
在介紹根據不同傾斜狀況對兩個頂點的計算方法之前,先介紹一下如何確定右上點簇和左下點簇。在圖片處於端正狀態下,位於右上點兩側邊緣上的點就被定義為“右上點簇”,位於左下點兩側邊緣上的點就被定義為“左下點簇”。在此之後,無論這張圖片如何傾斜,“右上點簇”和“左下點簇”的相對位置都不會改變。
如何區分圖片是“向右傾斜”還是“向左傾斜”呢?首先,按照第一種方法的思路,將除開左上點和右下點的其餘所有點歸類進“左下”和“右上”這兩個點簇中。如果某張圖片的“右上”點簇中的所有點的縱座標都大於左上點的縱座標,就說明這張圖是“向右傾斜”;否則這張圖就是“向左傾斜”。上述思路的工程程式碼如下:


  
  1. enum imageStyle { normal, leanToRight, leanToLeft };
  2. if (rightTop. end() == find_if(
  3. rightTop. begin(), rightTop. end(),
  4. [leftTop, rightTop]( Point p)
  5. { return p.y < leftTop.y; }
  6. )) / /如果所有右上點的y值都 > 左上點的y值,說明影象向右傾斜
  7. imageState = imageStyle::leanToRight;
  8. else
  9. imageState = imageStyle::leanToLeft;

在“向右傾斜”狀態下,對“右上”點簇中的所有點按照橫座標降序排列,橫座標最大的點就是真正的右上點,如圖所示;對“左下”點簇中的所有點按照橫座標升序排列,橫座標最小的點就是真正的左下點,如圖所示。在“向左傾斜”狀態下,對“右上”點簇中的所有點按照縱座標升序排列,縱座標最小的點就是真正的右上點,如圖所示;對“左下”點簇中的所有點按照縱座標降序排列,縱座標最大的點就是真正的左下點,如圖所示。
工程程式碼如下:


  
  1. if (imageState == imageStyle::leanToRight) //向右傾斜
  2. {
  3. sort(rightTop. begin(), rightTop .end (),
  4. [rightTop](Point p1 , Point p2 ){return p1 .x > p2 .x ; });//對所有右上點按X值排序,X最大的就是真正的右上點
  5. rightTop.erase(remove(rightTop. begin(), rightTop .end(), Point( 0, 0)), rightTop .end()) ;
  6. trueRightTop = rightTop[ 0] ;
  7. sort(leftDown. begin(), leftDown .end(),
  8. [leftDown](Point p1, Point p2) {return p1 .x < p2 .x ; });//對所有左下點按X值排序,X最小的就是真正的左下點
  9. leftDown.erase(remove(leftDown. begin(), leftDown .end(), Point( 0, 0)), leftDown .end()) ;
  10. trueLeftDown = leftDown[ 0] ;
  11. }
  12. else //向左傾斜
  13. {
  14. sort(rightTop. begin(), rightTop .end (),
  15. [rightTop](Point p1 , Point p2 ){return p1 .y < p2 .y ; });//對所有右上點按Y值排序,Y最小的就是真正的右上點
  16. rightTop.erase(remove(rightTop. begin(), rightTop .end(), Point( 0, 0)), rightTop .end()) ;
  17. trueRightTop = rightTop[ 0] ;
  18. sort(leftDown. begin(), leftDown .end(),
  19. [leftDown](Point p1, Point p2) {return p1 .y > p2 .y ; });//對所有左下點按Y值排序,Y最大的就是真正的左下點
  20. leftDown.erase(remove(leftDown. begin(), leftDown .end(), Point( 0, 0)), leftDown .end()) ;
  21. trueLeftDown = leftDown[ 0] ;
  22. }

基於輪廓提取

輪廓提取的思路和邊緣提取基本相同,就是預處理中,將提邊緣換成體輪廓。
當初想到基於輪廓提取是為了互相驗證這兩種方法的可靠性~~
就不再詳述這種方法了~~~

The END

在文章的最後,當然還是要放幾張效果圖啦~~~


效果圖1
效果圖2
效果圖3
效果圖4


當然,還是存在一些顯而易見的問題:

如果輸入影象的頂點本身已經缺失過多,那我提出的兩種頂點計算方法都不可能完全還原出該圖本身的缺失頂點(因為該頂點已處於影象畫素範圍之外,無法計算);

另外,邊緣提取和輪廓提取的引數也不可能做到完全的自適應。