下文提到的种子点生长算法,包括泛洪法,扫描线法,区段法三种。文本先从最简单的泛洪法入手介绍种子点生长算法的相关概念。之后进一步讨论了扫描线法和区段法,同时提供了实验数据验证其中的一些结论。本文按照如下的结构来介绍:
- 泛洪法
- 扫描线法
- 区段法
- 算法分析对比以及实验
一、泛洪法
1.简介
泛洪法,又叫Floodfill算法,属于图像处理算法,是从图像中寻找连通区域的经典算法。因其思路类似洪水从一个区域扩散到所有能到达的区域而得名。可能很多人都有接触过这个算法,因为大多数人都使用过Windows自带的画图工具或者Photoshop,知道里里面有一种工具叫做“油漆桶”。实际上这个油漆桶在使用的时候,他的背后就是执行了二维的泛洪法。使用油漆桶的时候,你所需要做的事,首先是选择一个你需要的颜色,然后通过你的肉眼在你想要填充的区域选择(点击)一个点,这个点就是所谓的“种子点”,然后这些图像处理软件就会自动帮你把所有和这个种子点在一个“联通区域”的点都涂成你需要的颜色。
泛洪法有几个重要的要素如下:
1.领域选择策略。即四邻域还是八邻域,亦或是用户自定义的邻域。
2.包含策略。即一个点是否能纳入当前联通区域?是要求颜色等于某值,还是在某个范围内?也可以采用补集即“拒绝策略”,决定什么样的点不能加入。
3.生长方式。深度优先还是广度优先。
2.关于邻域
平面二维泛洪法一般是用两种领域选择策略,一种是四领域,一种是八领域。首先要说明一下邻域的概念。邻域的概念是相对于一个点而言的,对于图像上的一个像素P,跟它相关的属性有他的横纵位置X,Y,像素值V,邻域S。S也是像素的集合,S里面是和P位置相邻的像素,相邻有很多的定义方法,如下文涉及的四邻域和八邻域。
P的四邻域可以表示为:
S=Adj4(P)={P0(P.X-1,P.Y),P1(P.X+1,P.Y),P2(P.X,P.Y-1),P3(P.X,P.Y+1)}。
即四个邻域分别表示当前像素上下左右的相邻像素(若当前像素为边界像素,则应从中去掉超出边界的像素)。
P的八邻域可以表示为:
S=Adj8(P)={P0(P.X-1,P.Y),P1(P.X+1,P.Y),P2(P.X,P.Y-1),P3(P.X,P.Y+1),P4(P.X-1,P.Y-1),P5(P.X+1,P.Y+1),P6(P.X+1,P.Y-1),P7(P.X-1,P.Y+1)}。
相对四邻域多了斜相邻的左上、右上、左下、右下像素。
使用泛洪法寻找联通区域时,采用四邻域和八邻域的策略结果往往会有所不同,一般来讲,采用八邻域的泛洪法找到的区域会比四邻域的大。原因是在不同的邻域策略下,对“联通区域”的定义是不同的。八领域的联通区域在生长的时候,包含条件更宽松(因为斜对面有同色像素也认为是相邻,在同一个区域里),所以区域也会更大。当然两种策略也可以产生一样大的区域,这要看具体的图像。
下图是泛洪法选择四个方向扩散的例子:
还需要注意的是:其实邻域也不止这两种,还可以自己定义。比如也可以定义一个2邻域,只有一个像素左右两个像素才算做“邻居”这样也是可以的。至于这样的邻域什么时候能派上用场,那就得看应用场合了。
3.关于生长方式
泛洪法最简单的实现方法是采用深度优先搜索的递归方法,也可以采用广度优先搜索的迭代来实现。
FloodFill(P,bitmap,replaceColor,targetColor) foreach point T in Adj(P) if color at T is equal to targetColor set color at T to replaceColor FloodFill(T,bitmap,replaceColor,targetColor) end
如果不希望使用递归的算法:可以使用栈或者队列的结构,相关的伪代码如下:
FloodFill(seed,bitmap,replaceColor)
get targetColor from bitmap using seed position
set color at seed point to replaceColor
set queue Q to empty
push seed into Q
while Q is not empty
pop a point P from Q
foreach point T in Adj(P)
if color at T is equal to targetColor
set color at T to replaceColor
push T into Q
end
上面的伪代码使用的是队列Q作为容器,这就是所谓的广度搜索策略。而若是将上面的Q声明为LIFO的栈S,实际上算法执行的过程就和递归的算法一样了。下图分别是采用队列和栈的结构,算法执行过程的演示,从图中可以很好的看出,队列式的泛洪法更加与“泛洪”相像,是向四周洪水一样扩散;而栈式的泛洪法就是“一路走到黑,没路再回头”式的扩散,就像仙剑里走迷宫一样。
![]() | ![]() |
队列式生长 | 栈式生长 |
4.使用位图标记表拓展的更一般的泛洪法
在实际做图像处理的算法中,有时目的并不是为了对图像进行修改,而是为了获取图像的信息,图像本身必须保证不能被修改,这时就有引入更具一般性的泛洪法的必要。从上面的介绍知道,经典的泛洪法,其实是对原图像的填充,执行过程中明显的修改了图像,每找到一个点就把这个点染上新颜色。拓展的泛洪法的算法思路和上文所说的泛洪法是一样的,但为了在不修改原始图片的情况下寻找连通区域(这样的应用可能只是把联通区域的点集求出来或者统计区域点数,并不是要填充区域),所以必须引入标记的机制来判断当前点是否已经被包含入区域,这个机制是必须的。因为仔细分析泛洪法的执行过程就可以知道,一个点可能被考察不止一次,同一个点可能会被不同方向的邻居来的“水”给冲到。在经典泛洪法中,当一个点被涂为目标颜色时,即表明它已被纳入联通区域。但当在不能够修改原始图像的场合,想要求出从一个种子点出发的联通区域,除了使用原始图像的副本之外,往往更好的办法是使用“标记位图表”。
标记位图表可以理解为长宽和原始图像一样,元素值为bool的二维数组,它是用于对应原图像的相同位置的像素的,在泛洪法执行的过程中,通过标记已经纳入联通区域的点,使得新考察的点可以通过访问这标记的值来判断是否已被纳入联通区域。这个位图表的实现可以有比较灵活的方式,若空间紧张,可以使用按bit表示的数组,如std::vector就是标准库实现好的一个动态大小的位图结构,他的一个bool元素在内存中实际上只占了1位(注意c++的std::vector和其他的模版实例化的类std::vector不同,具体信息请查询相关资料),使用这样的结构作为标记位图表的实现,使得表只需要占据 1/8n的原图片空间(n是图片的位数除以8)。C#语言也提供了BitArray类实现位数组,和std::vector一样。至于如何使用一维数组对应二维图像中的点,这个技巧是图像处理邻域的常识,就是所谓的“i+j*width”方式,详情参考第一篇“图像数据的组织方式”。
那么,把泛洪法的概念括宽之后,其实,可以将泛洪法的伪代码用以下方式来描述:
FloodFill(seed,bitmap,includePredicate,process)
set all postions of flagMap to false
set container Q to empty.
push seed into Q
set seed position in flagMap to true
process(seed)
while Q is not empty
pop a point P from Q
foreach point T in Adj(P)
if includePredicate(T) is true
set position of T in flagMap to true
push T into Q
process(T)
end
其中includePredicate是判断新点能否加入当前区域的函数。算法把这种类似函数的东西当参数,所以可以理解其为C++中的函数指针、c#中的委托这样的参数形式。对于一般的泛洪法,就是像素点在图像边界之内的颜色相等。Process是处理当前新加入的像素的函数,就是对当前像素的处理,对于一般泛洪法就是把这个像素的颜色改为目标颜色。之所以把includePredicate和Process当作参数传递,是为了方便拓展,因为算法的用户可能不需要默认的生长策略(如像素值相等)和纳入处理(如染色),而是要自己定义自己的生长策略和纳入处理,所以这两个函数让用户去写更为合适。
使用这样的形式拓展经典的泛洪法,使得泛洪法的算法思想可以推广到很多应用上去。比如图像分割中的区域生长算法,实际上就是泛洪法的变形。他的includePedicate是判断新点在图像边界之内且新点T的像素值和为了找到它所出发的旧点P的像素值之差小于一个给定值。同时,这个算法思想也可以联系到很多非图像处理领域的算法思想。比如对数据结构中“图”结构的遍历,实际上无论是深度还是广度遍历,算法的思想都和拓展的的泛洪法类似。为了方便,再往下的泛洪法,都是指这种方式拓展后的算法。
5.实现
还需要指出的是:为方便起见,下文以后所说的图像,都是指8位图像,也就是用一个byte表示一个像素的图像。下面的代码是用C#实现的泛洪法所需要的基础结构,一共是三个,点结构,位图类,位图标记表类。位图标记表的实现采用了.NET自带的BitArray类型,这个类型是一个位数组,和C++的std::vector类似。它比用一般的bool数组少占7/8的空间:
点结构:
public struct Int16Double { public int X; public int Y; public Int16Double(int x, int y) { X=x; Y=y; } }//点结构,用于存储像素点的XY坐标
位图类(严格说应该叫8位位图类):
public class BitMap2d { public const byte WHITE = 255; public const byte BLACK = 0; public byte[] data; public int width; public int height; public BitMap2d(int width, int height, byte v) { this.width = width; this.height = height; data = new byte[width * height ]; for (int i = 0; i < width * height; i++) data[i] = v; } public BitMap2d(byte[] data, int width, int height) { this.data = data; this.width = width; this.height = height; } public void SetPixel(int x, int y, byte v) { data[x + y * width] = v; } public byte GetPixel(int x, int y) { return data[x + y * width]; } }//表示2维图像的结构,存储图像数据在data中并提供存取像素的方法。
位图标记表类:
public class FlagMap2d { public int width; public int height; BitArray flags; public FlagMap2d(int width, int height) { this.width = width; this.height = height; flags = new BitArray(width * height , false); } public void SetFlagOn(int x, int y, bool v) { flags[x + y * width] = v; } public bool GetFlagOn(int x, int y) { return flags[x + y * width]; } }//表示2维图像的位图标记表,和图像的位置一一对应,值为bool,表示该点对应像素的状态。
下面的c#代码是泛洪法的实现主体:
class FloodFill2d { protected BitMap2d bmp; protected FlagMap2d flagsMap; protected Container_Queue<Int16Double> queue; protected int count = 0; public virtual bool IncludePredicate(Int16Double p) { return bmp.GetPixel(p.X, p.Y) == BitMap2d.WHITE; }//加入区域的判断,本代码采用的加入条件为像素值等于WHITE(255) public virtual void Process(Int16Double p) { count++; return; }//对新加入的点的处理,本代码的处理只是简单计数,应根据应用需要选择不同处理 public void ExcuteFloodFill(BitMap2d data,Int16Double seed) { this.bmp = data; flagsMap = new FlagMap2d(data.width, data.height); queue = new Container_Queue<Int16Double>(); Int16Double[] adjPoints4 = new Int16Double[6]; flagsMap.SetFlagOn(seed.X, seed.Y, true); queue.Push(seed); Process(seed); while (!queue.Empty()) { Int16Double p = queue.Pop(); InitAdj4(ref adjPoints4, ref p); for (int adjIndex = 0; adjIndex < 4; adjIndex++) { Int16Double t = adjPoints4[adjIndex]; if (t.X < data.width && t.X >= 0 && t.Y < data.height && t.Y >= 0) { if (!flagsMap.GetFlagOn(t.X, t.Y) && IncludePredicate(t)) { flagsMap.SetFlagOn(t.X, t.Y, true); queue.Push(t); Process(t); } } } } return; }//泛洪法执行主程序,注意本代码采用队列,用栈也能达到目的,两者的区别在后文会涉及 private void InitAdj4(ref Int16Double[] adjPoints4, ref Int16Double p) { adjPoints4[0].X = p.X - 1; adjPoints4[0].Y = p.Y ; adjPoints4[1].X = p.X + 1;adjPoints4[1].Y = p.Y; adjPoints4[2].X = p.X ;adjPoints4[2].Y = p.Y - 1; adjPoints4[3].X = p.X ;adjPoints4[3].Y = p.Y + 1; }//初始化邻域的函数,若想改为8邻域,只需修改这个函数 }
可以看出这里定义了一个类,includePredicate和Process定义为虚函数便于扩展。ExcuteFloodFill包含泛洪法主要逻辑。
二、扫描线算法
1.介绍
泛洪法的效率问题一直是相关领域的研究重点,之前所描述的方法其实并不是寻找联通区域的最快的办法。实际上,业界在寻找图像联通区域这个问题上有很多效率上优于经典泛洪法的实现。
扫描线算法(Scanline)是最为著名的泛洪法的改进算法,扫描线种子填充算法的基本思想是:首先填充当前扫描线上的位于给定区域内的一区段,然后确定与这一区段相邻的上下两条扫描线上位于该区段内是否存在需要填充的新区段,如果存在,则依次把它们保存起来。反复这个过程,直到所保存的各区段都填充完毕。算法执行如下的过程:
- 初始化堆栈
- 种子压入堆栈
- while(堆栈非空)
- 从堆栈弹出种子象素。
- 求出种子区段:xleft、xright 并填充整个区段。
- 检查相邻的上扫描线的xleft≤x≤xright区间内,是否存在需要填充的新区段,如果存在的话,则把每个新区段在xleft≤x≤xright范围内的最右边的象素,作为新的种子象素依次压入堆栈。
- 检查相邻的下扫描线的xleft≤x≤xright区间内,是否存在需要填充的新区段,如果存在的话,则把每个新区段在xleft≤x≤xright范围内的最右边的象素,作为新的种子象素依次压入堆栈。
- 算法结束
算法用伪代码描述如下,其中算法采用了和上文泛洪法相同的表述方式,includePredicate判断待考察点是否能纳入区域中,process是对新加入的点的有关处理。
ScanLineFill(seed,bmp,includePredicate,process) initialize container Q to empty set all flags in flagMap to false push seed into Q while Q is not empty pop point P from Q find out xleft from P find out xright from P check the line range from (xleft,P.Y-1) to (xright,P.Y-1) check the line range from (xleft,P.Y+1) to (xright,P.Y+1) end
其中的寻找xleft和xright步骤以及检查两侧扫描线的子程序伪代码如下所示:
Findxleft(p,flagMap,Q,includePredicate,process) initialize xleft to p.X-1 while (true) if xleft reaches 0 or flag at point(xleft,p.Y) is true break the circle else then if includePredicate(xleft,p.Y) is true set flag at (xleft,p.Y) to true process point(xleft,p.Y) xleft decrease by 1 else break the circle return xleft+1 end
Findxright(p,flagMap,Q,includePredicate,process) initialize xright to p.X+1 while (true) if xright reaches bmp.width or flag at point(xright,p.Y) is true break the circle else then if includePredicate(xright,p.Y) is true set flag at (xright,p.Y) to true process point(xright,p.Y) xright decrease by 1 else break the circle return xright-1 end
Findxleft和Findxright的操作从p点出发,向各自的方向寻找连续的未被标记且符合加入区域条件的点,直到遇到边界或者不满足条件的点停止。每找到合适的点,即做上标记并进行所需的处理,最后返回在相应的方向所到达的最远位置。
CheckRange(xleft,xright,y,flagMap,Q,includePredicate,process) initialize index to xleft while index is less than or equal to xright if flag at (index,y) is false and includePredicate(index,y) is true initialize rb to index+1 while rb is less than xright and flag at (index,y) is false and includePredicate(index,y) is true rb increase by 1 rb decrease by 1 set flag at (rb,y) to true push (rb,y) to Q process(rb,y) set index to rb+1 else then index increase by 1 end
CheckRange函数对纵坐标为Y、横坐标在范围[xleft,xright]内的点进行考察,这个考察过程实际上是需要识别出在这个大区段上所有未被填充的子区段然后把这些子区段中的一个点(其实可以是区段左、右顶点或者其中任意一点)加入堆栈,下图所示说明这个大区段中的小区段,浅蓝色的部分是这个大区段上不能被加入的像素(不符合加入区域的条件或者已经被标记),他们把这个大区段分成了若干小区段。其中rb就是每个区段的最右像素,并被标记上后加入堆栈。
这些被加入堆栈的点,会在之后的过程中从栈中弹出然后再对他们进行Findxleft和Findxright操作,这样这些点所在的区段的所有点也就被加入了。用C#实现的扫描线算法的代码如下,其中相应的基本结构和泛洪法一致,可以看出扫描线算法的三个主要操作就是Findxleft,Findxright和CheckRange。
2.代码实现
class ScanlineFill2d { protected int count = 0; protected Container<Int16Double> container;//这个容器可以是Queue和Stack中任意一种,这里抽象成一个Container protected BitMap2d bmp; public FlagMap2d flagsMap; protected virtual void ExcuteScanlineFill(BitMap2d data, Int16Double seed) { this.bmp = data; data.ResetVisitCount(); flagsMap = new FlagMap2d(data.width, data.height); flagsMap.SetFlagOn(seed.X, seed.Y, true); container.Push(seed); Process(seed); while (!container.Empty()) { Int16Double p = container.Pop(); int xleft = FindXLeft(p); int xright = FindXRight(p); if (p.Y - 1 >= 0) CheckRange(xleft, xright, p.Y - 1); if (p.Y + 1 < data.height) CheckRange(xleft, xright, p.Y + 1); } }//该函数为扫描线法主体 protected void CheckRange(int xleft, int xright, int y) { for (int i = xleft; i <= xright; ) { if ((!flagsMap.GetFlagOn(i, y)) && IncludePredicate(i, y)) { int rb = i + 1; while (rb <= xright && (!flagsMap.GetFlagOn(rb, y)) && IncludePredicate(rb, y)) { rb++; } rb--; Int16Double t = new Int16Double(rb, y); flagsMap.SetFlagOn(rb, y, true); container.Push(t); Process(t); i = rb + 1; } else { i++; } } }//CheckRange操作 protected int FindXLeft(Int16Double p) { int xleft = p.X - 1; while (true) { if (xleft == -1 || flagsMap.GetFlagOn(xleft, p.Y)) { break; } else { byte value = bmp.GetPixel(xleft, p.Y); if (IncludePredicate(xleft, p.Y)) { Int16Double t = new Int16Double(xleft, p.Y); flagsMap.SetFlagOn(xleft, p.Y, true); Process(t); xleft--; } else { break; } } } return xleft + 1; }//FindXLeft操作 protected int FindXRight(Int16Double p) { int xright = p.X + 1; while (true) { if (xright == bmp.width || flagsMap.GetFlagOn(xright, p.Y)) { break; } else { byte value = bmp.GetPixel(xright, p.Y); if (IncludePredicate(xright, p.Y)) { Int16Double t = new Int16Double(xright, p.Y); flagsMap.SetFlagOn(xright, p.Y, true); Process(t); xright++; } else { break; } } } return xright - 1; }//FindXRight操作 protected bool IncludePredicate(int x, int y) { return bmp.GetPixel(x, y) == BitMap2d.WHITE; } protected void Process(Int16Double p) { count++; } }
3.特点
扫描线算法之所以比经典泛洪法具有更好的效率,主要是由于具有了以下几个特点:
- 该算法考虑了扫描线上像素的相关性,种子象素不再代表一个孤立的像素,而是代表一个尚未填充的区段。
- 进栈时,只将每个区段选一个像素进栈(每个区段最右边或最左边的像素),这样解决了堆栈溢出的问题。
- 种子出栈时,则填充整个区段。
- 这样有机的结合:一边对尚未填充像素的登记(像素进栈),一边进行填充(像素出栈),既可以节省堆栈空间,又可以实施快速填充。
需要注意的是,以上的扫描线算法结果等价于4向泛洪法,而不等价于8向泛洪法。以上四点特点并不能完整说明扫描线法效率高的原因。关于扫描线能加快填充区域速度的细节原因,会在下文的“效率分析与实验的部分”详细说明。
三、区段算法
1.简介
实际上基于Span的算法本质思想和基于扫描线算法一样。Span即“区段”,表示图像上纵坐标相等的一段连续像素集合。采用显式的区段数据结构作为填充和出入堆栈的单元,这样能够加快堆栈操作的效率。同时在区段结构上可以适当添加标记,增加了信息,可以避免不必要的回溯。
为了由浅入深的介绍算法,先要知道该算法反复执行的操作是:
1.弹出区段(pop span)→2.区段伸展(FindXleft or FindXright)→3.检查邻接区段并建立新区段入栈(CheckRange)
这样的循环一直持续到堆栈为空。下文中会详细介绍这些操作具体是做什么事。
2.数据结构和操作
首先需要了解区段结构及其相关枚举标记的定义:
public struct Span { public int XLeft;//区段左界 public int XRight;//区段右界 public int Y;//区段Y坐标 public ExtendTypes Extended;//区段类别 public ParentDirections ParentDirection;//父区段方向 } public enum ParentDirections { Y0 = 1, //表示该区段的父区段在上方(Y-)方向(上方) Y2 = 2,//表示该区段的父区段在下方(Y+)方向(下方) Non= 5//表示该区段无父区段方向,通常只是初始区段是这个类型 } public enum ExtendTypes { LeftRequired = 1, //当前区段右端已被探查到底,只需要FindXleft操作即可 RightRequired = 2, //当前区段左端已被探查到底,只需要FindXRight操作即可 AllRez = 3, //当前区段左右两端已被探查到底,不需要左右探查操作 UnRez = 4 //当前区段左右均未被探查到底,需要左右探查操作 }
其中“父区段方向(PairentDirection)”标记的作用是指示当前区段是从哪个方向(上方还是下方)的区段出发所寻找出来的;“伸展类型(ExtendType)”表示该区段当前所需要进行的是哪种操作(这里不清楚可以继续往下看)。下文为了叙述方便,我们将一个横坐标范围为a到b、纵坐标为y的区段(span)记录为[a-b,y]
因为该算法不再基于点,而是基于区段的,所以相应的堆栈或者队列容器里存放的不在是Int16Double表示的点坐标,而是区段结构Span。此算法的思想和扫描线一样,都有类似的操作Findxleft,Finldxright和CheckRange,下面的叙述是这三种操作所做的具体事情,可以结合示意图进行理解:
FindXLeft
对区段sp执行Findxleft,即是从区段sp的左端点sp.xleft开始向左边寻找连续的能够纳入区域的点,每找到一个即加入区域并做标记,直到找不到为止,最后返回找的的左界xleft,这样的操作实际上在左方形成了一个新追加的范围extended range。示意图如下:
FindXRight
对区段sp执行Findright,即是从区段sp的右端点sp.xright开始向右边寻找连续的能够纳入区域的点,每找到一个即加入区域并做标记,直到找不到为止,最后返回找的的右界xright,这样的操作实际上在右方形成了一个新追加的范围extended range。示意图如下:
CheckRange
执行CheckRange,即是对某一个区间(图中为a到b)遍历需找所有连续符合纳入条件的像素组成的段。在扫描线算法的实现中CheckRange实际上也找出来了这些“区段”,但由于扫描线算法不显式的包含“区段”的概念,所以只是把寻找出来的区段中的一点(上文说最右点)加入队列。而在区段算法中,CheckRange操作每找到一个区段,就会为每个区段新建一个Span结构,记录下其左右边界和Y坐标,同时对区段内的点做已访问标记,然后再推入堆栈。在[a-b,y]区段内执行CheckRange示意图如下,可以看出此操作生成了四个新的子区段:
3.算法流程
下图说明了对于一个从栈中弹出的区段current span(红色部分)的详细操作。该区段被弹出后,首先进行左右延伸到xleft和xright;然后在延伸后形成的[xleft-xright,y]范围的上方区间[xleft-xright,y-1]进行考察(当然还要对下方区间也要考察这里只列出一部分);考察的过程中发现了三个新区段span1、span2和span3(绿色部分)。
从上述示意图可以看出,新形成的span根据自己在区段[xleft-xright,y-1]中的位置,实际上有四种不同的可能的形态种类。下表说明了这三个span各自的ExtendType和ParentDirection应设置的标记。
ExtendType | ParentDirection | |
span1 | LeftRequired | Y+ |
span2 | AllRez | Y+ |
span3 | RightRequired | Y+ |
首先ParentDirection的设置比较容易理解。这三个span对其均设置ParentDirection为Y+,意味着这三个子区段都是从他们Y+方向的父区段(红色部分)创建而来。
关于ExtendType的设置的解释如下:
- span1的ExtendType被标记为LeftRequired,意味着对于span1来说,他还有向左边进一步延伸的可能,因为它的左端接触到了xleft。所以当它将来从栈中弹出来后,应先执行FindXLeft再CheckRange。
- span2的ExtendType被标记为AllRez,意味着对于span2来说,不需要再向左右方向探查,它的左右方向已经确定不能伸展。所以当它将来从栈中弹出来后,不用左右伸展,应直接CheckRange。
- span3的ExtendType被标记为LeftRequired,意味着对于span3来说,他还有向右边进一步延伸的可能,因为它的右端接触到了xright。所以当它将来从栈中弹出来后,应先执行FindRight再CheckRange。
下面的示意图显示区段的标记为UnRez的情况,注意这此时当前span(红色部分)在考察区间的上方。当此span执行过findxleft或者findxright后,延伸出的范围为区段[xleft-xright,y]。当对此范围下方区段[xleft-xright,y+1]进行CheckRange时,若此操作产生唯一的区段span1,且span1完全充满[xleft-xright,y+1]区间,此时这个span即被标志为第四种ExtendType类型UnRez。
- span1的ExtendType被标记为UnRez,意味着对于这样的两边都触底的区段,既有向左也有向右延伸的可能。所以当它将来从栈中弹出来后,需要FindXleft和FindXright再CheckRange。
ExtendType | ParentDirection | |
span1 | UnRez | Y- |
简而言之,就是在CheckRange操作时,若产生的新的span的左端正好接触左界,但右端又不接触右界,这个span就属于LeftRequired;方向反过来的就是RightRequired;两边都不接触左右界的是AllRez;两边都接触则为UnRez。所以不难有这样的推论:若CheckRange操作只产生一个子区段,这个区段必然是UnRez,LeftRequired或者RightRequired三种之一;而若产生不止一个区段,则LeftRequired和RightRequired只可能是第一个和最后一个区段,中间的区段必然是AllRez。
在知道了标记的含义之后,还需要知道ExtendType和ParentDirection是如何配合使用的,即如何有效避开已经访问过的或者已经被确认无所需点的区域。如下的示意图进一步展示了具有不同标记的span从堆栈中弹出后应该对哪些范围CheckRange:
- 一个具有leftRequired标记的span,由于右侧已然不能延伸,对于此span只需执行findxleft操作得到左界xl,之后再根据ParentDirection的方向(例图中父区段在下方),在Y方向只需考察绿色的范围上方的(xl- ~ span.xright)和下方的(xl~ span.xleft)(考察的意思就是对范围执行CheckRange操作,即下图的绿色部分)即可。
- 一个具有rightRequired标记的span,由于左侧已然不能延伸,对于此span只需执行findxright操作得到右界xr,之后再根据ParentDirection的方向(例图中父区段在下方),在Y方向只需考察绿色的范围上方的(span.xleft~ xr)和下方的(span.xright~ xr)即可。
- 一个具有AllRez标记的span,由于左侧右侧均不能再延伸,对于此span,直接考察其一侧范围(span.xleft ~ span.xright)即可。
- 一个具有UnRez标记的span,对于此span则同时需要findxleft和findxright操作,找到左右界xl和xr。两侧除了parent span的部分,之外都需要考察,即上方的(xl- ~ xr)、下方的(xl~ span.xleft)和(span.xright ~ xr)。
综上所述,区段算法的详细过程如下:
初始化堆栈 为种子点建立初始区段sp0 将sp0的ParentDirection置为Non,Extented置为UnRez 将sp0压入堆栈 while(堆栈非空) 从堆栈弹出一个区段sp。 判断sp的类型 若sp的Extended为UnRez 从其左端点执行FindXleft,记录左端点xl 从其右端点执行FindXright操作,记录右端点xr 若sp的ParentDirection为Y-,对下列区间执行CheckRange操作: 1. [xl-sp.xleft,sp.Y-1] 2. [sp.xright-xr,sp.Y-1] 3. [xr-xr,sp.Y+1] 若sp的ParentDirection为Y+,对下列区间执行CheckRange操作: 1. [xl-xr,sp.Y-1] 2. [xl-sp.xleft,sp.Y+1] 3. [sp.xright-xr,sp.Y+1] 若sp的ParentDirection为Non,对下列区间执行CheckRange操作: 1. [xl-xr,sp.Y-1] 2. [xl-xr,sp.Y+1] 继续循环 若sp的Extended为 AllRez 若sp的ParentDirection为Y-,对下列区间执行CheckRange操作: 1. [sp.xleft-sp.xright,sp.Y+1] 若sp的ParentDirection为Y+,对下列区间执行CheckRange操作: 1. [sp.xleft-sp.xright,sp.Y-1] 继续循环 若sp的Extended为LeftRequired 从其左端点执行FindXleft,记录xl 若sp的ParentDirection为Y-,对下列区间执行CheckRange操作: 1. [xl-sp.xleft,sp.Y-1] 2. [xl-sp.xright,sp.Y+1] 若sp的ParentDirection为Y+,对下列区间执行CheckRange操作: 1. [xl-sp.xright,sp.Y-1] 2. [xl-sp.xleft,sp.Y+1] 继续循环 若sp的Extended为RightRequired 从其右端点执行FindXright,记录xr 若sp的ParentDirection为Y-,对下列区间执行CheckRange操作: 1. [sp.xright-xr,sp.Y-1] 2. [sp.xleft-xr,sp.Y+1] 若sp的ParentDirection为Y+,对下列区间执行CheckRange操作: 1. [sp.xleft-xr,sp.Y-1] 2. [sp.xright-xr,sp.Y+1] 继续循环 end
注意以上代码未检查Y坐标越界,实际应用时在每个CheckRange调用前需要用判断语句确保Y坐标在0~bmp.Height-1的范围内,若不在此范围内则不执行CheckRange。
4.代码实现
区段算法用C#实现的代码如下:
enum ParentDirections { Y0 = 1, Y2 = 2, Non = 5 } enum ExtendTypes { LeftRequired = 1, RightRequired = 2, AllRez = 3, UnRez = 4 } struct Span { public int XLeft; public int XRight; public int Y; public ExtendTypes Extended; public ParentDirections ParentDirection; } class SpanFill2d { protected int count = 0; protected BitMap2d bmp; public FlagMap2d flagsMap; protected Container<Span> container;//以Span为单位的Queue或Stack容器 protected virtual void ExcuteSpanFill(BitMap2d data, Int16Double seed) { this.bmp = data; data.ResetVisitCount(); flagsMap = new FlagMap2d(data.width, data.height); Process(seed); flagsMap.SetFlagOn(seed.X, seed.Y, true); Span seedspan = new Span(); seedspan.XLeft = seed.X; seedspan.XRight = seed.X; seedspan.Y = seed.Y; seedspan.ParentDirection = ParentDirections.Non; seedspan.Extended = ExtendTypes.UnRez; container.Push(seedspan); while (!container.Empty()) { Span span = container.Pop(); #region AllRez if (span.Extended == ExtendTypes.AllRez) { if (span.ParentDirection == ParentDirections.Y2) { if (span.Y - 1 >= 0) CheckRange(span.XLeft, span.XRight, span.Y - 1, ParentDirections.Y2); continue; } if (span.ParentDirection == ParentDirections.Y0) { if (span.Y + 1 < bmp.height) CheckRange(span.XLeft, span.XRight, span.Y + 1, ParentDirections.Y0); continue; } throw new Exception(); } #endregion #region UnRez if (span.Extended == ExtendTypes.UnRez) { int xl = FindXLeft(span.XLeft, span.Y); int xr = FindXRight(span.XRight, span.Y); if (span.ParentDirection == ParentDirections.Y2) { if (span.Y - 1 >= 0) CheckRange(xl, xr, span.Y - 1, ParentDirections.Y2); if (span.Y + 1 < bmp.height) { if (xl != span.XLeft) CheckRange(xl, span.XLeft, span.Y + 1, ParentDirections.Y0); if (span.XRight != xr) CheckRange(span.XRight, xr, span.Y + 1, ParentDirections.Y0); } continue; } if (span.ParentDirection == ParentDirections.Y0) { if (span.Y + 1 < bmp.height) CheckRange(xl, xr, span.Y + 1, ParentDirections.Y0); if (span.Y - 1 >= 0) { if (xl != span.XLeft) CheckRange(xl, span.XLeft, span.Y - 1, ParentDirections.Y2); if (span.XRight != xr) CheckRange(span.XRight, xr, span.Y - 1, ParentDirections.Y2); } continue; } if (span.ParentDirection == ParentDirections.Non) { if (span.Y + 1 < bmp.height) CheckRange(xl, xr, span.Y + 1, ParentDirections.Y0); if (span.Y - 1 >= 0) CheckRange(xl, xr, span.Y - 1, ParentDirections.Y2); continue; } throw new Exception(); } #endregion #region LeftRequired if (span.Extended == ExtendTypes.LeftRequired) { int xl = FindXLeft(span.XLeft, span.Y); if (span.ParentDirection == ParentDirections.Y2) { if (span.Y - 1 >= 0) CheckRange(xl, span.XRight, span.Y - 1, ParentDirections.Y2); if (span.Y + 1 < bmp.height && xl != span.XLeft) CheckRange(xl, span.XLeft, span.Y + 1, ParentDirections.Y0); continue; } if (span.ParentDirection == ParentDirections.Y0) { if (span.Y + 1 < bmp.height) CheckRange(xl, span.XRight, span.Y + 1, ParentDirections.Y0); if (span.Y - 1 >= 0 && xl != span.XLeft) CheckRange(xl, span.XLeft, span.Y - 1, ParentDirections.Y2); continue; } throw new Exception(); } #endregion #region RightRequired if (span.Extended == ExtendTypes.RightRequired) { int xr = FindXRight(span.XRight, span.Y); if (span.ParentDirection == ParentDirections.Y2) { if (span.Y - 1 >= 0) CheckRange(span.XLeft, xr, span.Y - 1, ParentDirections.Y2); if (span.Y + 1 < bmp.height && span.XRight != xr) CheckRange(span.XRight, xr, span.Y + 1, ParentDirections.Y0); continue; } if (span.ParentDirection == ParentDirections.Y0) { if (span.Y + 1 < bmp.height) CheckRange(span.XLeft, xr, span.Y + 1, ParentDirections.Y0); if (span.Y - 1 >= 0 && span.XRight != xr) CheckRange(span.XRight, xr, span.Y - 1, ParentDirections.Y2); continue; } throw new Exception(); } #endregion } } protected void CheckRange(int xleft, int xright, int y, ParentDirections ptype) { for (int i = xleft; i <= xright; ) { if ((!flagsMap.GetFlagOn(i, y)) && IncludePredicate(i, y)) { int lb = i; int rb = i + 1; while (rb <= xright && (!flagsMap.GetFlagOn(rb, y)) && IncludePredicate(rb, y)) { rb++; } rb--; Span span = new Span(); span.XLeft = lb; span.XRight = rb; span.Y = y; if (lb == xleft && rb == xright) { span.Extended = ExtendTypes.UnRez; } else if (rb == xright) { span.Extended = ExtendTypes.RightRequired; } else if (lb == xleft) { span.Extended = ExtendTypes.LeftRequired; } else { span.Extended = ExtendTypes.AllRez; } span.ParentDirection = ptype; for (int j = lb; j <= rb; j++) { flagsMap.SetFlagOn(j, y, true); Process(new Int16Double(j, y)); } container.Push(span); i = rb + 1; } else { i++; } } }//区段法的CheckRange 注意与扫描线的CheckRange的不同 protected int FindXRight(int x, int y) { int xright = x + 1; while (true) { if (xright == bmp.width || flagsMap.GetFlagOn(xright, y)) { break; } else { if (IncludePredicate(xright, y)) { Int16Double t = new Int16Double(xright, y); flagsMap.SetFlagOn(xright, y, true); Process(t); xright++; } else { break; } } } return xright - 1; } protected int FindXLeft(int x, int y) { int xleft = x - 1; while (true) { if (xleft == -1 || flagsMap.GetFlagOn(xleft, y)) { break; } else { if (IncludePredicate(xleft, y)) { Int16Double t = new Int16Double(xleft, y); flagsMap.SetFlagOn(xleft, y, true); Process(t); xleft--; } else { break; } } } return xleft + 1; } protected bool IncludePredicate(int x, int y) { byte value = bmp.GetPixel(x, y); return value == 255; } protected void Process(Int16Double p) { count++; } }
四、关于算法的对比分析以及实验
1.实验数据
以上一共介绍了泛洪法,扫描线算法、区段算法三种种子点生长的算法,现在我们来用实验数据说明这几种算法的效率:
用于实验一共有五组数据,分别如下表所示。表中包含了其数据特点、参数和算法预计得到结果的预览。注意白色的部分是所关心的区域,黑色为背景,种子点的位置一定取自白色区域
数据描述 | 原图像预览 | 结果预览 |
类型:8位二值图像 尺寸:1124×1924 种子点位置:(387, 654) 特点:符合一般应用数据特征,形状不规则,但具有较大成片区域,周长除以面积的比值适中。 | ![]() | ![]() |
类型:8位二值图像 尺寸:1694×1257 种子点位置:(682, 34) 特点:具有特殊性的数据,区域形状也不规则但周长除以面积的比值较大,横向连续像素较多。 | ![]() | ![]() |
类型:8位二值图像 尺寸:1694×1257 种子点位置:(686, 110) 特点:具有特殊性的数据,区域形状不规则且周长面积比较大,纵向连续像素较多。 | ![]() | ![]() |
类型:8位二值图像 尺寸:5000×5000 种子点位置:(50, 50) 特点:全图都是白色,用于模拟生长区间覆盖全图的特殊情况。此图也是测试数据中结果规模最大的,用于测试时间效率最为适合。 | ![]() | ![]() |
类型:8位二值图像 尺寸:2300×2300 种子点位置:(1150, 1150) 特点:白色部分为标准球形。符合一般应用数据特征,形状规则,具有较大成片区域。 | ![]() | ![]() |
首先算法的效率包含了时间效率和空间效率两方面。下面讲空间效率。
2.空间效率
从算法结构上看所占的空间主要由三个部分组成:原图片数据空间,位图标记表空间,容器(堆栈或队列)空间。这三个算法在前两项的空间占有是相同的,不同的只是第三部分,第三部分的空间大小由单位元素的大小和可能的最大容量决定。
算法 | 图像占空间(byte) | 位图标记表空间(byte) | 容器空间(byte) |
泛洪法 | W*H*N/8 | W*H/8 | sizeof(Int16Double)*ContainerMaxSize |
扫描线法 | W*H*N/8 | W*H/8 | sizeof(Int16Double)*ContainerMaxSize |
区段法 | W*H*N/8 | W*H/8 | sizeof(Span)*ContainerMaxSize |
上表中W与H分别为图像的宽和高,n是图像的位数,例如一个100*100的32位ARGB位图加载入程序后大小为100*100*32/8=40000字节。位图标记表则占有100*100/8=1250字节的空间。根据本文c#代码的定义,一个Int16Triple的大小是两个int即8个字节。实际上可以使用short来表示XY坐标,这样Int16Triple最小其实只需要4个字节。Span包含三个表示坐标的int(也可用short代替)和两个标记值,各占1个字节,其实这两个标记可以用位来表示,也就是两个标记一起也可以用1个字节表示,也就是说最小的span结构可以定义成3个short加1个byte,这样只占7字节空间。综合说来,Int16Double结构最大为8字节最小为4字节,Span结构最大为14字节最小为7字节。
通过在程序中插入计数变量记录下容器最大的容量,可以统计出相应的容器空间占有率比例,该比例的定义是以容器的空间之和与图像空间的比值。
测试数据 | 泛洪法(栈式) | 泛洪法(队列式) | 扫描线法(栈式) | 扫描线法(队列式) | 区段法(栈式) | 区段法(队列式) | 区域点数 | 原图空间(byte) |
Test1.bmp | 0.7434708 | 0.00676 | ~=0 | ~=0 | ~=0 | ~=0 | 675080 | 2162576 |
Test2.bmp | 1.0450712 | 0.004464 | ~=0 | ~=0 | ~=0 | ~=0 | 439882 | 2129358 |
Test3.bmp | 1.3026592 | 0.0007948 | ~=0 | ~=0 | ~=0 | ~=0 | 422726 | 2129358 |
Test4.bmp | 1.9803960 | 0.000808 | ~=0 | ~=0 | ~=0 | ~=0 | 25000000 | 25000000 |
Test5.bmp | 1.0033636 | 0.004424 | ~=0 | ~=0 | ~=0 | ~=0 | 4154681 | 5290000 |
上表中的数值是按照计数变量所记录的容器最大元素容量乘以单位结构所占的字节数得到的,这里是按照较小的空间占有计算(int16double,当4字节),计算出的容器占空间大小是最小的可能情况(这里已经假定容器空间大小是由内含元素的量决定,实际上不是,容器一般的扩张策略会使容器占内存更多一些,详情请参考相关语言的容器扩张策略)。可以看出使用栈作为泛洪法的容器,其空间效率最差,以第4组数据为例,1.98的数值意味着假如输入图像为10MB的8位位图,则算法除了使用1MB大小的位图标记表外,还需使用近20M(1.98*10M)的内存用于存放算法执行中栈的最大扩张量(注意:虽然栈中元素最后会POP掉,但栈占空间是不会随之收缩的,在没有代码做显式处理前,会保持扩张到最大时候的内存占用,详情参考相应语言的文档)。当然不同数据的结果有所不同,相对的第1组数据就要小一些。再考察其他方法的内存占用,发现其空间占用相比栈式泛洪法微不足道。
最后我们可以得出结论:若仅关心算法的空间效率,不要选择栈式泛洪法;其余的5种方法—队列泛洪法、扫描线算法(栈式与队列式)、区段法(栈式与队列式)都可以使用,因为他们的第三项空间占用几乎可以忽略不计。
3.时间效率
别的不说,先直接看数据,5组数据分别用六种算法得到的运行时间如下表:
测试数据 | 泛洪法(栈式) | 泛洪法(队列式) | 扫描线法(栈式) | 扫描线法(队列式) | 区段法(栈式) | 区段法(队列式) | 区域点数 |
Test1.bmp | 109ms | 110ms | 62ms | 62ms | 37ms | 37ms | 2162576 |
Test2.bmp | 75ms | 75ms | 42ms | 42ms | 27ms | 27ms | 2129358 |
Test3.bmp | 74ms | 71ms | 42ms | 44ms | 28ms | 28ms | 2129358 |
Test4.bmp | 4203ms | 4105ms | 2294ms | 2291ms | 1205ms | 1206ms | 25000000 |
Test5.bmp | 689ms | 699ms | 392ms | 388ms | 204ms | 205ms | 5290000 |
表中的算法运行时间不包含加载图像的时间,是准确的泛洪法执行时间。所有的算法使用相同的基本结构(标记表,容器等)。算法运行时间的计算是.NET运行库中的System.Diagnotics.Stopwatch类完成的,算法运行经过多次重复,多次运行的时间和表中的时间均相差不超过2ms,可以认为表中的运行时间充分说明代表算法对各自数据的执行的效率。
从表中得出的结论有两方面:
- 泛洪法慢于扫描线算法,扫描线算法慢于区段算法。
- 栈和队列的选择对算法时间效率影响不大(从本实验看不出来,但实际上要看数据的规模,大了有差别)。
对上述结论有如下需要注意的补充说明:
- 不可以从表中数据得出算法速率是2倍左右的关系,目前无任何证据证明这一点。根据业界研究,在数据规模大时,扫描线算法可以比泛洪法快一个数量级;而数据很小时,可能只快不到1倍。
- 相同的算法采用栈和队列在数据较大的时候还是有所区别,一般栈会略快,而本测试数据均不够大,所以未能明显表现差别。
4.影响时间效率的因素
使用VS的CPU sampling效率工具可以分别找到各自算法的费时操作(又被称为热点HOT POINT,注意其含义并不是某操作执行单次费时而是由于执行频率高导致CPU采样时较多样本落在这些操作上,),以运行Test1.bmp为例子,结果参考下图。下图是泛洪法的热点截图,可以看出泛洪法的时间花销主要是在对领域点的标记或者像素的考察、边界确认和邻域初始化上:
扫描线算法的热点截图如下,可以看出由于算法默认选择了区段的右端点入栈(其实选区段的任何一点入栈都行),所以FindXleft操作所占总时间比例较大,不难推导若算法选择最左端点入栈则FindXright操作占总时间比例会较大。
进一步细化,考察FindXleft函数中的热点,在FindXleft操作中,从下图可以看出,时间主要花在对标记表和像素的访问上。
同样在CheckRange操作中,时间同样主要花在对标记表和像素的访问上。
区段算法的热点截图,相比扫描线算法,区段算法没有那么多时间费在FindXleft或者FindXright的操作上,而是CheckRange操作占据主要时间,对CheckRange中的操作进行细分,发现时间主要也是花费在对像素和标记表的访问上:
![]() | ![]() |
由此推断种子生长算法的效率很大程度上依赖于如下几个基本操作:
- 对原始图片的GetPixel操作,包含取像素值和像素值的比较。
- 对位图标记表的GetFlagOn操作,由于是位数组,会比一般的数组略微费时。
- 对位图标记表的SetFlagOn操作,由于是位数组,会比一般的数组略微费时。
- 对容器的Push和Pop操作,注意也要考虑,容器在某次Push发生扩张的开销。
利用程序中插入计数变量的方法统计这些操作执行的次数同结果点数之比,测试数据采用Test1.bmp,得到如下的结果:
算法 | GetPixel/总点数 | GetFlagOn/总点数 | SetFlagOn/总点数 | Push和Pop/总点数 | 总点数 | 时间花费 |
泛洪法(栈式) | 1.01884 | 3.998879 | 1.0 | 1.0 | 662568 | 109ms |
泛洪法(队列式) | 1.01884 | 3.998879 | 1.0 | 1.0 | 662568 | 110ms |
扫描线法(栈式) | 3.01507 | 3.006306 | 1.0 | 0.005 | 662568 | 62ms |
扫描线法(队列式) | 3.014463 | 3.006248 | 1.0 | 0.005 | 662568 | 62ms |
区段法(栈式) | 1.017663 | 1.037063 | 1.0 | 0.005 | 662568 | 37ms |
区段法(队列式) | 1.017482 | 1.021789 | 1.0 | 0.006 | 662568 | 37ms |
对于第四组数据Test4.bmp进行相同的测试,结果如下:
算法 | GetPixel/总点数 | GetFlagOn/总点数 | SetFlagOn/总点数 | Push和Pop/总点数 | 总点数 | 时间花费 |
泛洪法(栈式) | 1.0 | 3.992 | 1.0 | 1.0 | 25000000 | 4257ms |
泛洪法(队列式) | 1.0 | 3.992 | 1.0 | 1.0 | 25000000 | 4191ms |
扫描线法(栈式) | 2.9994 | 2.9994 | 1.0 | 0.005 | 25000000 | 2291ms |
扫描线法(队列式) | 2.9994 | 2.9994 | 1.0 | 0.005 | 25000000 | 2289ms |
区段法(栈式) | 1.0 | 1.00004 | 1.0 | 0.005 | 25000000 | 1233ms |
区段法(队列式) | 1.0 | 1.0 | 1.0 | 0.006 | 25000000 | 1233ms |
从上述数据可以看出,扫描线算法相比于泛洪法,主要减少了对标记和访问和对容器的操作,但同时增加了一部分对图像的重复访问;而区段算法在扫描线算法的基础上进一步减少了对标记和图像的访问。
同时我们还注意到,扫描线算法以及与其思想相似的区段算法,都利用了图像在X方向是连续存放的特点(即X相邻的像素在内存中也是相邻的),这样能够有效的利用计算机的高速缓存的局部性原理,上文还提到的基于栈的算法比队列的略快,也是在一定利用了计算机的这一原理。为了印证这个原理的影响,使用C#语言,用相同的原理编写了基于Y方向的区段算法,用3组数据分别做了测试。其中Test4放大版是10000*10000的全白图片。
算法 | 区段算法(栈) | 区段算法(Y方向版本) | 数据规模 |
Test1.bmp | 36ms | 37ms | 1134×1924 |
Test4.bmp | 1231ms | 1428ms | 5000×5000 |
Test4放大版.bmp | 4838ms | 6088ms | 10000×10000 |
从表格的数据可以看出,数据规模大的一组更能体现差别。由于Y方向图像的连续像素在内存中实际存放不连续,所以会导致更大的缓存未命中概率。由于一台机器的高速缓存是固定大小,因而最能表现差别的是更大的数据,这一点也符合常识和预期。
综上所述,时间效率这一部分可以推导出:相对最具有时间效率的是栈式区段算法,同时由于其在空间上也具有优良特性,因而是一个适合于二维种子点填充的优秀算法。