OpenCV2、OpenCV3包含三角剖分的接口,但是參考文檔裏並未介紹,給學習帶來了麻煩。
有一本經典的書《學習OpenCV》對其做了詳細介紹。但苦於這本書的新版,遲遲沒有翻譯成中文,所以現在有關OpenCV三角剖分的資料都是關於OpenCV1的,是C語言的接口。
我邊學習邊分享一下,自己的理解水平有限,如有錯漏,謝謝指正。由於時間有限,不逐字翻譯了,根據內容來。
原文參考《Learning OpenCV 3》附錄A:平面剖分。第923-937頁。
前面很大一部分是平面剖分的理論,不做翻譯,只簡單提一下。講了Delaunay三角剖分的特性和相關的計算方法。Delaunay是一個點集三角化的標準,在所有剖分方案中,滿足所有三角形的最小角的和是最大的。Delaunay剖分是唯一的,是最接近規則化的三角網。Delaunay剖分有很多算法實現。OpenCV應用的是逐點插入法,這從函數接口可以得出。
Delaunay三角剖分和Voronoi圖是對偶的,這意味着計算出了Delaunay剖分,Voronoi圖也就確定了。直觀感受一下:
重點來了,下面是OpenCV有關函數接口的理解和使用方法說明。
創建Delaunay 或者 Voronoi 剖分
首先需要在內存中開闢一塊地方來存儲Delaunay剖分。我們也需要一個方框(記住,爲了加速計算,算法處理過程中,在這個方框的外面需要一個虛擬的外圍三角形)。
爲了儘快開始,就假設這些點必須在一個600*600的圖像中吧。
// STRUCTURE FOR DELAUNAY SUBDIVISION
//
...
cv::Rect rect(0, 0, 600, 600); // Our outer bounding box
cv::Subdiv2D subdiv(rect); // Create the initial subdivision
這些代碼創建了初始的剖分,一個三角形包含一個特定的矩形框。
接下來,我們需要知道怎麼插入點。這些點必須是32位float類型的,或者是帶有整數座標值的點(cv::Point)。在後面的案例中,它們會自動轉換爲float類型。插入點使用cv::Subdiv2D::insert()
函數。
(譯者注:方便起見,後面很多相關函數省略cv::Subdiv2D命名空間了)
cv::Point2f fp; //This is our point holder
for( int i = 0; i < as_many_points_as_you_want; i++ ) {
// However you want to set points
//
fp = your_32f_point_list[i];
subdiv.insert(fp);
}
現在,點已經輸入完畢,我們能夠得到Delaunay三角剖分。從Delaunay三角剖分中計算三角形,使用getTiangleList()
函數。
vector<cv::Vec6f> triangles;
subdiv.getTriangleList(triangles);
調用之後,,在三角形中的每個Vec6f
包含三個頂點:(
得到對應的Voronoi圖使用函數getVoronoiFaceList
。
vector<vector<cv::Point2f> > facets;
vector<cv::Point2f> centers;
subdiv.getVoronoiFacetList(vector<int>(), facets, centers);
facets
包含Voronoi小面塊(譯者注:裏面的點數據只包括多邊形的頂點),centers
包含對應的區域中心。
值得一提的是,Delaunay三角化是迭代構建的,意味着,每插入一個點,三角剖分都會更新,所以它是總是更新的。然而,Voronoi圖是當你調用calcVoronoi()
一次性構建的。可選的是,你可以調用前面提到的getVoronoiFaceList()
(它內部調用了calcVoronoi()
)來隨之更新。
既然,我們已經創建好了一個二維點集的Delaunay剖分以及對應的Voronoi圖。下一步就是學習怎麼遍歷這個剖分。
遍歷Delaunay剖分
平面剖分基本的數據元素是邊,通過序號訪問邊。通過這個序號還可訪問相鄰邊,附加的參數還可指定想要訪問的邊同當前邊的位置關係。每個邊兩個端點叫做origin
和destination
。一個邊會和其它邊共享這些點。最後,存在一個對應(對偶)邊,每個Delaunay剖分的邊都有Voronoi剖分的邊相對應。
記住一點,在cv::Subdiv2D
接口中,對待邊總是直接的,這實際上是爲了方便。
還有,邊有方向。兩個點包含兩條邊,因爲區分origin
和destination
。
根據邊訪問點
不論Delaunay剖分,還是Voronoi剖分,邊都有起點和終點。訪問邊的端點如下:
int cv::Subdiv2D::edgeOrg( int edge, cv::Point2f* orgpt = 0 ) const;
int cv::Subdiv2D::edgeDst( int edge, cv::Point2f* dstpt = 0 ) const;
edge
是輸入,是邊的序號。參數表第二項返回點本身。函數返回點的序號。
給定點序號,可以得到點的座標,和相關的邊。
cv::Point2f cv::Subdiv2D::getVertex( int vertex, int* firstEdge = 0 ) const;
(譯者注:這裏的firstEdge
和點的關係不清楚,讀者可以考證一下)
需要注意,和邊一樣,點有序號。當然點也有座標。Subdiv2D
接口故意設計成這樣,在絕大多數的接口函數中,你主要使用的是邊和點的序號。
在剖分中定位點
一個可能發生的情況是:你有一個特定點的位置信息,但是想找到它在剖分中的序號。
相似的情況:可能這個點實際上並不是剖分中的頂點,但是你想找到包含這個點的三角形或小面塊。方法locate()
把一個點作爲輸入,返回這個點所在的一條邊。或者包含這個點的三角形或面塊的一條邊(如果這條邊不是頂點)。注意,在這種情況下,返回的不一定是距離最近的邊,只是簡單的返回包含點的三角形或面塊的其中一條邊。當點是頂點時,locate()
也會返回頂點的ID。
int cv::Subdiv2D::locate(
cv::Point2f pt,
int& edge,
int& vertex
);
函數返回值,告訴我們,點的着落位置
cv::Subdiv2D::PTLOC_INSIDE
點落在面塊內部,*edge是其中一條邊。cv::Subdiv2D::PTLOC_ON_EDGE
點落在邊上 *edge包含這條邊。cv::Subdiv2D::PTLOC_VERTEX
點落在剖分的頂點上, *vertex 包含頂點指針。cv::Subdiv2D::PTLOC_OUTSIDE_RECT
點落在參考矩形外面,返回指針無效。cv::Subdiv2D::PTLOC_ERROR
輸入參數無效訪問
環繞頂點遍歷
給定一條邊,你可能想訪問跟這條邊的起點或終點連接的新邊。實現這項工作的方法是,我們指定一個起始邊,我們繞着它的“頭”點或者“尾”點,逆時針,亦或者順時針搜尋下一條邊。這種設計的說明見下圖。我們通過函數getEdge()
來實現。
int cv:Subdiv2D::getEdge(
int edge,
int nextEdgeType // see text below
) const;
當調用這個函數時,我們需提供當前邊和nextEdgeType
參數,可選的參數值如下:
- cv::Subdiv2D::NEXT_AROUND_ORG, 繞起點下一條邊 (eOnext)
- cv::Subdiv2D::NEXT_AROUND_DST, 繞終點下一條邊 (eDnext)
- cv::Subdiv2D::PREV_AROUND_ORG, 繞起點上一條邊 (反向 eRnext)
- cv::Subdiv2D::PREV_AROUND_DST, 繞終點上一條邊 (反向 eLnext)
怎麼遍歷完全取決於你,也可以繞三角形或面塊遍歷,參數值如下:
- cv::Subdiv2D::NEXT_AROUND_LEFT, 環繞左側面塊的下一條邊 (eLnext)
- cv::Subdiv2D::NEXT_AROUND_RIGHT,環繞右側面塊的下一條邊 (eRnext)
- cv::Subdiv2D::PREV_AROUND_LEFT, 環繞左側面塊的上一條邊 (reversed eOnext)
- cv::Subdiv2D::PREV_AROUND_RIGHT, 環繞右側面塊的上一條邊(reversed eDnext)
(譯者注:“下一條邊”的隱含的訪問順序,繞點訪問時是逆時針,繞多邊形時是逆時針環行)
不用擔心是繞Delaunay三角形的邊,還是Voronoi圖的多邊形的邊,因爲輸入edge的序號已經包含這個信息,後面可詳細瞭解邊的編號方法。
也可選擇方便的調用方式,nextEdge()
:
// equivalent to getEdge(edge, cv::Subdiv2D::NEXT_AROUND_ORG)
//
int cv:Subdiv2D::nextEdge(
int edge
) const;
它等價於getEdge()
函數按cv::Subdiv2D::NEXT_AROUND_ORG方式調用。當我們想訪問環繞一個點的所有邊時,這個函數很方便。對一些應用場景很有幫助,比如,從虛擬外接三角形內的某個頂點出發,尋找凸包。
旋轉邊
假設你手頭上有一個邊的序號。無論你是從其他函數中得到的,還是想輕率地從某個特定的序號開始遍歷整個圖,調用下面的函數你可以從Delaunay剖分的邊上跳到對應的Voronoi剖分的邊上。
int cv::Subdiv2D::rotateEdge(
int edge,
int rotate // get other edges in the same quad-edge: modulo 4 operation
) const;
參數rotate
指定了你想旋轉的方式,可以選擇下列參數指定下一條邊,參考下圖更易理解:
- 0, 輸入的邊 (e 下圖e 即是)
- 1, 旋轉後的邊 (eRot)
- 2, 反向邊 (反向 e)
- 3, 反向旋轉後的邊 (反向 eRot)
(譯者注:從圖中可以看出,旋轉邊默認是繞起始點逆時針)
關於頂點和邊更多的知識
頂點及其命名順序
由於Delaunay剖分初始化的方式,下面的事實總是成立的:
- 0號頂點是空頂點,沒有座標。
- 接下來,1、2、3號頂點是給定外圍矩形外側的“虛擬”頂點,每個都被設置成距離點集很遠的位置。
(譯者注:理解爲一個虛無縹緲的位置,不確定的位置,我將這3個點看作外接三角形的3個頂點,因爲三角形也是虛構的。) - 接下來的點都是點集上的點,提供給
Subdiv2D
對象。
邊及其命名順序
在Subdiv2D
對象裏的每條邊都被賦予一個整數值,這些整數被4個一組使用,每4個號碼代表的邊是相關聯的:
edge % 4 == 0
一條 Delaunay 邊edge % 4 == 1
垂直於初始邊的Voronoi 邊edge % 4 == 2
和初始邊方向相反edge % 4 == 3
上面Voronoi 邊的反向
虛擬邊和空邊
0號邊是空邊,不指向任何地方(或者,更準確地說,它的兩個端點是0號頂點-也是空的)。
1、2、3號邊總是連接虛擬頂點的虛擬Delaunay邊。指未固定的虛擬邊,因爲它的兩個頂點都是虛擬的。(譯者注:因爲邊的兩頭都是虛無縹緲,邊當然也是了,而且沒有一端是實打實的點。)
空邊的起點和終點都是(0,0)。
旋轉空邊的結果,會得到另一個空邊。從空點開始的“第一條邊”也是一個空邊,隨後用nextEdge()
產生的邊也一樣。
從任何虛擬頂點訪問的“第一條邊”總是連接到另一個虛擬頂點。(譯者注:“第一條邊”怎麼理解?)
確定外接三角形
既然,當我們對一個點集創建Delaunay剖分時,前3個點總是構建出一個外接三角形(不包括0號點)。我們可以通過下面的方式訪問這三個頂點:
Point2f outer_vtx[3];
for( int i = 0; i < 3; i++ ) {
outer_vtx[i] = subdiv.getVertex(i+1);
}
我們也能得到外接三角形的3條邊:
int outer_edges[3];
outer_edges[0] = 1*4;
outer_edges[1] = subdiv.getEdge(outer_edges[0], Subdiv2D::NEXT_AROUND_LEFT);
outer_edges[2] = subdiv.getEdge(outer_edges[1], Subdiv2D::NEXT_AROUND_LEFT);
(譯者注:這麼說來,4號邊總是外接三角形的一條邊)
確定凸包,在凸包上環繞訪問
回憶一下,根據構造函數Subdiv2D(rect)
,我們用一個外接矩形初始化了Delaunay剖分。基於此,下面的敘述成立:
如果有這樣一條邊,它的起點和終點都在矩形外側,然後這條邊在剖分的虛構外接三角形內。這樣的邊,我們叫做未固定的虛擬邊。
如果有這樣一條邊,它的兩個端點分佈在矩形內外兩側,然後內側的點在點集的凸包上。凸包上的每個點都和虛構的外圍三角形的兩個頂點相連,而且這兩條邊的序號是相鄰的。我們把這樣一端連在矩形內,一端連在矩形外虛擬點上的邊,叫做固定的虛擬邊。
基於以上事實,我們可以快速找到凸包。例如:從頂點1、2、3開始,我們知道這3個點是虛構外接三角形上的3個虛擬頂點。我們可以使用nextEdge()
可以迅速產生所有的固定的虛擬邊的集合(簡單的拒絕未固定的虛擬邊)。然後調用rotateEdge()
,取反向邊,然後再調用1次,或者2次nextEdge,就會落在凸包的邊上。準確的講,一條固定的虛擬邊對應一條凸包的邊,這些邊的集合就是凸包。
(譯者注:
解決一些疑惑
1. nextEdge()可訪問點四周的所有邊,所以從1,2,3開始會得到全部的固定的虛擬邊。
2. 通過邊可以獲得起點和終點的編號,通過邊兩端點的序號可以區分未固定的虛擬邊、固定的虛擬邊、未固定的虛擬邊。
3. rotateEdge(2)將邊的起點從虛構三角形頂點上轉移到凸包的頂點上。
4. 調用1次或2次nextEdge()恰恰驗證了凸包的頂點跟虛擬三角形有兩條連線。
)
使用示例
我們可以使用locate()
環繞Delaunay三角形的邊逐步訪問。在下面的例子中,寫了一個函數實現給定一個點,在包含點的Delaunay三角形的每條邊上做些什麼事:
void locate_point(
cv::Subdiv2D& subdiv,
const cv::Point2f& fp,
...
) {
int e;
int e0 = 0;
int vertex = 0;
subdiv.locate( fp, e0, vertex );
if( e0 > 0 ) {
e = e0;
do // Always 3 edges -- this is a triangulation, after all.
{
// [Insert your code here]
//
// Do something with e ...
e = subdiv.getEdge( e, cv::Subdiv2D::NEXT_AROUND_LEFT );
}
while( e != e0 );
}
}
給定一個點,我們也可以調用如下函數找到最近的點:
int Subdiv2D::findNearest(
cv::Point2f pt,
cv::Point2f* nearestPt
);
跟locate()
不同,findNearest()
會返回剖分中距離最近的頂點的整數ID。輸入的點不必落在面塊或三角形內。值得注意的是,這個函數不是const函數,因爲當沒有更新數據時它會計算Voronoi圖。
類似的,我們可以環繞Voronoi面塊訪問,然後畫出來它。
void draw_subdiv_facet(
cv::Mat& img,
cv::Subdiv2D& subdiv,
int edge
) {
int t = edge;
int i, count = 0;
vector<cv::Point> buf;
// Count number of edges in facet
do{
count++;
t = subdiv.getEdge( t, cv::Subdiv2D::NEXT_AROUND_LEFT );
} while (t != edge );
// Gather points
//
buf.resize(count);
t = edge;
for( i = 0; i < count; i++ ) {
cv::Point2f pt;
if( subdiv.edgeOrg(t, &pt) <= 0 )
break;
buf[i] = cv::Point(cvRound(pt.x), cvRound(pt.y));
t = subdiv.getEdge( t, cv::Subdiv2D::NEXT_AROUND_LEFT );
}
// Around we go
//
if( i == count ){
cv::Point2f pt;
subdiv.edgeDst(subdiv.rotateEdge(edge, 1), &pt);
fillConvexPoly(
img, buf,
cv::Scalar(rand()&255,rand()&255,rand()&255),
8, 0
);
vector< vector<cv::Point> > outline;
outline.push_back(buf);
polylines(img, outline, true, cv::Scalar(), 1, cv::LINE_AA, 0);
draw_subdiv_point( img, pt, cv::Scalar(0,0,0) );
}
}