最近apply了新的算法, 并且更新在了github的主頁(yè)上面
在youtube上也有效果展示鏈接
文章中描述的算法是以前的版本, 太長(zhǎng)了沒(méi)空改, 后面空了會(huì)更新文章. 新算法的具體實(shí)現(xiàn)可以直接去看代碼.
這篇文章主要解決這樣一個(gè)問(wèn)題:
有一張傾斜了的圖片(當(dāng)然是在Z軸上也有傾斜,不然直接旋轉(zhuǎn)得了o(╯□╰)o),如何盡量將它糾正到端正的狀態(tài)。
而要解決這樣一個(gè)問(wèn)題,可以用到透視變換。
關(guān)于透視變換的原理,網(wǎng)上已經(jīng)有一大推了,這里就不再做介紹了。
這篇文章的干貨是:
- 對(duì)OpenCV晦澀難懂的透視變換接口的使用細(xì)節(jié)的描述;
- 基于兩套自己提出的自動(dòng)選擇頂點(diǎn)進(jìn)行透視變換的可以運(yùn)行的 完整代碼
關(guān)于干貨的第1點(diǎn),相信很多同學(xué)在使用OpenCV透視變換接口的時(shí)候,一定google了不少東西吧。。。
而關(guān)于干貨的第2點(diǎn),應(yīng)該更能引起大家的共鳴吧。就像我當(dāng)初想做這個(gè)的時(shí)候,信心滿(mǎn)滿(mǎn)地去搜了很多博客,然而發(fā)現(xiàn)絕大部分博客或者教程中,關(guān)于透視變換的舉例無(wú)非是如下兩種:
- 是把一張端正的圖像進(jìn)行扭曲,比如下面這樣:

可以說(shuō)對(duì)要做的工作毫無(wú)卵用。。。
-
把上圖中變換后的圖片恢復(fù)成原圖。look here
可以說(shuō)剛看到可以這樣子的時(shí)候,大家應(yīng)該是非常激動(dòng)的。。。趕緊去看看代碼里面用了什么方法,然后看啊看,發(fā)現(xiàn)仿射變換的4個(gè)關(guān)鍵點(diǎn)是手動(dòng)確定的。。。又可以說(shuō)毫無(wú)卵用了。。畢竟每張圖片都要通過(guò)手動(dòng)的方法來(lái)確定4個(gè)關(guān)鍵點(diǎn),還是很容易讓人崩潰的。。。
于是乎,我決定,自己設(shè)計(jì)一套算法,來(lái)自動(dòng)確定這4個(gè)關(guān)鍵點(diǎn)的坐標(biāo)。當(dāng)然,由于才疏學(xué)淺,我的這套算法當(dāng)然可謂是漏洞百出,權(quán)當(dāng)抱磚引玉,歡迎大家提出更好的思路,一起交流~~
干貨來(lái)啦~~~
OpenCV的透視變換接口
API:
void warpPerspective(InputArray src,
OutputArray dst,
InputArray M,Size dsize,
intflags=INTER_LINEAR,
int borderMode=BORDER_CONSTANT,
const Scalar&borderValue=Scalar()
)
參數(shù)含義:
InputArray src:輸入的圖像;
OutputArray dst:輸出的圖像;
InputArray M:透視變換的矩陣;
Size dsize:輸出圖像的大?。?br>
int flags=INTER_LINEAR:輸出圖像的插值方法。
其中的透視變換矩陣還需要函數(shù)findHomography的計(jì)算來(lái)得到一個(gè)單映射矩陣。findHomography的函數(shù)接口如下:
Mat findHomography(InputArray srcPoints,
InputArray dstPoints,
int method=0,
doubleransacReprojThreshold=3,
OutputArray mask=noArray()
)
參數(shù)含義:
InputArray srcPoints:輸入圖像的頂點(diǎn);
InputArray dstPoints:輸出圖像的頂點(diǎn)。
關(guān)于自動(dòng)計(jì)算仿射變換頂點(diǎn)的兩種算法實(shí)現(xiàn)
以下處理的原圖如下:

