《Learning OpenCV 3》Delaunay三角剖分和Voronoi圖講解

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包含三個頂點:(x1,y1,x2,y2,x3,y3, )。

得到對應的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剖分

平面剖分基本的數據元素是,通過序號訪問邊。通過這個序號還可訪問相鄰邊,附加的參數還可指定想要訪問的邊同當前邊的位置關係。每個邊兩個端點叫做origindestination。一個邊會和其它邊共享這些點。最後,存在一個對應(對偶)邊,每個Delaunay剖分的邊都有Voronoi剖分的邊相對應。

記住一點,在cv::Subdiv2D接口中,對待邊總是直接的,這實際上是爲了方便。

還有,邊有方向。兩個點包含兩條邊,因爲區分origindestination

根據邊訪問點

不論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剖分初始化的方式,下面的事實總是成立的:

  1. 0號頂點是空頂點,沒有座標。
  2. 接下來,1、2、3號頂點是給定外圍矩形外側的“虛擬”頂點,每個都被設置成距離點集很遠的位置。
    (譯者注:理解爲一個虛無縹緲的位置,不確定的位置,我將這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) );
    }
}
發佈了75 篇原創文章 · 獲贊 127 · 訪問量 40萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章