1 摘要
盡管如Canny邊緣檢測器等算法能夠用于尋找圖像中分割不同區域的邊緣像素,但是這些算法并未將這些邊緣像素看作為一個整體,從而揭露更多的信息。通常下一步需要將這些邊緣像素組裝成為輪廓,OpneCV中實現該功能的函數是cv::findCountours()
。在本章開始我們將先介紹一些在使用該函數之前需要知道的基本知識,然后再介紹該函數的使用方法,最后介紹通過計算出的輪廓能夠實現的更復雜的計算機視覺任務。
2 輪廓查找
輪廓是一系列點點合集,它以某種方式表示圖像中的一條曲線。在不同環境下其表示方式也不同,在OpenCV中使用標準模版庫的向量容器vector<>
表示,向量中的每個元素都包含曲線中下一個點的位置信息。盡管2維點坐標組成的序列(vector<cv::Point>, vector<cv::Point2f>)
是最常見的表示方法,但是還有其他方法可以表示輪廓。如在Freeman Chain中,每個點表示的就是相對于前一個點在某個特定方向上的位移,在后文遇到這些方法的時候還會詳細介紹。現在只需要知道輪廓幾乎總是以標準模版庫的向量容器表示的,但是其中的元素不一定是最常用的cv::Point
。
函數cv::findCountours()
可以處理由Canny邊緣檢測器得到的結果,也可以處理通過閾值函數cv::threshold()
和cv::adaptiveThreshold()
得到的二值圖,對于后者情況而言得到的輪廓將是1值和非0值的邊界,處理邊緣圖像和二值圖像是有一定差異的,詳細信息將在下文介紹。
2.1 輪廓層次
在介紹如何提取輪廓之前還是有必須先了解輪廓是什么,以及輪廓組之間的是如何關聯的。尤其值得關注的是輪廓樹(Contour Tree)的概念,它對理解函數cv::findCountours()
很重要。在下圖中,左側是一副測試圖像,它由幾塊通過A-E表示的顏色的區域和白色背景組成。右上角是通過函數cv::findCountours()
找到的輪廓,這些輪廓通過cX或者hX的標簽表示。其中C表示的是輪廓Countour(這里先理解為顏色區域的外輪廓),它表示邊緣圍成的區域外部是白色。而H表示Hole(這里先理解為顏色區域的內輪廓),它表示邊緣圍城的區域外部是暗色部分。
包含的概念在很多應用中都很重要,因此OpenCV可以支持輸出輸出如上圖右下角表示的輪廓樹,該結構包含了輪廓之間的包含關系。在該輪廓樹中,c0輪廓為根節點,它直接包含的輪廓h00和h01是它的子節點,然后依次表示出測試圖中的所有輪廓。當然輪廓層次的組織方式處理輪廓樹外還有其他的方式,將在下文介紹。
表示樹結構的方式有很多,在OpenCV中是使用由向量元素組成的向量來表示,每個向量元素的類型為cv::Vec4i
。每個向量元素的子元素都有特殊的含義,都表示與當前索引對應的輪廓有某種關系的輪廓的索引。如果子元素對應的特殊關系的輪廓不存在,則該子元素的值為-1。例如在上圖的輪廓樹結構中,根節點即索引值為0的節點是沒有父節點的,即沒有包含該輪廓的輪廓,因此該向量元素表示父節點的子元素將被設置為1。
向量不同索引位置的子元素表示的映射關系如下。
子元素的索引 | 該位置的值指向的輪廓索引和當前輪廓的關系 |
---|---|
0 | 同層下一個輪廓 |
1 | 同層上一個輪廓 |
2 | 下一層的第一個輪廓 |
3 | 上層輪廓,即父節點輪廓 |
此時再看上圖中右下角的輪廓樹,節點之間的連線都表示在輪廓樹輸出向量中,向量內每個元素的對應索引位置子元素值指向的節點。
需要注意的是使用函數cv::findCountours()
處理通過函數cv::canny()
等邊緣檢測函數得到的結果,與處理如上圖中的二值測試圖像不同的是,輪廓查找函數并不能將邊緣圖像中的白色的曲線識別為輪廓,而是識別成狹小的色塊,因此在得到每條外部輪廓時幾乎總能同時得到一條內部輪廓,你可以將它看作是白色到黑色區域到過度,標識著邊緣的外部邊界。
2.2 提取輪廓
OpenCV中提供的構建輪廓的函數原型如下,需要注意構建輪廓的同時允許生成輪廓結構,輪廓結構的表示方式不限于輪廓樹。
// image:待提取輪廓的圖像,8位單通道圖像
// contours:檢測到的輪廓,包含STL向量元素的STL向量,其中每個向量元素包含一個輪廓的所有點,
// 具體點的組織方式和method相關,下文介紹
// hierarchy:輪廓的層級信息,具體含義和mode相關,下文介紹
// mode:輪廓層級構建的方法,下文介紹
// method:輪廓表達的方法,下文介紹
// offset:檢測到的輪廓沒個點上施加的位移量
void cv::findContours(cv::InputOutputArray image,
cv::OutputArrayOfArrays contours, cv::OutputArray hierarchy,
int mode, int method, cv::Point offset = cv::Point());
void cv::findContours(cv::InputOutputArray image,
cv::OutputArrayOfArrays contours,
int mode, int method, cv::Point offset = cv::Point());
輸入圖像image
必須是單通道,數據類型為8U
,它會被處理成二值圖像,即所有非零像素含義都相同。函數運行后會修改該圖像的數據,因此如果你還需要使用該圖像,請拷貝一份圖像作為函數參數。
參數hierarchy
為檢測到的輪廓構建出的層級關系,他是一個STL的向量,其中的每個元素都是vec4i
數據,hierarchy[i]
表示與contours[i]
表示的輪廓直接連接的輪廓信息,每個vec4i
數據的子元素都表示了對應關系連接到的輪廓的索引。每個子元素表示的映射關系在上文的表中已經描述。
參數mode
指定了輪廓提取的方式,可選的值有如下4種。當指定為cv::RETR_EXTERNAL
表示只提取最外層的輪廓,因此對于測試圖像而言只有一個最外層輪廓,因此在下圖的輪廓層級關系中該輪廓也沒有任何相連的輪廓。
當指定為cv::RETR_LIST
時表示提取所有輪廓并以列表的方式組織,如在下圖中將上圖測試圖像中的輪廓所有層都壓縮為單一層,并且輪廓依序連接,參數hierarchy
中的每個元素的vec4i
數據的第1個和第2個子元素被用于表示相互連接的節點。需要注意在新版本的OpenCV中不推薦使用該方法,因為contours
參數包含的數據是通過向量組織的,本身就可以看作是一個列表。
當指定為cv::RETR_CCOMP
時,輪廓將被分為兩層,其中所有外輪廓依序排列在上層,內輪廓在下層。外輪廓包含的第一個直屬輪廓使用vec4i
數據的第3和第4個子元素表示,同層的內輪廓或外輪廓之間使用vec4i
數據的第1和第2個子元素表示。
當指定為cv::RETR_TREE
時表示使用輪廓樹組織所有的輪廓,此時最外層的輪廓在第一層,向下包含其內部的內輪廓或者外輪廓,并使用vec4i
數據的第3和第4個子元素表示連接關系。某個外輪廓包含的直屬內輪廓之間使用vec4i
數據的第1和第2個子元素表示,如下圖中的1號和2號內輪廓都時0號外輪廓的直屬內輪廓。
參數method
決定了參數contours
返回的輪廓點是如何組織的,其可選值如下。當設置為cv::CHAIN_APPROX_NONE
時會返回輪廓的所有點,設置為該選項時將會得到大量的頂點。當設置為cv::CHAIN_APPROX_SIMPLE
時只包含線段的端點,在大多數情況下這種方式都能減少返回的頂點樹,在輪廓為矩形的極端場景下,設置改選項后只會返回矩陣的四個頂點。當設置為cv::CHAIN_APPROX_TC89_L1
或者cv::CHAIN_APPROX_TC89_KC05
時表示使用對應的Teh-Chin鏈逼近算法。如果感興趣算法的具體實現可以閱讀論文《On the Dectation of Dominant Points on Digital Curve》,由于該算法的實現不受參數影響因此這里不詳細介紹。該算法更復雜并且計算量更大,但是對于通用的曲線它可以有效的降低返回的頂點數量。
參數offset
用于平移最終計算得到的頂點坐標,當你在一副圖像的局部提取到輪廓后,想將結果轉換到整幅圖片的坐標系中,或者相反情況下你想將全局坐標轉換到局部坐標系下時,可以使用該參數。
2.3 繪制輪廓
當得到輪廓數據后可能最想做的事情就是繪制出這些輪廓,OpenCV中繪制輪廓的函數原型如下。
// image:輪廓繪制的背景圖片
// contours:輪廓數據,包含多組輪廓點的STL向量的STL向量
// contourIdx:需要繪制的輪廓索引,設置為-1時繪制所有輪廓,但是還是會受參數maxLevel的限制
// color:輪廓繪制的顏色
// thickness:輪廓繪制的線寬,設置為-1時會填充輪廓
// lineType:線段鏈接類型,4鄰域或者8鄰域或者抗拒值cv:AA
// hierarchy:輪廓層級信息,通過函數findContours獲取
// maxLevel:繪制的最大輪廓層級,下文介紹
// offset:輪廓點的偏移量
void cv::drawContours(cv::InputOutputArray image,
cv::InputArrayOfArrays contours, int contourIdx,
const cv::Scalar& color, int thickness = 1, int lineType = 8,
cv::InputArray hierarchy = noArray(), int maxLevel = INT_MAX,
cv::Point offset = cv::Point())
參數hierarchy
提供了輪廓的層級信息,而maxLevel
可以指定繪制的輪廓層級,即從contourIdx
指定的層向下再繪制maxLevel
層。當你使用cv::RETR_TREE
表示輪廓層級信息時使用這兩個參數組合能夠繪制出你想要的輪廓。另外在使用cv::RETR_CCOMP
組織輪廓層級時,使用這兩個參數組合也能很容易的繪制出外輪廓而忽略內輪廓。
示例程序TrackBarContour通過一個滑動條設置一個簡單閾值,然后從閾值處理后的圖像中提取輪廓,其核心代碼如下。
// 原圖灰度圖
cv::Mat g_gray;
// 閾值處理后的二值圖,用于查詢輪廓
cv::Mat g_binary;
// 閾值處理使用的閾值
int g_thresh = 100;
// 滾動條的事件回調函數
void on_trackbar(int, void *) {
// 生成二值圖
cv::threshold(g_gray, g_binary, g_thresh, 255, cv::THRESH_BINARY);
cv::imshow("Binary", g_binary);
// 獲取輪廓
std::vector<std::vector<cv::Point>> contours;
cv::findContours(g_binary, contours, cv::noArray(), cv::RETR_LIST,
cv::CHAIN_APPROX_SIMPLE);
// 清空二值圖
g_binary = cv::Scalar::all(0);
// 繪制輪廓
cv::drawContours(g_binary, contours, -1, cv::Scalar::all(255));
cv::imshow("Contours", g_binary);
}
int main(int argc, const char * argv[]) {
// 讀取原始圖像
g_gray = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
cv::imshow("Original", g_gray);
// 創建UI控件
cv::namedWindow("Contours", 1);
cv::createTrackbar("Threshold", "Contours", &g_thresh, 255, on_trackbar);
// 手動調用滑動條觸發函數
on_trackbar(g_thresh, nullptr);
// 掛起程序,等待用戶輸入事件
cv::waitKey();
return 0;
}
該程序使用默認閾值運行后顯示的原圖、二值圖和輪廓圖分別如下。
示例程序ContourPer首先查找了圖像中的所有輪廓,并計算每個輪廓的面積并根據面積排序,通過用戶鍵盤輸入事件控制每次只繪制一條輪廓。在該示例程序中可以通過修改查找輪廓函數cv::findContours()
的參數mode
控制輪廓層級的組織方式,輪廓繪制函數cv::drawContours()
的參數maxLevel
控制輪廓繪制的層級來更好理解這兩個參數的組合效果。程序的核心代碼如下。
// 用于比較兩個輪廓的結構體
struct AreaCmp {
public:
// 構造函數
AreaCmp(const std::vector<float>& _areas) : areas(&_areas) {}
// 重載std::sort()函數需要使用到的運算符
bool operator()(int a, int b) const {
return (*areas)[a] > (*areas)[b];
}
private:
// 保存所有輪廓的區域
const std::vector<float>* areas;
};
int main(int argc, const char * argv[]) {
// 加載圖片
cv::Mat img = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
// 得到閾值圖像
cv::Mat img_edge;
cv::threshold(img, img_edge, 128, 255, cv::THRESH_BINARY);
cv::imshow("Image after threshold", img_edge);
// 查找輪廓
std::vector<std::vector<cv::Point>> contours;
std::vector<cv::Vec4i> hierarchy;
cv::findContours(img_edge, contours, hierarchy, cv::RETR_LIST,
cv::CHAIN_APPROX_SIMPLE);
// 根據輪廓的面積降序排序
std::vector<int> sortIdx(contours.size());
std::vector<float> areas(contours.size());
for (int n = 0; n < (int)contours.size(); n++) {
sortIdx[n] = n;
areas[n] = cv::contourArea(contours[n], false);
}
std::sort(sortIdx.begin(), sortIdx.end(), AreaCmp(areas));
// 繪制單條輪廓
cv::Mat img_color;
for (int n = 0; n < (int)sortIdx.size(); n++) {
int idx = sortIdx[n];
cv::cvtColor(img, img_color, cv::COLOR_GRAY2BGR);
// Try different values of max_level, and see what happens
cv::drawContours(img_color, contours, idx,
cv::Scalar(0,0,255), 2, 8, hierarchy, 0);
cv::imshow(argv[0], img_color);
int key = cv::waitKey();
// 如果輸入ESC鍵,則退出循環
if ((key & 255) == 27) {
break;
}
}
return 0;
}
示例程序的運行結果如下圖,分別是原圖、閾值圖和繪制的輪廓圖。
2.4 快速連通區域分析
與輪廓區域緊密相關的另一個方法是聯通區域分析(Connected Component Analysis)。使用某些方法特別是閾值法分割圖像后,可以使用聯通區域分析方法處理和分離生成圖像中的區域。OpenCV提供的連通區域分析法的輸入是一張二值圖像,輸出帶標記的像素圖,其中同一個連通區域內的非零向量會分配到相同的唯一標記。例如在本章開始的例子給出的附圖中共存在5個連通區域,其中最大的一個區域包含兩個孔,次大的兩個區域各包含一個孔,最小的兩個區域不包含孔。連通區域分析發在背景分割算法中常作為后處理濾鏡,用于移除小的噪聲塊(即大輪廓中的微小閉合輪廓可以被認為是噪聲塊,它們可以都被處理成為背景)。另外在一些已知待提取前景區域的算法如OCR中,連通區域分析算法中也常被使用。
當然除了使用OpenCV提供的連通區域分析函數外,可以使用函數cv::findContours()
并將輪廓組織方式設置為設置cv::RETR_CCOMP
提取輪廓(一條外輪廓扣除掉內部的輪廓包圍區域后就是一個連通區域),然后在得到的連通區域上循環調用函數cv::drawContours()
并設置填充顏色為連通區域的標記,并將線寬設置為-1。這種方式效率更低,主要包含以下幾個原因。
- 函數
cv::findContours()
需要為每個輪廓創建一個標準庫向量,而一副圖片中包含的輪廓可以是幾百條、甚至幾千條。 - 當想要填充一個非凸區域時,函數
cv::drawContorus()
的效率也很低,需要根據輪廓構建并排序圍繞該區域 的所有細小線段。 - 收集連通區域的一些如面積和圍繞矩形等基本信息也需要額外的,有時甚至是昂貴的函數調用。
OpenCV提供的連通區域分析函數能夠快速的幫助我們快速的實現上述冗長復雜的邏輯,其函數原型如下。
// 返回值:連通區域的數量
// image:待分析的二值圖像,單通道,數據類型為8U
// labels:連通區域分析的結果
// connectivity:連通性判定的方法,4鄰域或者8鄰域
// ltype:分析結果矩陣的元素基本數據類型,可以是CV_32S或者CV_16U
int cv::connectedComponents(cv::InputArrayn image, cv::OutputArray labels,
int connectivity = 8, int ltype = CV_32S);
// stats:統計結果,N??5矩陣,N和連通區域數量相同,5列分別表示包圍連通區域的最小矩形
// 的(頂點坐標x,y,寬,高,面積)
// centroids:質心統計結果,N??2矩陣,基本數據類型為CV_64F,2列分別表示質心坐標x,y
// 如果不需要返回質心,傳入cv::noArray()
int cv::connectedComponentsWithStats(cv::InputArrayn image, cv::OutputArray labels,
cv::OutputArray stats,
cv::OutputArray centroids,
int connectivity = 8, int ltype = CV_32S);
該函數內部不會調用函數cv::findContours()
和cv::drawContorus()
,而是使用一種高效算法直接分析連通區域,該算法發表在論文《Two Strategies to Speed Up Connected Component Labeling Algorithms》中。
示例ConnectedComponents繪制了帶標記的連通區域,并移除了其中面積較小的元素,其核心代碼如下。
int main(int argc, const char * argv[]) {
// 加載原始圖片
cv::Mat img = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
cv::imshow("Source Image", img);
// 生成閾值圖
cv::Mat img_edge;
cv::threshold(img, img_edge, 128, 255, cv::THRESH_BINARY);
cv::imshow("Image after threshold", img_edge);
// 分析連通區域
cv::Mat labels, stats, centroids;
int nccomps = cv::connectedComponentsWithStats(img_edge, labels,
stats, centroids);
std::cout << "Total Connected Components Detected: " << nccomps << std::endl;
// 為每個連通區域分配一個隨機顏色,labels中的標記對應為顏色表內的索引
std::vector<cv::Vec3b> colors(nccomps + 1);
// label為0的連通區域是背景區域(即在待分析圖像中就是黑色部分),設置為黑色
colors[0] = cv::Vec3b(0,0,0);
for (int i = 1; i <= nccomps; i++) {
// 面積如果小于100,則設置為黑色
if (stats.at<int>(i-1, cv::CC_STAT_AREA) < 100) {
colors[i] = cv::Vec3b(0,0,0);
} else {
colors[i] = cv::Vec3b(rand()%256, rand()%256, rand()%256);
}
}
// 繪制連通區域分析結果圖像
cv::Mat img_color = cv::Mat::zeros(img.size(), CV_8UC3);
for (int y = 0; y < img_color.rows; y++) {
for (int x = 0; x < img_color.cols; x++) {
int label = labels.at<int>(y, x);
CV_Assert(0 <= label && label <= nccomps);
img_color.at<cv::Vec3b>(y, x) = colors[label];
}
}
// 展示連通區域分析結果
cv::imshow("Labeled map", img_color);
return 0;
}
該示例程序的運行結果如下圖,從坐至右分別是原始圖像,閾值處理后的圖像,連通區域分析后的著色圖像。
3 深入輪廓
圖像的輪廓數據分析出來后,我們可能對其中的部分輪廓感興趣。想要簡化它們,或者計算它們的近似幾何形狀,將其與模版圖形進行匹配,以及做一些其他操作。本小節會介紹一些和圖像輪廓相關的復雜計算機視覺任務,并介紹一些OpenCV提供的函數,這些函數有的直接實現了這些圖像處理任務,其他的可以簡化些圖像處理任務。
3.1 近似多邊形
通常在處理某條輪廓的時候會計算其對應的包含更少頂點的近似多邊形,OpenCV提供兩種方式實現該任務,其中函數cv::approxPolyDP()
原型如下。該函數是Douglas-Peucker(DP)逼近算法的實現。與之對應的常用算法還有Rosenfeld-Johnson和Teh-Chin算法。這兩種算法中,Teh-Chin算法在OpenCV中不能用于縮減頂點,但是可以在提取輪廓時使用,詳情可以參考前文函數cv::findContours()
的介紹。
// curve:待處理的輪廓,2維坐標集合,可以使用N??1的雙通道矩陣對象,或者包含cv:Point
// 元素的標準向量
// approxCurve:近似多邊形的輪廓,可以使用矩陣或者向量,但是需要和參數curve一致
// epsilon:原始輪廓到近似多邊形輪廓允許的最大位移
// closed:是否需要閉合輪廓,即從輪廓的最后一個頂點鏈接到第一個頂點形成閉合曲線
void cv::approxPolyDP(cv::InputArray curve, cv::OutputArray approxCurve,
double epsilon, bool closed);
更好的理解Douglas-Peucker算法可以加深對函數cv::approxPolyDP()
的理解,也能幫助我們選擇合適的epsilon
參數。該算法原理如下圖所示,對于b圖中給定的輪廓,選擇最遠的兩個點連接相連的到c圖中的一條直線,然后再從原來的輪廓點里選擇離該線最遠點并更新輪廓得到d圖,隨后算法繼續迭代得到e圖,最后當原始輪廓中的所有頂點到近似多邊形的某條邊的距離都小于參數epsilon
值的時候算法停止迭代,輸出最終得到的近似多邊形。
因此epsilon
建議設置為輪廓的軸長,或者輪廓外包矩形周長或者其他能夠表示輪廓整體大小的一個分量。
3.2 特征計算
在處理輪廓時通常需要計算其各種特征,如邊長,輪廓矩(Contour Moments)等,輪廓矩可以用于概括輪廓的大致形狀特征。OpenCV提供了一些列函數用于計算這些特征,它們不僅適用于表示曲線的點集,如計算邊長,也可以用于無任何含義的點集,如計算最小包含矩形。
3.2.1 輪廓長度
OpenCV計算輪廓長度的函數原型如下,需要注意該函數只對表示輪廓的點集才有意義。
// 返回值:輪廓的長度
// points:待分析的輪廓,N??1的雙通道矩陣,或者STL向量
// closed:輪廓是否閉合,即是否需要將輪廓的首尾頂點相連
double cv::arcLength(cv::InputArray points, bool closed);
3.2.2 最小直立包圍矩形
計算點集的最小直立包圍矩形(即矩形的邊一定水平或者豎直)函數原型如下,該函數適用于表示任何含義的頂點集合。
// 返回值:點集的最小包圍矩形
// points:頂點幾何,N??1的雙通道矩陣,或者STL向量
cv::Rect cv::boundingRect(cv::InputArray points);
3.2.3 最小旋轉包圍矩形
OpenCV還支持查找包含點集的最小旋轉矩形,即其邊不需要一定水平和垂直。如在下圖中左側是尋找到的最小直立包圍矩形,而右側是尋找到到最小旋轉包圍矩形,該矩形到面積更小。圖中cv::RotatedRect
是專用于表示旋轉矩形的數據結構。
本系列文章最初將數據結構時已經介紹過cv::RotatedRect
,這里列出該類的定義如下,幫助我們回憶該數據結構。
class cv::RotatedRect {
// 矩形中心點,也是矩形的旋轉點
cv::Point2f center;
// 矩形的大小,相對于旋轉中心的寬和高
cv::Size2f size;
// 矩形旋轉的角度
float angle;
};
計算最小旋轉矩形的函數原型如下,該函數適用于表示任何含義的頂點集合。
// 返回值:技術得到的最小旋轉矩形
// points:待分析的點集,可以是N??1的雙通道矩陣,或者是STL向量
cv::RotatedRect cv::minAreaRect(cv::InputArray points);
3.2.4 最小包圍圓
計算最小包圍圓的函數原型如下,該函數適用于表示任何含義的頂點集合。
// points:待分析的點集,可以是N??1的雙通道矩陣,或者是STL向量
// center:計算得到的圓心
// radius:計算得到的半徑
void cv::minEnclosingCircle(cv::InputArray points,
cv::Point2f & center, float & radius);
3.2.5 最佳包圍橢圓
需要注意這里的的最佳包圍橢圓和前文的集中最小包圍框不同,最佳包圍橢圓不需要必須包含所有的點集。計算該橢圓的函數原型如下,該函數適用于表示任何含義的頂點集合。
// 返回值:表示橢圓的旋轉矩形
// points:待分析的點集,可以是N??1的雙通道矩陣,或者是STL向量
cv::RotatedRect cv::fitEllipse(cv::InputArray points);
計算最佳包圍橢圓使用到了最小平方擬合函數(least-squares function),這里不對該函數詳細介紹。函數的返回值是cv::RotatedRect
的實例,它表示橢圓的最小矩形。例如在下圖中從左至右分別是點集的最小包圍圓,最佳包圍橢圓,表示最佳包圍橢圓的旋轉矩形。
3.2.6 最佳擬合曲線
在很多時候,得到的輪廓是一條近似直線的點集,或者說是對直線的帶噪聲的采樣。基于很多原因我們可能想要擬合出這條直線,因此也出現了很多擬合方法。OpenCV中通過使得成本函數(Cost Function)取得最小值來完成該任務,該函數定義如下。
其中θ表示的是定義直線的一系列參數,而xi表示的是第i個點,ri表示的是該點到由參數集合θ定義到直線之間的距離,而p(ri)則是定義單個點的距離成本,計算單個點距離成本的方式有很多。其中OpenCV提供的選項cv::DIST_L2
就是了解基礎統計學讀者最屬性的最小平方擬合法。當需要更精確的擬合結果時,如需要很好的處理異常數據點時,可以使用更復雜的成本計算方法。下表列出了OpenCV支持的成本計算方法及其數學公式,其中給出的C值為建議的參數。
擬合最佳去想的函數原型如下,該函數適用于表示任何含義的頂點集合。
// points:待分析的點集,可以是N??1的雙通道/三通道矩陣,或者是STL向量
// line:擬合得到的直線端點,處理2D點集時使用Vec4f,處理3D點集時使用Vec6f,
//。 前半部分表示直線的方向,后半部分是直線上的一個點
// distType:成本計算方法,見上表
// param:成本計算公式中需要用到的參數C,見上表,設置為0時將會使用上表中的建議值
// reps:擬合曲線的點精度,常用1e-2
// aeps:擬合曲線的角度精度,常用1e-2
void cv::fitLine(cv::InputArray points, cv::OutputArray line,
int distType, double param, double reps, double aeps);
3.2.7 輪廓突包
突包指的是特殊的突出多邊形,如下圖C圖多邊形,其中任意三個相鄰頂點組成的兩個向量的內角都必須小于180度。而輪廓突包指的是從輪廓中提取的這種多邊形,多邊形的所以頂點都應該來自于表示輪廓的點集。如下圖中的B圖是從A圖人像中提取的輪廓,而C圖是根據B圖計算的輪廓突包。
計算輪廓突包的原因可能有很多,其中一個就是當判斷一個點是否位于復雜多邊形內部時,先判斷其是否位于從該復雜多邊形提取的輪廓突包內部,這樣能極大的加快程序整體效率。計算輪廓突包的函數原型如下,改函數只對表示輪廓的點集有意義。
// points:待分析的點集,可以是N??1的雙通道矩陣,或者是STL向量
// hull:計算出的輪廓突包
// clockwise:輸出頂點的方向,fase時為逆時針,ture為順時針
// returnPoints:返回頂點坐標,還是在point中的索引
// 當參數hull類型為向量時,該參數被忽略,因為根據向量的數據類型為int或者是
// cv::Point能夠判斷出你想要返回的是索引還是點坐標,當參數hull類型為
// cv::Mat時,該參數必須正確設置
void cv::convexHull(cv::InputArray points, cv::OutputArray hull,
bool clockwise = false, bool returnPoints = true);
3.3 幾何學測試
在處理圍繞矩形以及其他表示輪廓整體形狀的多邊形時,通常需要執行一些如多邊形重疊或者快速圍繞矩形重疊檢測,OpenCV提供了一些函數來處理這些任務。大多數和矩形相關的幾何學測試功能都是通過矩形的數據結構類來提供的,如cv::Rect
提供函數contains()
用于測試某個點是否在矩形內部。
包含兩個矩形的最小矩形可以通過代碼rect1 | rect2
計算,兩個矩形的重疊矩形可以通過rect1 & rect2
計算。但是對于旋轉矩形cv::RotatedRect
而言,OpenCV并沒有提供相應的成員函數,但是OpenCV額外提供了一些函數來處理任意多邊形。
3.3.1 測試點是否位于多邊形內
測試點是否位于多邊形內的函數原型如下。
// 返回值:點距離多邊形邊的最短距離,點位于多邊形外時返回值為正,剛好位于多邊形邊上時返回值為0,
// 在多邊形內部返回值為負
// contour:表示多邊形的輪廓,二維點組成的N??1雙通道矩陣或者是向量
// pt:測試點
// measureDist:是否需要精確返回距離,當其為ture時會返回精確的距離,否則返回1表示在多邊形外,
// 0表示在多邊形邊上,-1表示在多邊形內
double cv::pointPolygonTest(cv::InputArray contour, cv::Point2f pt,
bool measureDist);
3.3.2 測試輪廓是否為凸多邊形
確定一個多邊形是否為凸多邊形是一個很常見的操作,這樣做的原因可能有很多,但是最典型的一個原因就是OpenCV中的一些算法只能用于凸多邊形,或者有些算法在處理凸多邊形時可以極大簡化算法邏輯,提升算法效率。處理該任務的函數原型如下,需要注意算法默認傳入的多邊形是閉合的,并且多邊形的邊不能相交。
// 返回值:待測試的多邊形是否為凸多邊形
// contour:表示多邊形的輪廓,二維點組成的N??1雙通道矩陣或者是向量
bool cv::isContourConvex(cv::InputArray contour);
4 匹配輪廓與圖像
目前你應該已經了解了輪廓是什么以及如何使用OpneCV定義的輪廓對象,接下來將會介紹一些在實際工作中輪廓的使用示例。最常見的計算機視覺任務就是比較兩個輪廓,或者是使用計算出的輪廓匹配模版。
4.1 矩
比較兩個輪廓的最簡單方式就是計算輪廓矩(Contour Moments),輪廓矩是表示一個輪廓、一副圖像或者一個點集(為了方便下文統稱對象)的某種高層次特征,其定義公式如下。
在上述公式中,矩mpq是對象中所有“像素值”的總和,每個像素的值都是其像素強度和系數xp和yq的乘積。在計算矩m00時,如果處理的是二值圖像,則m00計算的就是圖像中非零像素的個數,如果處理的是輪廓,則它計算的就是輪廓的長度,如果處理的是點集,則它計算的是點點數量。需要注意在處理輪廓時,如果先對輪廓進行光柵化處理,如使用函數cv::drawContours()
繪制輪廓,然后再計算輪廓的矩,則得到的長度和直接計算輪廓矩得到的長度并不完全相同,當然在分辨率無限的情況下它們是相同的。
如果理解了m00,則很方便理解對于二值圖像而言,m10/m00和m01/m00分別計算了非零像素的平均x值以及平均y值。術語矩與統計學相關,而更高階的矩與統計學分布有關,如面積、均值和方差等。在這個背景下你可以將非二值圖像的矩看作是二值圖像矩的特殊形式,其每個像素包含多個值。
計算矩等函數原型如下。
// 返回值:待處理對象的矩
// points:待處理對象,可以是二維點集合(N??1或者1??N矩陣,或者STL向量),也可以是圖像(M??N矩陣)
// binaryImage:是否為二值圖像,選擇false時像素強度會被看作是像素點“質量”
cv::Moments cv::moments(cv::InputArray points, bool binaryImage = false);
該函數在處理二維點集點時候不會將參數points
包含點數據看做是離散點點,而是輪廓點頂點,這以為著如果你想只處理這些點時,需要使用這些點創建一張圖像,再將其作為該函數點輸入。參數binaryImage
設置為YES時,所有非零值都將被識別為1,這對于處理應用閾值操作后的圖像很有幫助,因為在這些圖像中非零值可能會被設置為255或者其他值。cv::Moments
是OpenCV表示矩的數據結構,其定義如下。
class Moments {
public:
double m00; // 0階矩(x1)
double m10, m01; // 1階矩(x2)
double m20, m11, m02; // 2階矩(x3)
double m30, m21, m12, m03; // 3階矩(x4)
double mu20, mu11, mu02; // 2階中心矩(central moments)(x3)下文介紹
double mu30, mu21, mu12, mu03; // 3階中心矩(x4)
double nu20, nu11, nu02; // 2階標準化中心矩(Hu invariant moments)(x3)下文介紹
double nu30, nu21, nu12, nu03; // 3階標準化中心矩(x4)
// 構造函數
Moments();
Moments(
double m00,
double m10, double m01,
double m20, double m11, double m02,
double m30, double m21, double m12, double m03
);
// 將老版本的CvMoments轉換為新的C++對象
Moments( const CvMoments& moments );
// 重載運算符,將C++對象轉換為老版本的老版本的CvMoments
operator CvMoments() const;
}
函數cv::moments()
調用一次會計算到三階(p + q <= 3)矩,并且會同時計算中心矩和標準化中心矩。
4.2 深入理解矩
矩能夠描述輪廓的基本特征,也能作為參考比較兩個輪廓。但是在實際場景中普通的矩,即在類Moments的定義中大部分以m和數字組合的屬性并不是最好的比較標準。即對于兩個相同的但是相互之間有位移,或者是有縮放,或者是發時旋轉的兩個對象而言,其普通矩計算結果并不相同。
4.2.1 中心矩平移不變性
對于形狀相同但是發生位移的兩個輪廓和圖像,其m00矩的計算結果是相同的,但是高階普通矩就不相同了。考慮m10矩計算的是對象包含像素的x坐標分量平均值,很明顯如果對象發生位移這個值將會隨之變化。顯然這不是我們想要的結果,我們想要的是比較不同位置的對象能夠得到相同對值,即判斷標準具有平移不變性(Invariant Under Translation),計算結果不隨對象的相對位置改變而改變。
為了處理平移的場景需要使用到中心矩(Central Moments),其計算公式如下。
顯然中心矩mu00(矩數據結構屬性以mu開頭,等同于上述公式的μ)等于普通矩m00,因為任何數的0次冪都等于1,另外中心矩陣mu10和mu01都等于0。由于中心矩的計算都是相對均值的變化,因此對象的平移不會改變中心矩的計算結果。
另外mu00等用m00,mu10=mu01=0,nu00=1,nu10=nu01=0,由于它們是固定值或者和其他值相等,因此在Moments的定義中為了節省內存空間不包含這些屬性。
4.2.2 標準化中心矩縮放不變性
使用相機拍攝同一個物體時,相機的遠近會導致獲取的圖像中目標對象的大小不一致,因此通常我們需要一個不受縮放影響的比較標準,而標準化中心矩(Normalized Central Moments)就滿足這個條件,它具有縮放不變性。在調用函數cv::moments()
會同時計算普通矩,中心矩和標準化中心矩。標準化中心矩計算時會使用中心距除以目標整體大小的一個指數冪,其計算公式如下。
4.2.3 Hu不變矩旋轉不變性
Hu不變矩(Hu invariant moments)是標準化中心矩的線性組合,Hu不變矩處理同個目標對象的縮放、旋轉、鏡像(除h1矩外)對象時都能得到相同的計算結果。Hu不變矩的定義如下。
為了更形象的體驗hu不變矩的實際意義,我們分別計算下圖的5個不同對象的多階hu不變矩。
計算結果如下表。
對象 | h1 | h2 | h3 | h4 | h5 | h6 | h7 |
---|---|---|---|---|---|---|---|
A | 2.837e–1 | 1.961e–3 | 1.484e–2 | 2.265e–4 | –4.152e–7 | 1.003e–5 | –7.941e–9 |
I | 4.578e–1 | 1.820e–1 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
O | 3.791e–1 | 2.623e–4 | 4.501e–7 | 5.858e–7 | 1.529e–13 | 7.775e–9 | –2.591e–13 |
M | 2.465e–1 | 4.775e–4 | 7.263e–5 | 2.617e–6 | –3.607e–11 | –5.718e–8 | –7.218e–24 |
F | 3.186e–1 | 2.914e–2 | 9.397e–3 | 8.221e–4 | 3.872e–8 | 2.019e–5 | 2.285e–6 |
從上表中可以明顯看出隨著hu矩的階數不斷增加,得到的值越小。這并不奇怪,因為根據前文提到的hu矩定義,高階Hu矩陣是一系列標準因子的高階冪,由于這些標準因子的值都小于1,因此指數越高其計算得到的值越小。
值得關注的是對象I,它具有180度旋轉和鏡像對稱性,其3到7階hu矩值都等于0,對象O也具有類似的對稱性,盡管其所有hu矩都不為0,但是其中兩個已經接近于0了。
計算Hu矩有單獨的函數,它需要使用普通矩的計算結果作為輸入,即函數cv::moments()
的計算結果作為輸入,其函數原型如下。
// moments:對象的普通矩,即函數cv::moments()的計算結果
// hu:計算得到的Hu矩,C風格的數組,共包含1-7階矩,共7個元素
void cv::HuMoments(const cv::Moments& moments, double * hu);
4.3 使用Hu矩進行匹配
計算對象的矩的目的顯然是需要比較兩個程度等相似度,OpenCV專門提供了函數直接根據指定的方法比較兩個對象的相似程度,其函數原型如下。
// object1:第一個待比較的對象,可以是二維點集,也可以是元素類型為cv:U8C1的矩陣
// object1:第二個待比較的對象,可以是二維點集,也可以是元素類型為cv:U8C1的矩陣
// method:比較方法下文介紹
// parameter:保留參數,暫時不會使用
double cv::MatchShapes(cv::InputArray object1, cv::InputArray object2,
int method, double parameter = 0);
該函數內部會計算對象的矩,然后再進行比較,并將計算結果返回。參數method
的可選值及對應的函數返回值的計算公式如下。其中A和B分別對應函數的輸入對象object1
和object2
。
4.4 使用形狀場景方法比較形狀
使用矩來比較形狀是一個經典的技術,最早可以追溯到上個世紀80年代,OpenCV同樣提供更好的現代算法用于形狀比較。在OpenCV3中有單獨的模塊Shape負責處理這些任務,其中最典型的就是形狀場景算法(Shape Context)。該模塊尚在開發中,因此這里只簡略介紹其高層抽象結構以及部分非常有用的函數和數據結構。
4.4.1 形狀模塊的基本結構
形狀模塊的核心是形狀距離提取器類cv::ShapeDistanceExtractor
,該抽象類型適用于任何用于比較兩個或者更多形狀并返回能夠量化這兩種形狀差異的距離度量的函數子(Functor)。這里使用距離這個詞來衡量不相似性是因為在很多場景下,距離和差異性具有相同的屬性,如對于兩個完全相同的對象而言,它們的距離為0。
在繼續介紹該類之前,先要熟悉另外兩個基本的數據結構,其中ShapeTransformer
的定義如下。
class ShapeTransformer : public Algorithm {
public:
virtual void estimateTransformation(
cv::InputArray transformingShape,
cv::InputArray targetShape,
vector<cv::DMatch>& matches
) = 0;
virtual float applyTransformation(
cv::InputArray input,
cv::OutputArray output = noArray()
) = 0;
virtual void warpImage(
cv::InputArray transformingImage,
cv::OutputArray output,
int flags = INTER_LINEAR,
int borderMode = BORDER_CONSTANT,
const cv::Scalar& borderValue = cv::Scalar()
) const = 0;
};
類ShapeTransformer
廣泛用于表示一類將一個點集重映射到另外一個點集的算法,更一般化的場景就是將一副圖片映射成為一張新的圖片。本系列文章前面章節講到的仿射和投影變換也能夠通過定義形狀轉換器實現。其中的一個重要例子就是薄板樣條轉換器(Thine Plate Spline Transform),該轉換器得名于金屬薄板物理模型,并且它從根本上解決了金屬薄板上的部分控制點移動到其他位置而產生的映射問題。當控制點移動后金屬薄板會隨之變性,這樣其內部稠密點的映射變換就是算法要解決的問題。事實證明這是一個廣泛適用的數據結構,在圖像對其和形狀匹配中也有很多應用。在OpenCV中該算法被定義為函數子cv::ThinPlateSplineShapeTransformer
。
HistogramCostExtractor
的定義如下。
class HistogramCostExtractor : public Algorithm {
public:
virtual void buildCostMatrix(
cv::InputArray descriptors1,
cv::InputArray descriptors2,
cv::OutputArray costMatrix
) = 0;
virtual void setNDummies( int nDummies ) = 0;
virtual int getNDummies() const = 0;
virtual void setDefaultCost( float defaultCost ) = 0;
virtual float getDefaultCost() const = 0;
};
直方圖成本提取器推廣可以得到在前面章節技術地球移動距離(Earth Mover Distance, EMD)時使用到的數據結構,在計算EMD距離時我們計算的是從一個直方圖分組移動“數據”到另外一個分組的成本。有時這個成本是常量或者是與移動距離線性相關,但是有時成本與移動的”數據量“相關。計算EMD距離有單獨的函數可以調用,而基類cv::HistogramCostExractor
和其派生類可以用于處理更一般化的問題。下表列出了該類的派生類及其處理的任務類型。
直方圖成本提取器的派生類 | 成本含義 |
---|---|
cv::NormHistogramCostExtractor | 使用L2或者其他范數計算成本 |
cv::ChiHistogramCostExtractor | 使用卡方距離(Chi-Square Distance)計算成本 |
cv::EMDHistogramCostExtractor | 和EMD距離使用L2范數計算的成本相同 |
cv::EMDL1HistogramCostExtractor | 和EMD距離使用L1范數計算的成本相同 |
對于上表中的每個類型的成本提取器,OpenCV都提供了一個類似createX()
的函數用于生成對應的實例,例如cv::createChiHistogramCostExtractor()
。
4.4.2 形狀場景距離提取器
本小節的開頭就介紹過形狀模塊的核心是形狀距離提取器類cv::ShapeDistanceExtractor
,現在介紹它的一些派生類。首先介紹的是形狀場景距離提取器ShapeContextDistanceExtractor
,它的內部實現使用了一個形狀轉換器和一個直方圖成本提取器,其定義如下。
namespace cv {
// 形狀場景距離提取器的定義
class ShapeContextDistanceExtractor : public ShapeDistanceExtractor {
public:
...
virtual float computeDistance(InputArray contour1, InputArray contour2) = 0;
};
// 構建形狀場景距離提取器的函數
Ptr<ShapeContextDistanceExtractor> createShapeContextDistanceExtractor(
int nAngularBins = 12,
int nRadialBins = 4,
float innerRadius = 0.2f,
float outerRadius = 2,
int iterations = 3,
const Ptr<HistogramCostExtractor> &comparer =
createChiHistogramCostExtractor(),
const Ptr<ShapeTransformer> &transformer =
createThinPlateSplineShapeTransformer()
);
}
實質上形狀背景距離算法計算了多個待比較形狀的某種特征表示,其中每個特征都是形狀邊界點的子集計算,對于該子集中的每個采樣點,該算法都會以該點為觀察點創建一個能夠在極坐標系中反應輪廓特征的直方圖。所有的直方圖大小相同,都為nAngularBins??nRadialBins。從形狀contour1
中的點pi和形狀contour2
中的點qj計算出的直方圖使用經典的卡方距離比較(Chi-squared Distance)。然后算法計算形狀contour1
中采樣子集p和形狀contour2
中的采樣子集q中點點最優關聯,使得整體卡方距離最小。該算法并不是最快的,甚至計算成本矩陣的復雜度為N??N??nAngularBins??nRadialBins(對于每個p中的點q都需要計算所有的點從而找到最小的卡方距離,而每次尋找咖啡距離會遍歷直方圖所有元素),其中N是形狀邊界點采樣子集的數量。但是這個算法仍然能夠給出不錯的結果。
示例程序SCDE使用形狀背景提取器比較了兩個形狀,其核心代碼如下。
/// 提取圖片輪廓,并隨機采樣頂點
/// - Parameters:
/// - image: 待采樣的圖片
/// - n: 采樣頂點數
static std::vector<cv::Point> sampleContour(const cv::Mat& image, int n = 300) {
// 查找所有的輪廓
std::vector<std::vector<cv::Point>> contours;
cv::findContours(image, contours, cv::RETR_LIST, cv::CHAIN_APPROX_NONE);
// 這里提取出第一條輪廓的所有頂點,由于準備的圖片只能提取出一張輪廓,
// 因此得到的就是想要尋找的輪廓
std::vector<cv::Point> all_points;
for (size_t j = 0; j < contours[0].size(); j++) {
all_points.push_back(contours[0][j]);
}
// 如果單條輪廓的頂點數量小于n,則重復該條輪廓已有的頂點,直至輪廓數量等于N
int dummy = 0;
for (int add = (int)all_points.size(); add < n; add++) {
all_points.push_back(all_points[dummy++]);
}
// 使用隨機順序排列所有的頂點
unsigned seed =
(unsigned)std::chrono::system_clock::now().time_since_epoch().count();
std::shuffle(all_points.begin(), all_points.end(),
std::default_random_engine (seed));
// 隨機采樣輪廓的n個頂點
std::vector<cv::Point> sampled;
for (int i = 0; i < n; i++) {
sampled.push_back(all_points[I]);
}
return sampled;
}
int main(int argc, const char * argv[]) {
// 讀取待比較的兩個圖片
cv::Mat img1 = imread(argv[1], cv::IMREAD_GRAYSCALE);
cv::Mat img2 = imread(argv[2], cv::IMREAD_GRAYSCALE);
// 分別計算兩個圖片的隨機采樣輪廓頂點
std::vector<cv::Point> c1 = sampleContour(img1);
std::vector<cv::Point> c2 = sampleContour(img2);
// 比較兩個形狀的距離
cv::Ptr<cv::ShapeContextDistanceExtractor> mysc =
cv::createShapeContextDistanceExtractor();
// 可能是由于XCode使用的編譯器是Clang + LLVM,使得程序運行時報動態庫符號綁定
// 錯誤:Symbol not found: ___emutls_get_address
float dis = mysc->computeDistance(c1, c2);
std::cout << "shape context distance between "
<< argv[1] << " and " << argv[2] << " is: " << dis << std::endl;
// 顯示兩個形狀
cv::imshow("SHAPE #1", img1);
cv::imshow("SHAPE #2", img2);
// 掛起程序等待用戶輸入
cv::waitKey();
return 0;
}
更復雜的使用示例請參考OpenCV3的官方文檔中的示例程序…samples/cpp/shape_example.cpp。
4.4.3 Hausdorff距離提取器
和形狀背景距離提取器一樣,Hausdorff距離提取器也繼承自類ShapeDistanceExtractor
,它同樣可以用于比較形狀的差異。其定義如下。
class CV_EXPORTS_W HausdorffDistanceExtractor : public ShapeDistanceExtractor {
public:
CV_WRAP virtual void setDistanceFlag(int distanceFlag) = 0;
CV_WRAP virtual int getDistanceFlag() const = 0;
CV_WRAP virtual void setRankProportion(float rankProportion) = 0;
CV_WRAP virtual float getRankProportion() const = 0;
};
算法計算公式如下(推測第一個公式的h(BA)誤寫為h(Ba))。首先獲取了圖片中的所有點,對于每個點,找到最近與它最近點的距離,這些距離里面的最大值定義為直接Hausdorff距離(Directed Hausdorff Distance),使用符號h表示。兩個直接Hausdorff距離中的大值就是Hausdorff距離(Hoausdorff Distance),使用符號H表示。需要注意直接Hausdorff距離是非對稱的,即交換括號內AB順序會影響計算結果,而Hausdorff距離是對稱的。公式中的|| X ||表示某種范數,常用的是歐式距離。本質上該算法計算的是兩個形狀中距離最遠的一組點。
構建Hausdorff距離提取器的函數原型如下。
// distanceFlag:距離計算方式
cv::Ptr<cv::HausdorffDistanceExtractor> cv::createHausdorffDistanceExtractor(
int distanceFlag = cv::NORM_L2, float rankProp = 0.6);
5 小結
本章是圍繞輪廓和二維空間點集展開的,它們可以使用包含如cv::Vec2f
等點對象的STL向量表示,也可以使用N??1的雙通道矩陣或者N??2的單通道矩陣表示。輪廓也可以表示為二維點集,OpenCV提供了一系列函數來提取和處理輪廓。
輪廓在表示圖片的分區時非常有用,OpenCV也提供了很多工具函數來比較輪廓對象以及計算它們的一些特征屬性,如輪廓凸包,矩以及任意點和輪廓的關系。最后OpenCV提供了很多方式匹配輪廓和形狀,文中介紹了經典的基于矩的比較方法,也介紹了基于形狀距離提取器的新特性。