基于邊緣提取
在OpenCV中,表示直線(xiàn)的數(shù)據(jù)結(jié)構(gòu)一般是Vec4i,這本身是一個(gè)vector[1]結(jié)構(gòu),包含了4個(gè)元素,分別對(duì)應(yīng)直線(xiàn)起點(diǎn)和終點(diǎn)的橫縱坐標(biāo),在工程代碼里,用vector<Vec4i>來(lái)表示經(jīng)過(guò)直線(xiàn)提取后的的直線(xiàn)簇:
vector<Vec4i> lines;
首先,對(duì)原圖進(jìn)行邊緣檢測(cè),為了使邊緣檢測(cè)和直線(xiàn)提取的結(jié)果盡可能主要體現(xiàn)在輪廓方面,工程代碼里,將Canny邊緣檢測(cè)的threshold1設(shè)定為一個(gè)帶初值的變量,并設(shè)置最多檢測(cè)出的直線(xiàn)條數(shù),迭代地通過(guò)增加threshold1的值,去減少每次檢測(cè)出的直線(xiàn)條數(shù),通過(guò)工程代碼也能體現(xiàn)出來(lái):
const int maxLinesNum = 12;//最多檢測(cè)出的直線(xiàn)條數(shù)
while (this->lines.size() >= maxLinesNum)
{
this->cannyThreshold += 2;
Canny(this->srcImage, this->midImage,this->cannyThreshold,
this->cannyThreshold * factor);
threshold(this->midImage, this->midImage, 128,255, THRESH_BINARY);
cvtColor(this->midImage, this->edgeDetect,CV_GRAY2RGB);
HoughLinesP(this->midImage, this->lines, 1,CV_PI / 180, 50, 100, 100);
}
```
可以看出,只要本次檢測(cè)出的直線(xiàn)條數(shù)大于12條,那么就增加Canny函數(shù)的threshold1的值,使下次檢測(cè)出的直線(xiàn)條數(shù)減少,知道第一次小于12條,才退出循環(huán)。另外,由于一些照片拍攝的情形過(guò)于復(fù)雜,有許多環(huán)境噪聲的干擾不可避免,因此,算法里還加入了一個(gè)濾波器,這個(gè)濾波器可以有效地對(duì)過(guò)于貼近圖像邊緣的平行直線(xiàn)進(jìn)行過(guò)濾:
lines.erase(remove_if(
lines.begin(),lines.end(),
[](Vec4i line)
{return abs(line[0] - line[2]) < 10 ||abs(line[1] - line[3]) < 10; }
),
lines.end());
通過(guò)以上步驟的處理后,就可以得到下圖:

至此,左上、右上、左下、右下這四個(gè)頂點(diǎn)已經(jīng)被包含在了紫色的線(xiàn)條之中,下一步的工作就是從這些紫色的線(xiàn)條中解析出這四個(gè)頂點(diǎn)。
在解析這四個(gè)點(diǎn)之前,還需要對(duì)這些紫色的線(xiàn)條進(jìn)行一次處理:將所有點(diǎn)從這些線(xiàn)段中剝離出來(lái)。剝離的方法很直觀(guān):由于每條線(xiàn)段包含了兩個(gè)點(diǎn),因此點(diǎn)的個(gè)數(shù)最多是線(xiàn)段數(shù)的兩倍(考慮到有的線(xiàn)段共用了頂點(diǎn)),因此新建一個(gè)用于存儲(chǔ)所有點(diǎn)的vector,將他的大小初始化為lines這個(gè)vector大小的兩倍:
vector<Point> points(lines.size() * 2);//各個(gè)線(xiàn)段的起止點(diǎn),然后根據(jù)對(duì)應(yīng)關(guān)系直接將直線(xiàn)的起始點(diǎn)存入
points這個(gè)vector[3]中:
for (size_t i = 0; i < lines.size(); ++i)//將Vec4i轉(zhuǎn)為point
{
points[i * 2].x = linesi;
points[i * 2].y = linesi;
points[i * 2 + 1].x = linesi;
points[i * 2 + 1].y = linesi;
}
這樣就完成了對(duì)各個(gè)起始點(diǎn)的剝離。為了提高之后計(jì)算的效率,并且合并一些由于直線(xiàn)提取的誤差所產(chǎn)生的同一個(gè)點(diǎn)分離的情況,再對(duì)這些已經(jīng)剝離了的點(diǎn)進(jìn)行一次過(guò)濾:
vector<Point> candidates(candidate);
vector<Point> filter(candidate);
for (auto i = candidates.begin(); i !=candidates.end();)
for (auto j = filter.begin(); j != filter.end(); ++j)
{
if(abs((i).x - (j).x) < 5 &&
abs((i).y - (j).y) < 5 &&
abs((i).x - (j).x) > 0 &&
abs((i).y - (j).y) > 0
)
i= filter.erase(i);
else
++i;
}
return filter;
這次過(guò)濾是非常有必要進(jìn)行的,由于直線(xiàn)提取的閾值不可能適用于各種情形下拍攝的照片,因此有些照片的直線(xiàn)提取結(jié)果中,某些看上去是一條線(xiàn)段,實(shí)際上是由兩條甚至更多條線(xiàn)段合并而成,如果直接把他們剝離成點(diǎn)用于算法后面的計(jì)算的話(huà),由于后面的計(jì)算時(shí)間復(fù)雜度是O(N^2),盲目的計(jì)算會(huì)消耗非常多的時(shí)間,而這些消耗是沒(méi)有必要的。這次過(guò)濾后,重合的點(diǎn)將被刪除,而原本邏輯上是同一個(gè)點(diǎn)而計(jì)算后成為不同點(diǎn)的那些點(diǎn)將被合并為一個(gè)點(diǎn)。在經(jīng)過(guò)這次過(guò)濾后,再對(duì)剩余點(diǎn)進(jìn)行一次排序,排序的依據(jù)是這些點(diǎn)到(0,0)點(diǎn)的距離(圖像處理中的(0,0)點(diǎn)一般是左上角的點(diǎn),橫坐標(biāo)向右增加,縱坐標(biāo)向下增加):
sort(points.begin(), points.end(),
[](const Point& lhs, const Point& rhs)
{return lhs.x + lhs.y < rhs.x + rhs.y; }
);
經(jīng)過(guò)這次處理后,points中的所有點(diǎn)都是有序排列了。
為了保證對(duì)左上、右上、左下、右下這四個(gè)點(diǎn)計(jì)算結(jié)果的精確性,我設(shè)計(jì)了兩種方法來(lái)分別計(jì)算這四個(gè)點(diǎn)的坐標(biāo),并且在保證經(jīng)過(guò)兩種方法的計(jì)算后,各自的誤差滿(mǎn)足一定條件后,取兩種計(jì)算結(jié)果的平均值,作為最終的計(jì)算結(jié)果。這兩種方法中有部分思想是一致的:在絕大多數(shù)正常拍攝的照片中,左上、和右下這兩個(gè)頂點(diǎn)是容易提取的。不難發(fā)現(xiàn),左上這個(gè)頂點(diǎn)是距離原點(diǎn)最近的點(diǎn),右下這個(gè)頂點(diǎn)是距離原點(diǎn)最遠(yuǎn)的點(diǎn)。在經(jīng)過(guò)上述過(guò)濾和排序步驟后,我們得到過(guò)濾后的點(diǎn),就可以直接從中取出左上、右下這兩個(gè)點(diǎn):
vector<Point> temp = this->axisSort(lines);
Point leftTop, rightDown; //左上和右下可以直接判斷
leftTop.x = temp[0].x;
leftTop.y = temp[0].y;
rightDown.x = temp[temp.size() - 1].x;
rightDown.y = temp[temp.size() - 1].y;
下面分別介紹兩種方法計(jì)算左下和右上這兩個(gè)點(diǎn)的思路。
第一種思路相對(duì)簡(jiǎn)單。
具體思想是,將“右上”、“左下”定義為點(diǎn)簇而非具體的某個(gè)點(diǎn)。在除開(kāi)左上和右下這兩個(gè)點(diǎn)外的所有點(diǎn)中,經(jīng)行兩次過(guò)濾:第一次過(guò)濾可以選出右上的點(diǎn)簇,利用的是在剩余的點(diǎn)中,如果某個(gè)點(diǎn)的橫坐標(biāo)大于左上點(diǎn)的橫坐標(biāo)并且縱坐標(biāo)小于右下點(diǎn)的縱坐標(biāo),那么將這個(gè)點(diǎn)歸到“右上”這個(gè)點(diǎn)簇中,如下圖所示;如果某個(gè)點(diǎn)的縱坐標(biāo)大于左上點(diǎn)的縱坐標(biāo)并且橫坐標(biāo)小于右下點(diǎn)的橫坐標(biāo),那么將這個(gè)點(diǎn)歸到“左下”這個(gè)點(diǎn)簇中,如下圖所示。


工程中的代碼如下:
vector<Point>rightTop(temp.size());
vector<Point>leftDown(temp.size());//左下和右上有多個(gè)點(diǎn)可能符合
for (auto & i : temp)[2]
if (i.x > leftTop.x&& i.y < rightDown.y)
rightTop.push_back(i);
for (auto & i : temp)
if (i.y > leftTop.y&& i.x < rightDown.x)
leftDown.push_back(i);
經(jīng)過(guò)這個(gè)步驟后,就將所有滿(mǎn)足條件的點(diǎn)分別歸到了“左下”和“右上”這兩個(gè)點(diǎn)簇中。那么接下來(lái),如何從這兩個(gè)點(diǎn)簇中選出真正的左上點(diǎn)和右下點(diǎn)呢。這就要用到一個(gè)矩形中最長(zhǎng)的線(xiàn)段是對(duì)角線(xiàn)這個(gè)性質(zhì)了。即使原圖由于拍攝原因可能已經(jīng)產(chǎn)生了畸變,但是在“左下”和“右上”這兩個(gè)點(diǎn)簇中,能構(gòu)成最長(zhǎng)線(xiàn)段的點(diǎn)仍然是真正的右上點(diǎn)和左下點(diǎn)。于是在“左下”和“右上”這兩個(gè)點(diǎn)簇中從容器起始位置進(jìn)行遍歷,不斷更新最長(zhǎng)距離和此距離對(duì)應(yīng)的兩個(gè)容器中的元素位置,直到這兩個(gè)位置到達(dá)兩個(gè)容器的末尾,就停止更新。此時(shí)記錄下的元素位置所對(duì)應(yīng)的點(diǎn),就是真正的左下點(diǎn)和右上點(diǎn),如工程代碼所示:
int maxDistance = (rightTop[0].x - leftDown[0].x) *(rightTop[0].x - leftDown[0].x)
+ (rightTop[0].y - leftDown[0].y) *(rightTop[0].y - leftDown[0].y);
for (size_t i = 0; i < rightTop.size(); ++i)
for (size_t j = 0; j < leftDown.size(); ++j)
if (
(rightTop[i].x - leftDown[j].x) * (rightTop[i].x -leftDown[j].x)
+ (rightTop[i].y - leftDown[j].y) * (rightTop[i].y -leftDown[j].y)
> maxDistance
)
{
maxDistance = (rightTop[i].x - leftDown[j].x) * (rightTop[i].x - leftDown[j].x)
+ (rightTop[i].y - leftDown[j].y) *(rightTop[i].y - leftDown[j].y);
rightTopFlag= i;
leftDownFlag= j;
}
下面介紹第二種方法。
通常,輸入圖像在視覺(jué)直觀(guān)上可以分成端正、向左傾斜、向右傾斜這三種狀態(tài)。之所以很難通過(guò)通常的想法來(lái)確定一個(gè)圖像的左下點(diǎn)和右上點(diǎn),是因?yàn)橥ǔ5南敕ㄏ?,左下點(diǎn)應(yīng)該是橫坐標(biāo)最小且縱坐標(biāo)最大,右上點(diǎn)應(yīng)該是橫坐標(biāo)最大且縱坐標(biāo)最小。然而,這種判斷只適用于“端正”這種狀態(tài),如下圖所示。但是對(duì)于“向右傾斜”和“向左傾斜”這兩種狀態(tài),這種直觀(guān)的判斷就失效了,如下圖所示。在“向右傾斜”這種狀態(tài)下,左下點(diǎn)實(shí)際上是橫坐標(biāo)最小而縱坐標(biāo)卻不是最小,右上點(diǎn)實(shí)際上是橫坐標(biāo)最大而縱坐標(biāo)不是最?。辉凇跋蜃髢A斜”這種狀態(tài)下,左下點(diǎn)實(shí)際上是縱坐標(biāo)最大而橫坐標(biāo)卻不是最小,右上點(diǎn)實(shí)際上是縱坐標(biāo)最小而橫坐標(biāo)卻不是最大。



如果不對(duì)圖像的狀態(tài)進(jìn)行區(qū)分就直接計(jì)算左下點(diǎn)和右上點(diǎn),是非常困難的。但是,如果將圖片分成上述三種狀態(tài)后再對(duì)左下點(diǎn)和右上點(diǎn)進(jìn)行計(jì)算,那么將會(huì)容易得多。如果輸入圖片本身就是“端正”狀態(tài),可以對(duì)左上點(diǎn)和右下點(diǎn)進(jìn)行直接判斷,下面介紹在“向右傾斜”和“想做傾斜”這兩種狀態(tài)下,對(duì)這兩個(gè)點(diǎn)計(jì)算的方法。
在介紹根據(jù)不同傾斜狀況對(duì)兩個(gè)頂點(diǎn)的計(jì)算方法之前,先介紹一下如何確定右上點(diǎn)簇和左下點(diǎn)簇。在圖片處于端正狀態(tài)下,位于右上點(diǎn)兩側(cè)邊緣上的點(diǎn)就被定義為“右上點(diǎn)簇”,位于左下點(diǎn)兩側(cè)邊緣上的點(diǎn)就被定義為“左下點(diǎn)簇”。在此之后,無(wú)論這張圖片如何傾斜,“右上點(diǎn)簇”和“左下點(diǎn)簇”的相對(duì)位置都不會(huì)改變。
如何區(qū)分圖片是“向右傾斜”還是“向左傾斜”呢?首先,按照第一種方法的思路,將除開(kāi)左上點(diǎn)和右下點(diǎn)的其余所有點(diǎn)歸類(lèi)進(jìn)“左下”和“右上”這兩個(gè)點(diǎn)簇中。如果某張圖片的“右上”點(diǎn)簇中的所有點(diǎn)的縱坐標(biāo)都大于左上點(diǎn)的縱坐標(biāo),就說(shuō)明這張圖是“向右傾斜”;否則這張圖就是“向左傾斜”。上述思路的工程代碼如下:
enum imageStyle { normal, leanToRight, leanToLeft };
if (rightTop.end() == find_if(
rightTop.begin(), rightTop.end(),
[leftTop, rightTop](Point p)
{return p.y < leftTop.y; }
))//如果所有右上點(diǎn)的y值都 > 左上點(diǎn)的y值,說(shuō)明圖像向右傾斜
imageState = imageStyle::leanToRight;
else
imageState = imageStyle::leanToLeft;
在“向右傾斜”狀態(tài)下,對(duì)“右上”點(diǎn)簇中的所有點(diǎn)按照橫坐標(biāo)降序排列,橫坐標(biāo)最大的點(diǎn)就是真正的右上點(diǎn),如圖所示;對(duì)“左下”點(diǎn)簇中的所有點(diǎn)按照橫坐標(biāo)升序排列,橫坐標(biāo)最小的點(diǎn)就是真正的左下點(diǎn),如圖所示。在“向左傾斜”狀態(tài)下,對(duì)“右上”點(diǎn)簇中的所有點(diǎn)按照縱坐標(biāo)升序排列,縱坐標(biāo)最小的點(diǎn)就是真正的右上點(diǎn),如圖所示;對(duì)“左下”點(diǎn)簇中的所有點(diǎn)按照縱坐標(biāo)降序排列,縱坐標(biāo)最大的點(diǎn)就是真正的左下點(diǎn),如圖所示。
工程代碼如下:
if (imageState == imageStyle::leanToRight)//向右傾斜
{
sort(rightTop.begin(), rightTop.end(),
[rightTop](Point p1, Point p2){return p1.x > p2.x; });//對(duì)所有右上點(diǎn)按X值排序,X最大的就是真正的右上點(diǎn)
rightTop.erase(remove(rightTop.begin(), rightTop.end(), Point(0, 0)), rightTop.end());
trueRightTop = rightTop[0];
sort(leftDown.begin(), leftDown.end(),
[leftDown](Point p1, Point p2){return p1.x < p2.x; });//對(duì)所有左下點(diǎn)按X值排序,X最小的就是真正的左下點(diǎn)
leftDown.erase(remove(leftDown.begin(), leftDown.end(), Point(0, 0)), leftDown.end());
trueLeftDown = leftDown[0];
}
else //向左傾斜
{
sort(rightTop.begin(), rightTop.end(),
[rightTop](Point p1, Point p2){return p1.y < p2.y; });//對(duì)所有右上點(diǎn)按Y值排序,Y最小的就是真正的右上點(diǎn)
rightTop.erase(remove(rightTop.begin(), rightTop.end(), Point(0, 0)), rightTop.end());
trueRightTop = rightTop[0];
sort(leftDown.begin(), leftDown.end(),
[leftDown](Point p1, Point p2){return p1.y > p2.y; });//對(duì)所有左下點(diǎn)按Y值排序,Y最大的就是真正的左下點(diǎn)
leftDown.erase(remove(leftDown.begin(), leftDown.end(), Point(0, 0)), leftDown.end());
trueLeftDown = leftDown[0];
}
基于輪廓提取
輪廓提取的思路和邊緣提取基本相同,就是預(yù)處理中,將提邊緣換成體輪廓。
當(dāng)初想到基于輪廓提取是為了互相驗(yàn)證這兩種方法的可靠性~~
就不再詳述這種方法了~~~
The END
在文章的最后,當(dāng)然還是要放幾張效果圖啦~~~




當(dāng)然,還是存在一些顯而易見(jiàn)的問(wèn)題:
如果輸入圖像的頂點(diǎn)本身已經(jīng)缺失過(guò)多,那我提出的兩種頂點(diǎn)計(jì)算方法都不可能完全還原出該圖本身的缺失頂點(diǎn)(因?yàn)樵擁旤c(diǎn)已處于圖像像素范圍之外,無(wú)法計(jì)算);
另外,邊緣提取和輪廓提取的參數(shù)也不可能做到完全的自適應(yīng)。