[计算机图形学] 基于C#窗口的Bresenham直线扫描算法、种子填充法、扫描线填充法模拟软件设计(二)
上一节链接:http://www.cnblogs.com/zjutlitao/p/4116783.html
前言:
在上一节中我们已经大致介绍了该软件的是什么、可以干什么以及界面的大致样子。此外还详细地介绍了Bresenham直线扫描算法的核心思想及实现,并在最终在2-1小节引出工程中对于该算法具体的实现。本节将着手讲解多边形填充算法。
二、承接上篇
2-1、多边形扫描转换
把顶点表示转换为点阵表示:①从多边形的给定边界出发,求出其内部的各个像素;②并给帧缓冲器中各个对应元素设置相应灰度或颜色
2-2、区域填充
区域定义:已经表示成点阵的像素集合;
区域的表示:①内部表示:把给定区域内的像素枚举出来;②边界表示:把区域边界上的像素枚举出来
2-3、四连通填充和八连通填充
根据区域的特性这里可以把区域分为四连通邻域和八连通邻域。①这里所谓四连通区域即:区域内任意两个像素,从一个像素出发,可以通过上、下、左、右四种运动,到达另一个像素;②所谓的八连通区域即:区域内任意两个像素,从一个像素出发,可以通过水平、垂直、正对角线、反对角线八种运动,到达另一个像素。
因此,我们可以利用四连通或八连通的可达的特性,设计出基于种子的洪泛填充方法。这里以四连通种子填充为例:
①找到一个区域内的点,进行填充
②将其上下左右非边界且非已填充的点作为种子点继续填充
③直至递归结束
1 //4-connected boundary-fill 2 //一种基于边界返回的四连通填充法,比如绘图颜料桶的作用 3 void BoundaryFill4(int x,int y,int fill,int boundary) 4 { 5 int current; 6 current = getpixel(x, y); 7 if ((current != boundary) && (current != fill)) 8 { 9 putpixel(x, y, fill); 10 BoundaryFill4(x+1, y, fill, boundary); 11 BoundaryFill4(x-1, y, fill, boundary); 12 BoundaryFill4(x, y+1, fill, boundary); 13 BoundaryFill4(x, y-1, fill, boundary); 14 } 15 } 16 //4-connected boundary-fill 17 //一种基于新颜色返回的四连通填充法,最终多把内部和边界颜色涂成一样 18 void FloodFill4(int x,int y,int fillColor,int oldColor) 19 { 20 int current; 21 current = getpixel(x, y); 22 if (current == oldColor) 23 { 24 putpixel(x, y, fillColor); 25 BoundaryFill4(x+1, y, fillColor, oldColor); 26 BoundaryFill4(x-1, y, fillColor, oldColor); 27 BoundaryFill4(x, y+1, fillColor, oldColor); 28 BoundaryFill4(x, y-1, fillColor, oldColor); 29 } 30 }
其实基于八连通的填充算法也是类似,是一种递归算法!实践证明:当图过大进行递归时会出现爆栈的危险(本工程中如果选择2x2像素的情况用种子填充法填充多边形就会出现爆栈的情况)。下面是工程中用于种子填充的具体函数:该函数输入为一个种子点(注意该种子点一定要确保在区域内!),第3行是为了程序安全考虑,防止绘制超出窗口VRAM;这里第9行是检查checkBox的勾选来决定是用四连通填充还是用八连通填充。这里需要特别说明的有两点:
①Vram[a.X + a.Y * 600]是一个对窗口中各点的标记一维数组,能够将二位坐标映射到该一维数组中。其值初始为false:表示未走过或不是边界,当在画点成线的过程中计算边界的同时已经把边界标记为true当做已走过的点,这样就能为种子填充提供边界条件,其实这里Vram可以用INT类型,1表示边界、0表示未填充、2表示填充,就能在填充好之后区分边界和内部了;
②direction_4[4]和direction_8[8]是从当前点按照四\八连通走法向其相邻点移动的偏移量,用他们和当前点叠加就实现了当前点向其周围点坐标的转换。(这里direction_n[i]=pSearch_n[i]*XiangSu)
1 public Point[] pSearch_8 = 2 { new Point(-1, 0), new Point(-1, 1), new Point(0, 1), new Point(1, 1), 3 new Point(1, 0), new Point(1, -1), new Point(0, -1), new Point(-1, -1) 4 }; //八联通填充八个方向偏移(原始偏移) 5 public Point[] pSearch_4 = 6 { new Point(-1, 0), new Point(0, 1), new Point(1, 0), new Point(0, -1) 7 }; //四联通填充四个方向偏移
1 void FloodSeedFill(Point a) 2 { 3 if (a.X < XiangSu / 2 || a.X > 508 || a.Y < XiangSu / 2 || a.Y > 440) return;//边界情况 4 if(Vram[a.X + a.Y*600]==false) 5 { 6 Rectangle rect = new Rectangle(a.X - XiangSu / 2, a.Y - XiangSu / 2, XiangSu, XiangSu); 7 g.FillEllipse(red, rect); 8 Vram[a.X + a.Y * 600] = true;//标记已经走过 9 if (checkBox.Checked == true) 10 for (int i = 0; i < 4; i++) 11 { 12 tempp.X = a.X + direction_4[i].X; 13 tempp.Y = a.Y + direction_4[i].Y; 14 FloodSeedFill(tempp); 15 } 16 else 17 for (int i = 0; i < 8; i++) 18 { 19 tempp.X = a.X + direction_8[i].X; 20 tempp.Y = a.Y + direction_8[i].Y; 21 FloodSeedFill(tempp); 22 } 23 } 24 }
由上面的分析,我们已经知道了种子填充法会存在大图爆栈的危险,此外对于种子填充法其初始种子点的选取也是比较费时的,因为我们事先并不知道哪个点是在区域内的(我这里投机取巧取了边界点中第4个点的内侧作为初始点,所以只有运气好才会成功!)。而且,种子填充法最严重的问题是:无法处理交叉的图形填充(因为带有边交叉的图形或多个不连通的图形不符合连通性的要求了)。
2-4、扫描线填充法
既然上述种子填充算法存在这么多缺点,这里就介绍一种更加有效的方法。首先咱们还是先看几个逐点判断的例子:
(a) 射线法 (b) 夹角法
图:确定点在多边形内还是多边形外的两种方法
上面左图是通过射线法与边界交点数来判断该点在内部还是在外部;上面右图书利用夹角和是否为360°判断该点是否在多边形内部。但是可以想到这两种甚至是所有的逐点判断都不是太理想,对于百万级像素的图像生成就相当费时。那么就要介绍一种非逐点描绘的方法——扫描线算法。
扫描线算法充分利用了相邻像素之间的连贯性,避免了对像素的逐点判断和求交运算,提高了算法效率。这里主要介绍三种连贯性:①区域连贯性;②扫描线连贯性;③边的连贯性。
(a) 区域连贯性 (b) 扫描线连贯性 (c) 边的连贯性
图:三种主要的连贯性用于优化区域填充算法
由上面的三种连贯性可以看出:在扫描线连贯性的基础上应用边的连贯性,就可以由一条扫描线快速地求出下一条扫描线然后整条线整条线地填充,这就是扫描线填充算法的核心思路!下面将结合本工程关于扫描线算法的实现讲解扫描线算法(主要是复杂的数据结构了)的实现。
2-4-1、扫描线填充算法中的数据结构介绍
为了实现扫描线算法,我们需要专门设置一下边的数据结构。如下:一个边的类包括①xi,即边的下端点x坐标,在活化边链表中表示扫描线与边的交点的x坐标;②dx,即边的斜率的倒数;③ymax,即边的上顶点的y值。此外这里对几个比较等关系进行了重载(这里边的小于关系见代码)
1 public class EDGE 2 { 3 public double xi;//边的下端点x坐标,在活化链表(AET)中,表示扫描线与边的交点x坐标 4 public double dx;//是个常量(直线斜率的倒数)(x + dx, y + 1) 5 public int ymax;//边的上顶点的y值 6 public static bool operator <(EDGE a, EDGE b)//重载排列关系 7 { 8 return (Math.Abs(a.xi - b.xi)<1 ? a.dx < b.dx : a.xi < b.xi); 9 } 10 public static bool operator >(EDGE a, EDGE b)//重载排列关系 11 { 12 return (Math.Abs(a.xi - b.xi) < 1 ? a.dx > b.dx : a.xi > b.xi); 13 } 14 public static bool operator ==(EDGE a, EDGE b)//重载等于号 15 { 16 return (Math.Abs(a.xi - b.xi)<1 && a.dx == b.dx && a.ymax == b.ymax); 17 } 18 public static bool operator !=(EDGE a, EDGE b)//重载不等于号 19 { 20 return (Math.Abs(a.xi - b.xi)>1 || a.dx != b.dx || a.ymax != b.ymax); 21 } 22 }
这样对于一个多边形,则有相应的一个描述它的新边表。在本工程中新边表用List<EDGE>[] NET = new List<EDGE>[500];定义,这样NET[i]就表示扫描线y=i时刚要与之相交的边的集合。特别提示下这里建立“新边表”的规则就是:如果某条边的较低端点(y坐标较小的那个点)的y坐标与扫描线y相等,则该边就是扫描线y的新边,应该加入扫描线y的“新边表”。
根据上面我们扫描线的连贯性的分析可知:如果能找到当前扫描线与多边形的交点就能方便地绘制出该扫描线上的红色(内部)线段了!比如这里y=7时我们如果能知道这样一个结构就能轻易绘制出内部线段了。这里我们把这个数据结构定义为活动边表:AET(这是个按照边从小到大排序的链表)。
这样我们有了边的数据结构、有了新边表、有了活动边表就能实施我们的扫描线算法了:AET是扫描线填充算法的核心,整个算法都是围绕者这张表进行处理的。要完整的定义AET,需要先定义边的数据结构。每条边都和扫描线有个交点,扫描线填充算法只关注交点的x坐标。每当处理下一条扫描线时,根据△x直接计算出新扫描线与边的交点x坐标,可以避免复杂的求交计算。一条边不会一直待在AET中,当扫描线与之没有交点时,要将其从AET中删除,判断是否有交点的依据就是看扫描线y是否大于这条边两个端点的y坐标值,为此,需要记录边的y坐标的最大值。
2-4-2、扫描线填充算法细节介绍
下面是整个算法的唯一外部需要调用的函数,该函数需要传入多边形顶点的集合Q,WinForm绘图类实例的对象g,以及方便各种像素仿真用的XiangSu参数(因为FORM中使用的像素较高,绘制的点比较小,这里是按比例放大的)。从下面代码来看可知:①定义新边表然后实例化(一定要实例化,我当初没实例化吃了不少亏!);②第11行根据多边形的点求出y的最大和最小值(一定要用out关键字,C#和MFC不同,我没找到同名引用,就用这个能实现函数内部修改,然后函数结束该值也变化的效果);③第12行函数是初始化新边表;④第13行函数负责绘制所有的水平边;⑤第14行函数负责扫描线填充实现。
1 public void ScanLinePolygonFill(List<Point> Q, Graphics g, int XiangSu) 2 { 3 this.XiangSu = XiangSu; 4 this.g = g; 5 6 List<EDGE>[] NET = new List<EDGE>[500];//定义新边表 7 for (int i = 0; i < 500; i++) NET[i] = new List<EDGE>();//实例化 8 9 int ymax=0, ymin=0;//多边形y的最大值和最小值 10 11 GetPolygonMinMax(Q, out ymax, out ymin);//计算更新ymax和ymin(ok) 12 InitScanLineNewEdgeTable(NET, Q, ymin, ymax);//初始化新边表 13 HorizonEdgeFill(Q); //水平边直接画线填充 14 ProcessScanLineFill(NET, ymin, ymax); 15 }
这里是初始化新边表的函数。该函数通过遍历所有顶点获得边的信息,对于一些特殊情况(< 左交点、> 右交点、V 下交点、^ 上交点)通过判断与此边有关的前后两个顶点的情况,确定此边的ymax是否需要做-1修正。这里ps和pe分别是当前处理边的起点和终点,pss是起点的前一个相邻点,pee是终点的后一个相邻点,pss和pee用于辅助判断ps和pe两个点是否是左顶点或右顶点,然后根据判断结果对此边的ymax进行-1修正,算法实现非常简单,注意与扫描线平行的边是不处理的,因为水平边直接在HorizonEdgeFill()处理了。最后还按照边从小到大的顺序给每个扫描线上的新边集合排个序。
1 /// <summary> 2 /// 初始化新边表 3 /// 算法通过遍历所有的顶点获得边的信息,然后根据与此边有关的前后两个顶点的情况 4 /// 确定此边的ymax是否需要-1修正。ps和pe分别是当前处理边的起点和终点,pss是起 5 /// 点的前一个相邻点,pee是终点的后一个相邻点,pss和pee用于辅助判断ps和pe两个 6 /// 点是否是左顶点或右顶点,然后根据判断结果对此边的ymax进行-1修正,算法实现非 7 /// 常简单,注意与扫描线平行的边是不处理的,因为水平边直接在HorizonEdgeFill() 8 /// 函数中填充了。 9 /// </summary> 10 private void InitScanLineNewEdgeTable(List<EDGE>[] NET, List<Point> Q, int ymin, int ymax) 11 { 12 List<int> temp = new List<int>(); 13 EDGE e; 14 for (int i = 0; i < Q.Count; i++) 15 { 16 Point ps = Q[i]; 17 Point pe = Q[(i + 1) % Q.Count]; 18 Point pss = Q[(i - 1 + Q.Count) % Q.Count]; 19 Point pee = Q[(i + 2) % Q.Count]; 20 if (pe.Y != ps.Y)//不处理平行线 21 { 22 e = new EDGE(); 23 e.dx = (double)(pe.X - ps.X) / (double)(pe.Y - ps.Y) * XiangSu; 24 if (pe.Y > ps.Y) 25 { 26 e.xi = ps.X; 27 if (pee.Y >= pe.Y) 28 e.ymax = pe.Y - XiangSu; 29 else 30 e.ymax = pe.Y; 31 NET[ps.Y - ymin].Add(e);//加入对应的NET里 32 temp.Add(ps.Y - ymin); 33 } 34 else 35 { 36 e.xi = pe.X; 37 if (pss.Y >= ps.Y) 38 e.ymax = ps.Y - XiangSu; 39 else 40 e.ymax = ps.Y; 41 NET[pe.Y - ymin].Add(e);//加入对应的NET里 42 temp.Add(pe.Y - ymin); 43 } 44 } 45 } 46 for (int i = 0; i < temp.Count; i++) 47 { 48 My_Sort(ref NET[temp[i]]); 49 } 50 }
对于扫描线填充子函数比较好理解:①第3行是实例化一个AET,用于动态记录扫描线移动过程中扫描线与多边形相交的边的实时信息;②第9行函数负责将扫描线对应的所有新边插入到AET中,插入操作到保证AET还是有序表(注意这里AET前面的关键字!);③第16行负责执行具体的填充动作,它将AET中的边交点成对取出组成填充区间,然后根据“左闭右开”的原则对每个区间填充;④第17行函数负责将对下一条扫描线来说已经不是“活动边”的边从AET中删除,删除的条件就是当前扫描线y与边的ymax相等,如果有多条边满足这个条件,则一并全部删除;⑤第18行函数负责更新边表中每项的xi值,就是根据扫描线的连贯性用dx对其进行修正,并且根据xi从小到大的原则对更新后的AET表重新排序。
1 private void ProcessScanLineFill(List<EDGE>[] NET, int ymin, int ymax) 2 { 3 List<EDGE> AET=new List<EDGE>();//扫描线 4 for (int y = ymin; y < ymax; y+=XiangSu) 5 { 6 #region 显示运算信息 7 g.DrawLine(new Pen(red),new Point(10,y),new Point(20,y)); 8 g.DrawString(AET.Count.ToString(), new Font("微软雅黑", 6), blue, new Point(2, y)); 9 InsertNetListToAet(NET[y-ymin], ref AET); 10 g.DrawString(y + " -> " + NET[y - ymin].Count + " -> " + AET.Count.ToString(), new Font("微软雅黑", 6), blue, new Point(25, y)); 11 for (int i = 0; i < AET.Count; i++) 12 { 13 g.DrawString((((int)AET[i].xi) / XiangSu * XiangSu).ToString() + " ", new Font("微软雅黑", 6), blue, new Point(400 + i * 24, y)); 14 } 15 #endregion 16 FillAetScanLine(ref AET, y); 17 RemoveNonActiveEdgeFromAet(ref AET, y);//删除非活动边 18 UpdateAndResortAet(ref AET);//更新活动边表中每项的xi值,并根据xi重新排序 19 } 20 }
1 /// <summary> 2 /// 负责将扫描线对应的所有新边插入到aet中,插入操作到保证AET 3 /// 还是有序表,插入排序的思想 4 /// </summary> 5 /// <param name="list"></param> 6 /// <param name="AET"></param> 7 private void InsertNetListToAet(List<EDGE> list, ref List<EDGE> AET) 8 { 9 if (list.Count == 0) return; 10 if (AET.Count == 0) 11 { 12 AET = list; 13 return; 14 }//刚开始这里写成if()AET=list;return;一直出错!下次一定要规范!!! 15 List<EDGE> temp = new List<EDGE>(); 16 int i = 0, j = 0; 17 while (i < list.Count && j < AET.Count) 18 { 19 if (list[i] == AET[j]) 20 { 21 i++; 22 temp.Add(AET[j]); 23 j++; 24 continue; 25 } 26 if (list[i] < AET[j]) 27 { 28 temp.Add(list[i]); 29 i++; 30 continue; 31 } 32 if (list[i] > AET[j]) 33 { 34 temp.Add(AET[j]); 35 j++; 36 continue; 37 } 38 } 39 while (i < list.Count) 40 { 41 temp.Add(list[i]); 42 i++; 43 } 44 while (j < AET.Count) 45 { 46 temp.Add(AET[j]); 47 j++; 48 } 49 AET = temp; 50 //for (int i = 0; i < list.Count; i++) 51 //{ 52 // AET.Add(list[i]); 53 //} 54 //My_Sort(ref AET); 55 }
1 /// <summary> 2 /// FillAetScanLine()函数执行具体的填充动作, 3 /// 它将aet中的边交点成对取出组成填充区间, 4 /// 然后根据“左闭右开”的原则对每个区间填充 5 /// </summary> 6 /// <param name="AET"></param> 7 /// <param name="y"></param> 8 private void FillAetScanLine(ref List<EDGE> AET, int y) 9 { 10 if (AET.Count < 2) return; 11 y = y / XiangSu * XiangSu; 12 for (int i = 0; i < AET.Count; i += 2) 13 { 14 int from = ((int)AET[i].xi + XiangSu) / XiangSu * XiangSu; 15 int to = ((int)(AET[i + 1].xi + XiangSu / 2)) / XiangSu * XiangSu; 16 while (from < to) 17 { 18 Rectangle rect = new Rectangle(from - XiangSu / 2, y - XiangSu / 2, XiangSu, XiangSu); 19 g.FillEllipse(red, rect); 20 from += XiangSu; 21 } 22 } 23 }
1 /// <summary> 2 /// 负责将对下一条扫描线来说已经不是“活动边”的边从aet中删除, 3 /// 删除的条件就是当前扫描线y与边的ymax相等,如果有多条边满 4 /// 足这个条件,则一并全部删除 5 /// </summary> 6 /// <param name="AET"></param> 7 /// <param name="y"></param> 8 private int line = 0; 9 private void RemoveNonActiveEdgeFromAet(ref List<EDGE> AET, int y) 10 { 11 line = y; 12 AET.RemoveAll(IsEdgeOutOfActive); 13 } 14 private bool IsEdgeOutOfActive(EDGE obj) 15 { 16 return line == obj.ymax; 17 } 18 19 /// <summary> 20 /// 更新边表中每项的xi值,就是根据扫描线的连贯性用dx对其进行修正, 21 /// 并且根据xi从小到大的原则对更新后的aet表重新排序 22 /// </summary> 23 /// <param name="AET"></param> 24 private void UpdateAndResortAet(ref List<EDGE> AET) 25 { 26 AET.ForEach(UpdateAetEdgeInfo);//更新xi 27 My_Sort(ref AET); 28 } 29 private void UpdateAetEdgeInfo(EDGE e) 30 { 31 e.xi += e.dx; 32 }
三、最终效果
如下图最终实现了画点成线,连线成图,并用种子填充和扫描划线两种方法实现了对多边形的填充,有很好的展示效果,希望对想了解这几个算法的朋友有用!(亲-:)看完赞一下哦,让更多的分享)
相关链接
上一节链接: http://www.cnblogs.com/zjutlitao/p/4116783.html
上述工程C#源码:http://pan.baidu.com/s/1kTrAI5h
Bresenham算法讲解pdf:http://pan.baidu.com/s/1sjM6Cax
连点成线课件pdf:http://pan.baidu.com/s/1GV9i2
图形填充课件pdf:http://pan.baidu.com/s/1kTJvfOr
参考博客(good):http://blog.csdn.net/orbit/article/details/7368996
C#List.Sort的用法:http://msdn.microsoft.com/zh-cn/library/b0zbh7b6.aspx
C#参数传递百度文库:链接太长啦♪(^∇^*)
C#参数传递博客:http://www.cnblogs.com/qq731109249/archive/2012/11/01/2750401.html
C#List.RemoveAll方法:http://technet.microsoft.com/zh-cn/library/wdka673a(it-it,VS.85).aspx
最后还是打击盗版LZ链接:http://www.cnblogs.com/zjutlitao/