https://blog.csdn.net/u011947630/article/details/81382086
想要看懂Opencv源码,打算从findContours()开始。
源文件: C:\***\opencv\sources\modules\imgproc\src\contours.cpp
环境:opencv2.4.9 + VS2010
一、findContours() 接口函数
void cv::findContours( InputOutputArray _image, OutputArrayOfArrays _contours,
OutputArray _hierarchy, int mode, int method, Point offset )
Parameters:
image--待处理图,8bit单通道,非零像素被当成1,零像素仍然是0,因此该图被当成二值图来处理(实际在源码中发现,CV_8UC1和CV_32SC1都是允许的)。注意,该函数在提取轮廓的过程中会修改原始图像。(因此,在执行findContours之前将原图备份。)
contours--检测到的轮廓集合,每一个轮廓存储为 a vector of points。
hierarchy--可选的输出向量,包含图像拓扑结构信息。该向量的元素个数等于轮廓的总个数。对于每个轮廓contours[i], hierarchy[i][0], hierarchy[i][1], hierarchy[i][2], hierarchy[i][3]分别代表同级轮廓的后一个轮廓(next contour)、前一个轮廓(previous contour)、第一个子轮廓(first child contour)、母轮廓(paraent contour)。这些轮廓用基于0的序列号表示。如果没有相应的轮廓,则hierarchy[i][j]将被设置为负值。
mode--轮廓跟踪模式。(关于轮廓之间的层次关系)
- — CV_RETR_EXTERNAL 只提取最外层轮廓。所有轮廓的hierarchy[i][2] = hierarchy[i][3] =-1.
- — CV_RETR_LIST 提取所有轮廓但不建立任何层次关系。
- — CV_RETR_CCOMP 提取所有轮廓,把它们组织成两级结构。第一级是连通域的外边界(external boundaries),第二级是孔边界(boundaries of holes)。如果在孔中间还有另外的连通域,则被当成另一个外边界。
- — CV_RETR_TREE 提取所有轮廓并把它们组织成完整的层次结构。
method--轮廓近似方法。(关于单个轮廓存储的方法)
- — CV_CHAIN_CODE (手册上没有标出)采用Freeman链码的方式存储轮廓。
- — CV_CHAIN_APPROX_NONE 将Freeman链码转换成点集,并存储轮廓上所有的点。
- — CV_CHAIN_APPROX_SIMPLE 压缩水平、垂直和斜对角方向的元素,只保留线段端点的那些点。比如说,一个矩形,总共四条线段,只保留四个顶点,顶点之间的点就省略掉,所以最终该矩形轮廓只用4个点表示。
- — CV_CHAIN_APPROX_TC89_L1, CV_CHAIN_APPPRX_TC89_KCOS 使用Teh-Chin 链近似方法。
- — CV_LINK_RUNS 通过连接为1的水平碎片使用完全不同的轮廓提取算法。仅有CV_RETR_LIST 提取模式可以在本方法中使用.
offset-- 可选的偏移量参数。如果是从image ROI中提取出的轮廓,又需要在整个image分析这些轮廓,就要给每个轮廓增加一个坐标偏移量。
手册中给出了该函数参考的算法原始论文[Suzuki85] 。
- Suzuki, S. and Abe, K., Topological Structural Analysis of Digitized Binary Images by Border Following.CVGIP 30 1, pp 32-46 (1985)
论文翻译博客地址:http://blog.csdn.net/yiqiudream/article/details/76864722
论文原文及翻译下载地址:
看论文仍然糊里糊涂的,不知道链码跟踪的具体算法是什么。而contours.cpp中的函数较多,内容有点复杂,只能一个一个看,比如单个轮廓的具体跟踪方法,在 icvFetchContours() 。最后对该函数单步调试,列出了每一步执行的步骤,终于明白一点。所以记录下来。
二、icvFetchContours() 源码
为了便于理解代码,先把用到的一些宏定义贴出来。
/* initializes 8-element array for fast access to 3x3 neighborhood of a pixel */
#define CV_INIT_3X3_DELTAS( deltas, step, nch ) \
((deltas)[0] = (nch), (deltas)[1] = -(step) + (nch), \
(deltas)[2] = -(step), (deltas)[3] = -(step) - (nch), \
(deltas)[4] = -(nch), (deltas)[5] = (step) - (nch), \
(deltas)[6] = (step), (deltas)[7] = (step) + (nch))
注释:定义了一个3X3 的邻域位置快速查找表。因为在内存中,Mat数据存储的时候是一行一行连续存储的,从像素点(i,j)到像素点(i+1,j),指针偏移了一行的数据,也就是 step的距离。这里step 是指一行像素点所占的字节数,nch是步进距离,后面设置为1,也就是指针偏移一个Byte. deltas[8]是一个数组,共8个元素,分别代表8个方向。如图。
3 | 2 | 1 |
4 | 0 | |
5 | 6 | 7 |
static const CvPoint icvCodeDeltas[8] =
{ {1, 0}, {1, -1}, {0, -1}, {-1, -1}, {-1, 0}, {-1, 1}, {0, 1}, {1, 1} };
注释:deltas[8]存的是指针的偏移量,icvCodeDeltas[8] 存的是8个方向相对于中心点坐标的偏移量。
/* Contour retrieval modes */
enum
{
CV_RETR_EXTERNAL=0,
CV_RETR_LIST=1,
CV_RETR_CCOMP=2,
CV_RETR_TREE=3,
CV_RETR_FLOODFILL=4
};
/* Contour approximation methods */
enum
{
CV_CHAIN_CODE=0,
CV_CHAIN_APPROX_NONE=1,
CV_CHAIN_APPROX_SIMPLE=2,
CV_CHAIN_APPROX_TC89_L1=3,
CV_CHAIN_APPROX_TC89_KCOS=4,
CV_LINK_RUNS=5
};
icvFetchContour() 源码:
/*
marks domain border with +/-<constant> and stores the contour into CvSeq.
method:
<0 - chain //CV_CHAIN_CODE
==0 - direct //CV_CHAIN_APPRPX_NONE
>0 - simple approximation //CV_CHAIN_APPROX_SIMPLE 这个函数是只考虑这三个method的情况
*/
static void
icvFetchContour( schar *ptr,
int step,
CvPoint pt,
CvSeq* contour,
int _method )
{
const schar nbd = 2;
int deltas[16];
CvSeqWriter writer;
schar *i0 = ptr, *i1, *i3, *i4 = 0; //指向某个像素点,后面给这个像素点赋一个标记值。
int prev_s = -1, s, s_end; //表示8个方向数中的一个
int method = _method - 1; //method<=1
assert( (unsigned) _method <= CV_CHAIN_APPROX_SIMPLE ); // _method<=2
/* initialize local state */
CV_INIT_3X3_DELTAS( deltas, step, 1 );
memcpy( deltas + 8, deltas, 8 * sizeof( deltas[0] ));
/* initialize writer */
cvStartAppendToSeq( contour, &writer );
if( method < 0 ) //_method=1 CV_CHAIN_CODE
((CvChain *) contour)->origin = pt; //如果是链码,contour.origin设置为传入的起点pt,那其他method的情况呢?origin 初值是什么?
s_end = s = CV_IS_SEQ_HOLE( contour ) ? 0 : 4; //如果是孔就从0开始,如果是外边界则从4开始
do
{
s = (s - 1) & 7; //如果是孔,则从7方向(右下)开始,顺时针转; 如果是外边界,则从3开始,顺时针转
i1 = i0 + deltas[s]; //指针偏移,
if( *i1 != 0 ) //如果遇到非零点则跳出
break;
}
while( s != s_end ); //绕一圈没找到非零点也结束循环。
if( s == s_end ) /* single pixel domain */ //绕一圈没找到非零点的情况,认为是孤立点,将该点修改为负值。
{
*i0 = (schar) (nbd | -128); //(-128)补=(1000,0000)补,也就是 nbd这个值加个负号,比如2变成-2.
if( method >= 0 ) //_method>=1,
{
CV_WRITE_SEQ_ELEM( pt, writer );
}
}
else //一圈没绕完已经找到非零点的情况
{
i3 = i0; // i3代表了当前点
prev_s = s ^ 4; //异或运算,相当于(s+4)&7,也就是 s逆时针转4格,转了180度的方向,赋给prev_s. s 本身没有变,还是刚刚找到非零点的那个方向。
/* follow border */
for( ;; )
{
s_end = s; //更新搜索的初始方向
for( ;; )
{
i4 = i3 + deltas[++s]; //s先自增,也就是方向逆时针转一格,i3偏移
if( *i4 != 0 ) //找到下一个非零点的值。
break;
}
s &= 7; //s如果>=8, 则调整为 s/8. 此时,s保存了找到非零点的方向。
/* check "right" bound */
if( (unsigned) (s - 1) < (unsigned) s_end ) //int 转换成 unsigned, 本来负数的第一位是符号位的,现在变成数值了。
{
*i3 = (schar) (nbd | -128); //外轮廓最右边的标记改为NBD的负值。
}
else if( *i3 == 1 )
{
*i3 = nbd; //如果不是轮廓的最右边则是正的NBD。
}
if( method < 0 )
{
schar _s = (schar) s;
CV_WRITE_SEQ_ELEM( _s, writer );
}
else
{
if( s != prev_s || method == 0 )
{
CV_WRITE_SEQ_ELEM( pt, writer );
prev_s = s;
}
pt.x += icvCodeDeltas[s].x;
pt.y += icvCodeDeltas[s].y;
}
if( i4 == i0 && i3 == i1 )
break; // 这个for 循环每次跟踪一个像素点。再次循环跟踪下一个点,直到跟踪到最后一个点
i3 = i4;
s = (s + 4) & 7;
} /* end of border following loop */
}
cvEndWriteSeq( &writer );
if( _method != CV_CHAIN_CODE )
cvBoundingRect( contour, 1 );
assert( (writer.seq->total == 0 && writer.seq->first == 0) ||
writer.seq->total > writer.seq->first->count ||
(writer.seq->first->prev == writer.seq->first &&
writer.seq->first->next == writer.seq->first) );
}
为了单步调试该函数,我输入一张画了单个圆的图像,测试这段代码在跟踪外边界的时候的具体步骤。
测试代码:
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <math.h>
#include <iostream>
using namespace cv;
using namespace std;
int main()
{
Mat img0 = imread("one.bmp", 0);
Mat img1;
threshold(img0, img1, 1, 255, CV_THRESH_BINARY);
imshow("img1", img1);
vector<vector<Point> > contours;
vector<Vec4i> hierarchy;
findContours( img1, contours, hierarchy, CV_RETR_EXTERNAL, CV_CHAIN_CODE);
waitKey();
return 0;
}
输入的测试图片:(放大后能看得清每个像素点)。 为了便于说明,我给边界的每个点标了一个序号,从1到40. 最上面一行最左边一列的那个像素标为1.
(a)输入的测试图片 (b)我自己给每个边界上的像素点编号,方便后文说明。
边界扫描算法从左上角开始逐行扫描,当发现以下两种情况时,认为找到边界的起始点。(a)情况表示遇到了外边界;(b)情况表示遇到孔。所以对于测试图片,起始点就是第一行遇到的第一个非零像素点。
由于边界的层次关系复杂,Suzuki的方法是,找到一个边界,就用一个唯一的数字去标记,最后标记值相同的像素点属于同一个边界,不同边界之间的层次关系通过其标记值保存下来。
icvFecthContour()中的几个形参:
static void
icvFetchContour( schar *ptr, //ptr是一个指针,指向当前轮廓的起始点。
int step, // 当前图像每一行所占的Bytes数
CvPoint pt, // 中心点八邻域的相对坐标,相对于中心点的坐标。
CvSeq* contour, //跟踪到的轮廓
int _method ) // 轮廓跟踪的模式
const schar nbd = 2;
注释:nbd 就是一个标记值,number of border. 为什么从2开始而不是1呢?因为Suzuki 的方法中,一幅二值图的最上一行,最下一行,最左一行和最右一行都被设置为0,因此,这幅图像的最外层就是一个 hole, 这个hole称之为 背景(background),而这最边上的两行两列组成的轮廓就是 一个孔边界,称之为frame. 这个 frame 就用1 来标注。其次其他边界从2开始标注。
Suzuki 算法的层次关系定义:(理解 frame)
int deltas[16];
注释: 最上面定义的是 deltas[8] , 这里变成了deltas[16]. 装了什么呢?下面有定义:
/* initialize local state */
CV_INIT_3X3_DELTAS( deltas, step, 1 );
memcpy( deltas + 8, deltas, 8 * sizeof( deltas[0] ));
memcpy 是内存复制函数,首先指针指向 deltas, 把其后面的 8*sizeof( deltas[0]) 长度的数据复制下来,然后指针指向 (deltas+8),粘贴。也就是把前半部分复制给后半部分,deltas[8+i] = deltas[i] ,i=0,1,2,...7. 为什么要用到deltas[16],而不是deltas[8]呢,因为deltas[8]真的不够用,比如处理到右边界的时候,搜索下一个非零值,就会搜到0方向, 1方向,2方向。这时,++s 就会超过7,变成8,9,10)。所以定义16个数,且8-15 与0-7的值一一对应。
CvSeqWriter writer;
/* initialize writer */
cvStartAppendToSeq( contour, &writer );
CV_WRITE_SEQ_ELEM( pt, writer );
cvEndWriteSeq( &writer );
注释: writer是一个结构体,负责把当前元素写入到 contour中去。过程包括:初始化、写入元素、结束写入。
schar *i0 = ptr, *i1, *i3, *i4 = 0;
注释:定义了几个指针,指向某个像素点。其中
- i0: 指向 contour 的起始点。在轮廓跟踪过程中,该指针始终不变。对应上图中,自定义编号中的“1”。是轮廓的第一个像素点。在判断跟踪是否结束的时候用到它。
- i1: 指向 contour 的最后一个点。在轮廓跟踪过程中,该指针始终不变。对应上图中,自定义编号中的“40”。在判断跟踪是否结束的时候用到它。值得注意的是,轮廓算法中首先找到起始点,紧接着就寻找这个点。找到这个点之后才开始寻找轮廓的第二个像素点。
- i3: 指向当前中心点。中心点的意思是,当发现某个像素点属于轮廓,则将该点看做中心点,在它的8邻域内寻找下一个轮廓点。在轮廓跟踪过程中,这个指针会随着中心点的改变而改变。
- i4: 指向最新找到的非零点。以i3所指向的点为中心,在8邻域内寻找非零点,如果找到了,就用i4指向它。在轮廓跟踪过程中,这个指针会随时改变。
用三张图来表示,(a)图的状态:已经找到i0(第一个轮廓点) 和 i1(最后一个轮廓点), 并且找到了第2个轮廓点i3,此时i3作为中心点,在8邻域内找到了非零点,就是i4. (b)图的状态:经过某种判断标准,发现i4是新的轮廓点(即第3个轮廓点),让i3指向它(更新i3),该点作为当前中心点,在8邻域内寻找非零点,找到了,让i4指向它(更新i4)。 (c)图的状态:经过某种判断标准,发现i4是新的轮廓点(即第4个轮廓点),让i3指向它(更新i3),该点作为当前中心点,在8邻域内寻找非零点,如果找到则 让i4指向它(更新i4).
(a) (b) (c) 指针 i3、i4 更新示意图。
int prev_s = -1, s, s_end;
注释: Freeman链码保存的信息并不是点的坐标,而是初始点以及方向值,从第i个边界点到第 i+1 个边界点的移动方向。8个方向的定义在前面的表格中给出了。本例中边界共有40个像素点,那么就有39个方向值。代码中用了三个参数来表示方向,prev_s 表示的是上一个边界点相对于当前中心点的方向,针对当前中心点,最初 s=s_end,表示最初的扫描方向,然后++s, 寻找该方向是否是非零点。最后 if(s==s_end) 判断是否已经绕了一圈了,即8个方向都检测过了。
int method = _method - 1; //method<=1
assert( (unsigned) _method <= CV_CHAIN_APPROX_SIMPLE ); // _method<=2
注释:从 assert 函数看出,icvFetchContours()函数只支持 _method=0/1/2 三种情况。相应的,method = -1/0/1.
if( method < 0 ) //_method=0 CV_CHAIN_CODE
((CvChain *) contour)->origin = pt; //如果是链码,contour.origin设置为传入的起点pt,那其他method的情况呢?origin 初值是什么?
注释:如果_method = CV_CHAIN_CODE 表示使用Freeman 链码的模式。pt为轮廓起始点的坐标。(如果不用链码模式,则使用顶点序列模式,就是将链码转换成坐标的格式保存轮廓信息)。
s_end = s = CV_IS_SEQ_HOLE( contour ) ? 0 : 4; //如果是孔就从0开始,如果是外边界则从4开始
do
{
s = (s - 1) & 7; //如果是孔,则从7方向(右下)开始,顺时针转; 如果是外边界,则从3开始,顺时针转
i1 = i0 + deltas[s]; //指针偏移,
if( *i1 != 0 ) //如果遇到非零点则跳出
break;
}
while( s != s_end ); //绕一圈没找到非零点也结束循环。
注释: 在确定了起始点 i0 后,接下来要找的是 contour 的最后一个点 i1. 按什么方向搜索呢?算法规定,如果该 contour 是一个 hole, 则从0方向开始;如果是外边界,则从4方向开始。如下图。 do 循环就是寻找 i1 的过程。其中 s =(s-1)&7 = (s - 1) & (0111), s-1 就是顺时针方向转一格。(s-1)&7 或者写成 (s-1)/8 目的是确保方向值在[0, 7]之间,如果超过了,比如9方向,等同于1方向。
搜索 i1 点的过程(外边界的情况)
if( s == s_end ) /* single pixel domain */ //绕一圈没找到非零点的情况,认为是孤立点,将该点修改为负值。
{
*i0 = (schar) (nbd | -128); //(-128)补=(1000,0000)补,也就是 nbd这个值加个负号,比如2变成-2.
if( method >= 0 ) //_method>=1,
{
CV_WRITE_SEQ_ELEM( pt, writer );
}
}
注释:nbd=2, 是用来标注的值。 (nbd | -128)意思是 nbd 与 -128的补码做 “或”运算,相当于在首位置1,再强制转换成 (schar),有符号数,相当于是 nbd 这个值加个负号。为什么有的标 +2, 有的标 -2 呢?这是为了避免轮廓右边的部分被再次当做初始点。遇到负值的像素点是不判断它是否为一个新轮廓的起始点的,确保一个轮廓只扫描一次。下图示意了负值的标注地点(轮廓的每一行的最右边像素点)。
prev_s = s ^ 4;
注释: 异或运算。我用枚举法计算,发现s^4=(s+4)&7 。也就是 s 旋转180度,为什么要这样呢? 看最上面的标记图, 当“1”是当前中心点的时候,“2”相对于“1”的方向是5;而当“2”成为当前中心点的时候,“1”相对于“2”的方向就是1;方向5和方向1就是差了180度。 所以 prev_s 是用来记录上一个链码值的反方向值,以此为基础,确定当前的搜索的最初方向。
/* check "right" bound */
if( (unsigned) (s - 1) < (unsigned) s_end ) //int 转换成 unsigned, 本来负数的第一位是符号位的,现在变成数值了。
{
*i3 = (schar) (nbd | -128); //外轮廓最右边的标记改为NBD的负值。
}
else if( *i3 == 1 )
{
*i3 = nbd; //如果不是轮廓的最右边则是正的NBD。
}
注释:这一段就是判断边界上的点到底是标注+2 还是-2. if( (unsigned) (s - 1) < (unsigned) s_end ) 这个条件非常巧妙。我尝试了几个特例是正确的,如果非要我去写,估计先枚举出所有情况,然后再总结规律,暂且没有试。 其中,s_end 是最初的方向,s 是最新找到的非零点的方向。比如最上面标记图上的“1”号像素点到“2”号像素点,s_end=0. s=5, 不满足条件,所以“1”号像素点标记为 +2。 从 第“37”号像素点到第“38”号像素点,s_end=4, s=3,则满足条件,所以“38”号像素点标记为 -2.
if( method < 0 )
{
schar _s = (schar) s;
CV_WRITE_SEQ_ELEM( _s, writer );
}
else
{
if( s != prev_s || method == 0 )
{
CV_WRITE_SEQ_ELEM( pt, writer );
prev_s = s;
}
pt.x += icvCodeDeltas[s].x;
pt.y += icvCodeDeltas[s].y;
}
注释:这一段就是将边界像素点写入到 contour 中去。 如果是 CV_CHAIN_CODE 模式,则写入方向值 s,否则 写入点坐标 pt. pt是根据s 的值计算出来的。
if( i4 == i0 && i3 == i1 )
break; // 这个for 循环每次跟踪一个像素点。再次循环跟踪下一个点,直到……
注释: 判断轮廓跟踪有没有结束。 i4==i0,也就是最新找到的非零点就是contour的起始点。 i3 == i1, 也就是说当前的中心点 已经是contour 的最后一个点。
本文中提到的 “轮廓” “边界” 是同一个意思。
本文只用了一个外轮廓做测试,hole 的情况类似。