多边形区域填充算法--扫描线填充算法(有序边表法)

二、扫描线算法(Scan-Line Filling)

        扫描线算法适合对矢量图形进行区域填充,只需要直到多边形区域的几何位置,不需要指定种子点,适合计算机自动进行图形处理的场合使用,比如电脑游戏和三维CAD软件的渲染等等。

        对矢量多边形区域填充,算法核心还是求交。《计算几何与图形学有关的几种常用算法》一文给出了判断点与多边形关系的算法――扫描交点的奇偶数判断算法,利用此算法可以判断一个点是否在多边形内,也就是是否需要填充,但是实际工程中使用的填充算法都是只使用求交的思想,并不直接使用这种求交算法。究其原因,除了算法效率问题之外,还存在一个光栅图形设备和矢量之间的转换问题。比如某个点位于非常靠近边界的临界位置,用矢量算法判断这个点应该是在多边形内,但是光栅化后,这个点在光栅图形设备上看就有可能是在多边形外边(矢量点没有大小概念,光栅图形设备的点有大小概念),因此,适用于矢量图形的填充算法必须适应光栅图形设备。

 

2.1扫描线算法的基本思想

        扫描线填充算法的基本思想是:用水平扫描线从上到下(或从下到上)扫描由多条首尾相连的线段构成的多边形,每根扫描线与多边形的某些边产生一系列交点。将这些交点按照x坐标排序,将排序后的点两两成对,作为线段的两个端点,以所填的颜色画水平直线。多边形被扫描完毕后,颜色填充也就完成了。扫描线填充算法也可以归纳为以下4个步骤:

 

(1)       求交,计算扫描线与多边形的交点

(2)       交点排序,对第2步得到的交点按照x值从小到大进行排序;

(3)       颜色填充,对排序后的交点两两组成一个水平线段,以画线段的方式进行颜色填充;

(4)       是否完成多边形扫描?如果是就结束算法,如果不是就改变扫描线,然后转第1步继续处理;

 

        整个算法的关键是第1步,需要用尽量少的计算量求出交点,还要考虑交点是线段端点的特殊情况,最后,交点的步进计算最好是整数,便于光栅设备输出显示。

        对于每一条扫描线,如果每次都按照正常的线段求交算法进行计算,则计算量大,而且效率底下,如图(6)所示:

多边形区域填充算法--扫描线填充算法(有序边表法)_第1张图片

观察多边形与扫描线的交点情况,可以得到以下两个特点:

 

(1)       每次只有相关的几条边可能与扫描线有交点,不必对所有的边进行求交计算;

(2)       相邻的扫描线与同一直线段的交点存在步进关系,这个关系与直线段所在直线的斜率有关;

 

        第一个特点是显而易见的,为了减少计算量,扫描线算法需要维护一张由“活动边”组成的表,称为“活动边表(AET)”。例如扫描线4的“活动边表”由P1P2和P3P4两条边组成,而扫描线7的“活动边表”由P1P2、P6P1、P5P6和P4P5四条边组成。

        第二个特点可以进一步证明,假设当前扫描线与多边形的某一条边的交点已经通过直线段求交算法计算出来,得到交点的坐标为(x, y),则下一条扫描线与这条边的交点不需要再求交计算,通过步进关系可以直接得到新交点坐标为(x + △x, y + 1)。前面提到过,步进关系△x是个常量,与直线的斜率有关,下面就来推导这个△x。

        假设多边形某条边所在的直线方程是:ax + by + c = 0,扫描线yi和下一条扫描线yi+1与该边的两个交点分别是(xi,yi)和(xi+1,yi+1),则可得到以下两个等式:

 

axi + byi + c = 0                        (等式 1)

axi+1 + byi+1 + c = 0                     (等式 2)

 

由等式1可以得到等式3:

 

xi = -(byi + c) / a                           (等式 3)

 

同样,由等式2可以得到等式4:

 

xi+1 = -(byi+1 + c) / a                      (等式 4)

 

由等式 4 – 等式3可得到

 

xi+1 – xi = -b (yi+1 - yi) / a

 

由于扫描线存在yi+1 = yi + 1的关系,将代入上式即可得到:

 

xi+1 – xi = -b / a

 

即△x = -b / a,是个常量(直线斜率的倒数)。

 

        “活动边表”是扫描线填充算法的核心,整个算法都是围绕者这张表进行处理的。要完整的定义“活动边表”,需要先定义边的数据结构。每条边都和扫描线有个交点,扫描线填充算法只关注交点的x坐标。每当处理下一条扫描线时,根据△x直接计算出新扫描线与边的交点x坐标,可以避免复杂的求交计算。一条边不会一直待在“活动边表”中,当扫描线与之没有交点时,要将其从“活动边表”中删除,判断是否有交点的依据就是看扫描线y是否大于这条边两个端点的y坐标值,为此,需要记录边的y坐标的最大值。根据以上分析,边的数据结构可以定义如下:

