封閉連通域的圖像填充是個常見的算法,最近有機會接觸到大圖像的例子,做一下總結。
這類問題最基本的算法是種子填充。即先給出封閉區域內的一點,從這點出發搜索鄰域,只要不到邊界,就把相鄰點納入連通域,賦予填充色。邊界的判斷比較靈活,可以使用固定顏色,也可以用一定閾值的色彩容差,類似photoshop中的魔棒。其他更復雜的計算自然也可以。
鄰域的搜索是填充的重點。最簡單的算法就是遞歸,寫出來也就幾行代碼,像下面的樣子:
FillShape(int x, int y) { if( !IsBrim(x,y) ) { SetPixel(x,y); FillShape(x+1, y); FillShape(x-1, y); FillShape(x, y+1); FillShape(x, y-1); } }
只要當前點不是邊緣,就填充,然后遞歸相鄰的點。這個算法是4連域,改成8連域也是很簡單的事。
上述算法簡單直觀,但存在一些問題。首先它需要逐點搜索,效率不高。其次,在實際的編程中,遞歸運算是很消耗資源的事情。函數的遞歸需要壓棧,即把當前函數的地址和狀態量放到系統的堆棧中,進入下一個狀態。實際操作系統的資源總是有限的,如果遞歸的層數太多,系統的堆棧會溢出,這個是用戶控制不了的。這樣當我們處理稍大一些的圖像時,往往會出現堆棧溢出的錯誤,使程序無法運行。
針對這些問題,我們先做修改,把填充目標由點改為線。也即使用線掃描的方式搜索連通域。進入一個種子點后,在X方向從左向右逐點搜索連通點,填充,直到遇到中斷點停止,然后從這些連通點開始,取上下點遞歸,偽代碼見下:
FillShape(int x, int y) { if( !IsBrim(x,y) ) { // 向左填充,FillToLeft(x,y); nleft = x; while(!IsBrim(nleft,y)) { SetPixel(nleft,y); nleft--; } // 向右填充,FillToRight(x,y); nright = x; while(!IsBrim(nright,y)) { SetPixel(nright,y); nright++; } // 上下層的點遞歸 for(i=nleft+1;i<right;i++) { FillShape(x,y+1); FillShape(x,y-1); } } }
這樣做減少了遞歸次數,提高了效率。但是還沒有解決遞歸的根本缺陷,如果遇到大區域填充,仍然可能出現堆棧溢出。根本的解決之道是放棄遞歸。研究表明,任何遞歸算法都是可以修改為使用循環的非遞歸算法。修改的關鍵是兩步:一、設計並實現一個自己的棧,保存原來遞歸出現的中間狀態量,這樣資源的利用率大大提高。二、在循環處理代碼中,至少實現起始層和下一層遞歸的功能,這樣才能把中間狀態壓入自己的棧中以備處理。
經過修改的非遞歸算法如下:(為了簡潔起見,我把具體的代碼用函數名代替):
// 構建堆棧代碼 PushStack();// 壓棧 PopStack();// 出棧 SetStackEmpty();// 清空棧 int IsStackEmpty();// 判斷棧是否為空 FillShape(int x, int y) { FillToLeft(x,y); FillToRight(x,y); SetStackEmpty(); PushStack(); while(!IsStackEmpty()) { PopStack(); xLeft = getstack_left(); xRight = getstack_right(); // 處理上邊 y=y-1; FillToLeft(xLeft,y); i=xLeft; while( i <= xRight) { FillToRight(i,y); PushStack(); } // 處理下邊 y=y+2; FillToLeft(xLeft,y); i=xLeft; while( i <= xRight) { FillToRight(i,y); PushStack(); } } }
這段代碼的思路是,仍然使用掃描線進行鄰域填充,使用自己的堆棧來記錄中間狀態。完成一條掃描線的填充后,把前一個掃描線狀態壓入棧,彈出時,向上下搜索相鄰的掃描線段,填充,壓棧。直到棧中狀態都被彈出處理為止。
至此,填充算法提高了效率,也避免了系統堆棧溢出。而遞歸算法,更適合用於原理說明和較少層次的運算,對圖像的處理應當慎用並修改之。