Opencv轮廓跟踪算法源码分析(1)——icvFetchContour()

想要看懂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个方向。如图。

321
4 0
567


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 的情况类似。















  • 34
    点赞
  • 104
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
opencv是一个开源的计算机视觉库,opencv2.4.9是其中的一个版本。在opencv2.4.9中,有一个模块叫做stitching,用于图像拼接。 图像拼接是将多张图像按照一定的顺序和方式进行合并,形成一张更大视野覆盖范围的图像。拼接的过程需要解决图像间的重叠区域匹配、图像变换与叠加等问题。 在opencv2.4.9的stitching模块中,主要有以下几个重要的类: 1. Stitcher类:拼接器类,用于执行拼接的主要操作。它提供了一系列的方法,如设置拼接的模式、添加要拼接的图像等。 2. FeaturesFinder类:特征点检测类,用于在图像中寻找特征点。该类利用SIFT、SURF等算法来检测图像中的关键点,以便进行匹配。 3. FeaturesMatcher类:特征点匹配类,用于对图像中的特征点进行匹配。该类使用KNN算法进行特征点的匹配,并利用RANSAC算法进一步筛选特征点,剔除误匹配。 4. Estimator类:变换估计类,用于估计图像间的变换参数。该类可以通过特征点的对应关系,计算图像间的旋转矩阵、平移矩阵等变换参数。 5. Blender类:图像融合类,用于将拼接后的图像进行融合。该类可以进行多种融合方式,如线性融合、多频融合等。 通过以上的类和方法,opencv2.4.9的stitching模块能够完成图像拼接的过程。整个过程包括特征点检测、特征点匹配、变换参数估计和图像融合等步骤。 需要指出的是,本文只是对opencv2.4.9的stitching模块进行了初步的介绍,具体的源码分析需要深入研究。整个源码工程庞大,包含很多细节和算法,需要对计算机视觉和图像处理有较深入的理解才能进行分析和改进。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值