typedef struct tagEDGE
{
	int ymax;
	float xi;
	float dx;
	tagEDGE(float my, float mx, float mdx) :ymax(my), xi(mx), dx(mdx) {}
	bool operator<(tagEDGE &e);
}EDGE;
bool tagEDGE::operator<(tagEDGE &e)
{
	if(xi!=e.xi)
		return xi < e.xi;
	if (dx == 0)	return e.dx > 0;
	if (e.dx == 0)	return dx < 0;
	if (dx < 0 && e.dx>0)	return true;
	if (dx > 0 && e.dx < 0)	return false;
	return dx * ymax < e.dx*e.ymax;
}

根据EDGE的定义,扫描线4和扫描线7的“活动边表”就分别如图(7)和图(8)所示:

                                                                             图(7) 扫描线4的活动边表

 

                                                                           图(8) 扫描线7的活动边表

前面提到过,扫描线算法的核心就是围绕“活动边表(AET)”展开的,为了方便活性边表的建立与更新,我们为每一条扫描线建立一个“新边表(NET)”,存放该扫描线第一次出现的边。当算法处理到某条扫描线时,就将这条扫描线的“新边表”中的所有边逐一插入到“活动边表”中。“新边表”通常在算法开始时建立,建立“新边表”的规则就是:如果某条边的较低端点(y坐标较小的那个点)的y坐标与扫描线y相等,则该边就是扫描线y的新边,应该加入扫描线y的“新边表”。上例中各扫描线的“新边表”如下图所示:

多边形区域填充算法--扫描线填充算法(有序边表法)_第2张图片

                                                                       图(9) 各扫描线的新边表

讨论完“活动边表(AET)”和“新边表(NET)”,就可以开始算法的具体实现了,但是在进一步详细介绍实现算法之前,还有以下几个关键的细节问题需要明确:

(1)      多边形顶点处理

        在对多边形的边进行求交的过程中,在两条边相连的顶点处会出现一些特殊情况,因为此时两条边会和扫描线各求的一个交点,也就是说,在顶点位置会出现两个交点。当出现这种情况的时候,会对填充产生影响,因为填充的过程是成对选择交点的过程,错误的计算交点个数,会造成填充异常。

        假设多边形按照顶点P1、P2和P3的顺序产生两条相邻的边,P2就是所说的顶点。多边形的顶点一般有四种情况,如图(10)所展示的那样,分别被称为左顶点、右顶点、上顶点和下顶点:

多边形区域填充算法--扫描线填充算法(有序边表法)_第3张图片

除了下顶点在活动边表(AET)上存在两条边,其余的顶点在AET上只存在一条边,对于另外三种顶点,我们称为局部非极限点,如果不对局部非极限点做特殊处理会导致奇偶奇数错误,常采用的修正方法是修改以顶点为终点的那条边的区间,将顶点排除在区间之外,也就是删除这条边的终点,这样在计算交点时,就可以少计算一个交点,平衡和交点奇偶个数。结合前文定义的“边”数据结构:EDGE,只要将该边的ymax修改为ymax + 1就可以了。

(2)      水平边的处理

    水平边与扫描线重合,会产生很多交点,通常的做法是将水平边直接画出(填充),然后在后面的处理中就忽略水平边,不对其进行求交计算。

(3)      如何避免填充越过边界线

        边界像素的取舍问题也需要特别注意。多边形的边界与扫描线会产生两个交点,填充时如果对两个交点以及之间的区域都填充,容易造成填充范围扩大,影响最终光栅图形化显示的填充效果。为此,人们提出了“左闭右开”的原则,简单解释就是,如果扫描线交点是1和9,则实际填充的区间是[1,9),即不包括x坐标是9的那个点。

2.2扫描线算法实现

扫描线算法的整个过程都是围绕“活动边表(AET)”展开的,为了正确初始化“活动边表”,需要初始化每条扫描线的“边表(ET)”,首先定义“边表”的数据结构。定义“边表”为一个红黑树,map的每个元素存放对应的y值最小的扫描线的所有“边”。因此定义“边表”如下:

std::map > ET;

接下来我们要以多边形的顶点计算所有的边,并且初始化ET表:

​for (unsigned i = 0; i < v.size() - 1; i++)
{
	if (v[i + 1].x == v[i].x)
			continue;
	float dx = v[i + 1].y - v[i].y == 0 ? 0 : (v[i + 1].x - v[i].x) / (v[i + 1].y - v[i].y);
	float ymax, xi, ymin;//xi低端点的x值,x高端点的x值
	if (v[i + 1].y > v[i].y){
		ymax = v[i + 1].y;
		ymin = v[i].y;
		xi = v[i].x;
	}
	else {
		ymax = v[i].y;
		ymin = v[i + 1].y;
		xi = v[i + 1].x;
	}
	EDGE e(ymax, xi, dx);
	ET[ymin].push_back(e);
}

接下来要对ET表进行更新,将特殊点进行特殊处理:

//更新边表,将非极值点边表向y轴方向沿线段缩短一个单位
	for (auto it = ET.begin(); it != ET.end();)
	{
		if (it->second.size() == 1) {
			EDGE e = *(it->second.begin());
			e.xi += e.dx;
			int ymin = it->first + 1;
			ET.erase(it++);
			ET[ymin].push_back(e);
		}
		it++;
	}

最后,我们需要定义一个AET表,对AET表的操作:

(1)画一条与x轴水平的扫描线,扫描线的初始y值与ET表中key键对应的最小y值相等,将ET表中对应的边放入AET表中,对AET表排序

循环:

  (2)在AET表中遍历所有元素,将此时扫描线与所有边表的交点两两对应,画出交点区间内的所有点,将扫描线往y轴方向移动一个单位

  (3)遍历AET表中所有元素,若扫描线的y值大于边的y值(即扫描线在边上方)则删除该边

(4)搜索ET表中是否存在该扫描线对应的边,若存在,将该扫描线对应的所有边放入AET表中

(5)对AET表排序

循环直到AET表空 :结束

//创建活动边表并填充
	std::listAET;
	int y = 0;
	
	//获取首元素
	y = ET.begin()->first;
	AET.insert(AET.begin(),ET.begin()->second.begin(),ET.begin()->second.end());
	AET.sort();
	glBegin(GL_POINTS);
	do {
		auto node = AET.begin();
		while (node != AET.end())
		{
			float x1 = node->xi;
			node->xi += node->dx;
			node++;
			float x2 = node->xi;
			node->xi += node->dx;
			node++;
			while (x1 < x2)
			{
				glVertex2f(x1, y);
				x1++;
			}
		}
		y++; 
		std::list::reverse_iterator it = AET.rbegin();
		for (; it != AET.rend();) {
			if (y > it->ymax)
				it = std::list::reverse_iterator(AET.erase((++it).base()));
			else it++;
		}
		if (ET.find(y) != ET.end())
			AET.insert(AET.end(), ET[y].begin(), ET[y].end());
		AET.sort();
	}while (!AET.empty());
	glEnd(); 

所有代码如下:

typedef struct tagEDGE
{
	int ymax;
	float xi;
	float dx;
	tagEDGE(float my, float mx, float mdx) :ymax(my), xi(mx), dx(mdx) {}
	bool operator<(tagEDGE &e);
}EDGE;
bool tagEDGE::operator<(tagEDGE &e)
{
	if(xi!=e.xi)
		return xi < e.xi;
	if (dx == 0)	return e.dx > 0;
	if (e.dx == 0)	return dx < 0;
	if (dx < 0 && e.dx>0)	return true;
	if (dx > 0 && e.dx < 0)	return false;
	return dx * ymax < e.dx*e.ymax;
}
void fill(gl::Polygon &py)
{
	//初始化边表
	std::vectorv = py.getVertexs();
	std::map > ET;
	for (unsigned i = 0; i < v.size() - 1; i++)
	{
		if (v[i + 1].x == v[i].x)
			continue;
		float dx = v[i + 1].y - v[i].y == 0 ? 0 : (v[i + 1].x - v[i].x) / (v[i + 1].y - v[i].y);
		float ymax, xi, ymin;//xi低端点的x值,x高端点的x值
		if (v[i + 1].y > v[i].y){
			ymax = v[i + 1].y;
			ymin = v[i].y;
			xi = v[i].x;
		}
		else {
			ymax = v[i].y;
			ymin = v[i + 1].y;
			xi = v[i + 1].x;
		}
		EDGE e(ymax, xi, dx);
		ET[ymin].push_back(e);
	}
	//更新边表,将非极值点边表向y轴方向沿线段缩短一个单位
	for (auto it = ET.begin(); it != ET.end();)
	{
		if (it->second.size() == 1) {
			EDGE e = *(it->second.begin());
			e.xi += e.dx;
			int ymin = it->first + 1;
			ET.erase(it++);
			ET[ymin].push_back(e);
		}
		else {
			it++;
		}
	}
	//创建活动边表并填充
	std::listAET;
	int y = 0;
	
	//获取首元素
	y = ET.begin()->first;
	AET.insert(AET.begin(),ET.begin()->second.begin(),ET.begin()->second.end());
	AET.sort();

	glBegin(GL_POINTS);
	do {
		auto node = AET.begin();
		while (node != AET.end())
		{
			float x1 = node->xi;
			node->xi += node->dx;
			node++;
			float x2 = node->xi;
			node->xi += node->dx;
			node++;
			while (x1 < x2)
			{
				glVertex2f(x1, y);
				x1++;
			}
		}
		y++; 
		std::list::reverse_iterator it = AET.rbegin();
		for (; it != AET.rend();) {
			if (y > it->ymax)
				it = std::list::reverse_iterator(AET.erase((++it).base()));
			else it++;
		}
		if (ET.find(y) != ET.end())
			AET.insert(AET.end(), ET[y].begin(), ET[y].end());
		AET.sort();
	}while (!AET.empty());
	glEnd(); 
}

3.实现缺陷

1.EDG中的ymax为int,实际应为浮点数,整形会有较大的误差

2.实际上扫描线不应该是均匀采样

最后总的来说上述实现的代码没什么实际用处=,= 若是读者有兴趣可以完善下。

你可能感兴趣的:(OpenGL)