什麼是像素化
學計算機的人往往都比較清楚圖形和圖像的區別,而且往往能夠從數據結構的角度理解這兩者的區別,一般來說,圖形是由幾何空間中的基本圖元所組成,表現爲用外部輪廓線條勾勒成的矢量圖。例如由計算機繪製的直線、圓、矩形、曲線、圖表等。而圖像是由掃描儀、攝像機等輸入設備捕捉實際的畫面產生的數字圖像,是由像素點陣構成的位圖。例如在二維幾何空間中,同樣是爲了表述一個四邊形,從圖形的角度去看,需要提供四個頂點的座標作爲基本圖元,然後提供一個畫線的指令,將四個頂點按順序提供出來。而從圖像的角度看,一個四邊形需要映射爲一個特定分辨率位圖上的像素集合。例如一張大小爲9*9的位圖上,四邊形的邊所在的像素集被塗爲與背景不用的顏色,用以表述這個形狀。
像素化、以及體素化的過程是圖形轉變爲圖像的一個過程,在一些場合被稱作離散化(discretization),而其逆向過程,即由圖像生成圖形的過程可以被稱作輪廓生成(2D)或者表面生成(3D)。在之前的博客介紹了幾種從三維圖像生成表面的算法,它們就屬於從圖像到圖形的表面生成算法範疇。而這篇文章主要介紹一下二維空間像素化的方法,同時爲引申到三維空間做準備。下表顯示了這幾種概念和相應算法之間的關係:
圖像到圖形 | 圖形到圖像 | |
二維 | 輪廓生成:MarchingSquares算法等 | 像素化算法 |
三維 | 表面生成:Marchingcubes算法,Cuberille算法等 | 體素化算法 |
而下面的圖片也形象的說明了圖像與圖形的區別,同樣的箭頭,在不同的表示下是不同的形式。事實上計算機內部無時無刻不在進行這樣的轉換,通常人們大腦中習慣按幾何方式去理解圖形,但用屏幕顯示他們時,計算機需要將其轉爲像素點陣的模式。
圖形 | 圖形映射圖像的像素 | 生成圖像 |
基本圖元的像素化—線段的像素化
任意二維形狀像素化的基礎是基本圖元的像素化,二維空間的基本圖元就是點和線段。不難知道,對任意幾何點P(X,Y)的像素化即是在給定分辨率下尋找到P的對應像素點,像素點也可以理解爲二維空間中的格子,P落在哪個格子上,該格子即是P對應的像素。P(345.6,233.1)在512*512的位圖上,可以將像素(346,223)作爲P的近似來表示P。關鍵的算法是線段的像素化,有一定計算機圖形學或者圖像處理基礎的人應該都聽說過DrawLine算法,這個算法就是一種線段像素化的手段。在Windows畫圖中畫過下圖的這種細線段的人應該知道這樣的像素化的線段。
這種線段又被稱作Bresenham線,是線段像素化的一種,可以用來渲染線段,事實上,還有一種SuperCover線,同樣能夠用來表徵線段像素化的結果,不過其跟Bresenham線有所不同,細節從下圖對比中可以看出,若畫線段AB,Bresenham線只要求從A到B有着8向聯通關係最細像素組合;而SuperCover線要求像素組合是AB所穿過的所有像素。不難看出,SuperCover線的像素集包含Bresenham線的像素集。
Bresenham線 | SuperCover線 | 同一張圖對比,引自Eugen Dedu,ThisAlg指的就是SuperCover |
下面提供兩種畫Bresenham線的方法,一種是使用簡單的DDA算法,一種是使用Bresenham算法。爲此特意聲明一個ByteMatrix類型用來表示8位圖結構,對外提供獲取像素值和設置像素值的接口,所有的像素化算法都在此結構上進行,每一個位圖結構初始化爲0,塗色操作是將其修改爲255。其代碼如下:
class ByteMatrix { public: int width; int height; ByteMatrix(int width,int height,byte value) { this->width=width; this->height=height; this->data=new byte[width*height]; memset(data,0,width*height); } ~ByteMatrix() { delete[] data; } inline void SetValue(int x,int y, byte v) { data[x+width*y]=v; } inline byte GetValue(int x,int y) { return data[x+width*y]; } void SaveRaw(const char* fileName) { FILE *const nfile = fopen(fileName,"wb"); fwrite(data,sizeof(unsigned char),width*height,nfile); fclose(nfile); return; } private: byte* data; };
算法1—DDA法
DDA法利用了Bresenham線的一個性質,即在線擁有最長投影的那個軸上,選擇任意整數點作爲自變量,Bresenham線上擁有其唯一對應的像素點。
在橫軸方向上每個X有唯一方塊對應,而Y方向上不唯一 |
這樣DDA算法可以採用這樣的思路實現:對從A(X0,Y0)到B(X1,Y1)的線段,尋找投影最長的一個軸,然後從此軸上的最小值開始,依次使用直線方程計算出Y的位置,然後將離此位置最近的像素塗色。代碼如下所示(其中Trunc函數是四捨五入函數,實現方式是+0.5取整):
static void DrawLine_DDA(ByteMatrix& bmp,Point2d p0,Point2d p1) { int dx=p1.X-p0.X; int dy=p1.Y-p0.Y; if(abs(dx)>abs(dy)) { if(p0.X>p1.X) { Point2d temp=p1; p1=p0; p0=temp; } for(int i=p0.X;i<=p1.X;i++) { float y=dy*(i-p0.X)/dx+p0.Y; bmp.SetValue(i,Trunc(y),255); } } else { if(p0.Y>p1.Y) { Point2d temp=p1; p1=p0; p0=temp; } for(int i=p0.Y;i<=p1.Y;i++) { float x=dx*(i-p0.Y)/dy+p0.X; bmp.SetValue(Trunc(x),i,255); } } }
算法2—Bresenham算法
Bresenham算法是DDA算法畫線算法的一種改進算法。本質上它也是採取了步進的思想。不過它比DDA算法作了優化,避免了步進時浮點數運算,同時爲選取符合直線方程的點提供了一個好思路。首先通過直線的斜率確定了在x方向進行單位步進還是y方向進行單位步進:當斜率k的絕對值|k|<1時,在x方向進行單位步進;當斜率k的絕對值|k|>1時,在y方向進行單位步進。http://blog.csdn.net/clever101/article/details/6076841詳細介紹了這一算法,網上也能找到特別多關於這個算法的實現和思路講解,所以這裏就不多重複。這裏貼上一份代碼:
static void DrawLine_Bresenham(ByteMatrix& bmp,Point2d p0,Point2d p1) { int y1=p0.Y; int x1=p0.X; int y2=p1.Y; int x2=p1.X; const bool steep = (abs(y2 - y1) > abs(x2 - x1)); if(steep) { std::swap(x1, y1); std::swap(x2, y2); } if(x1 > x2) { std::swap(x1, x2); std::swap(y1, y2); } const float dx = x2 - x1; const float dy = abs(y2 - y1); float error = dx / 2.0f; const int ystep = (y1 < y2) ? 1 : -1; int y = (int)y1; const int maxX = (int)x2; for(int x=(int)x1; x<maxX; x++) { if(steep) { bmp.SetValue(y,x, 255); } else { bmp.SetValue(x,y, 255); } error -= dy; if(error < 0) { y += ystep; error += dx; } } }
關於畫SuperCover線的方法,網上的資料就不如Bresenham線多了,畢竟這條線比起Bresenham線,畫的像素更多,因此在渲染線段上面不如Bresenham線簡單高效。但SuperCover線也有不少其他方面的用途,所以這裏也簡單的敘述一下實現SuperCover線畫法的算法。
算法3—像素求交法
根據SuperCover線的定義,該線的所有像素都必須被線段穿過。若將像素想象成方塊的形狀,位圖想象成網格圖,線段與這些網格相交,不難分析出交點要麼是在平行與X的格線上,要麼是在平行Y的格線上。假如線段AB的X範圍爲X0~X1,Y範圍是Y0~Y1,那所有在X0~X1範圍的所有垂直X軸的格線都能與線段AB有交點,同理Y0~Y1範圍的所有垂直於Y軸的格線也有交點,而且不難知道,AB在哪個軸投影更長,則垂直於哪個軸的格線與AB的交點則更多。因此一個簡單的畫SuperCover線的思路是:先找出投影長的那一維,不妨假設是X軸投影更長,則求出X0-~X1所有垂直X軸格線與AB的交點,例如下圖所示的交點:
每一個交點都能對應找到兩個和它相鄰的像素(綠色標註)。這樣,每一個交點都找出兩個關聯像素並塗色後,就實現了SuperCover線的繪製。實現的代碼如下:
static void DrawSuperCoverLine_Simple(ByteMatrix& bmp,Point2d p0,Point2d p1) { int dx=p1.X-p0.X; int dy=p1.Y-p0.Y; if(abs(dx)>abs(dy)) { if(p0.X>p1.X) { Point2d temp=p1; p1=p0; p0=temp; } for(float i=p0.X+0.5f;i<=p1.X;i+=1.0f) { float y=dy*(i-p0.X)/dx+p0.Y; bmp.SetValue((int)(i-0.5f),Trunc(y),255); bmp.SetValue((int)(i+0.5f),Trunc(y),255); } } else { if(p0.Y>p1.Y) { Point2d temp=p1; p1=p0; p0=temp; } for(float i=p0.Y+0.5f;i<=p1.Y;i+=1.0f) { float x=dx*(i-p0.Y)/dy+p0.X; bmp.SetValue(Trunc(x),(int)(i-0.5f),255); bmp.SetValue(Trunc(x),(int)(i+0.5f),255); } } }
算法4—基於Bresenham的改進方法
上面那個算法其實是作者自己想的直接了當的算法,自然比不上專業的高效的算法。Eugen Dedu在他的網頁上談到了一種通過修改Bresenham算法的代碼來實現畫SuperCover線的算法。在網頁http://lifc.univ-fcomte.fr/~dedu/projects/bresenham/index.html上他詳細的提出了自己的思路,這個思路簡單的說就是:Bresenham算法不是一步一步的從A到B塗色嗎?那麼在每一次塗色時注意一下是往那個像素塗色的,Y方向有沒有變化,如果有,就計算一下這個線是偏向那個方向,順便把那個方向的角落也給填上。例如下圖所示的:Bresenham算法若從A畫到B,一般來說不會正好經過AB交點的那個像素,根據直線的斜率應該是會向上或下有個偏移,那麼根據這個偏移,把D或者C填上即可。詳情可以看他鏈接裏的內容,說的比較詳細,這裏就不重複了。順便把他的代碼貼這:
static void DrawSuperCoverLine_Bresenham(ByteMatrix& bmp,Point2d p0,Point2d p1) { int y1=p0.Y; int x1=p0.X; int y2=p1.Y; int x2=p1.X; int i; // loop counter int ystep, xstep; // the step on y and x axis int error; // the error accumulated during the increment int errorprev; // *vision the previous value of the error variable int y = y1, x = x1; // the line points int ddy, ddx; // compulsory variables: the double values of dy and dx int dx = x2 - x1; int dy = y2 - y1; bmp.SetValue(x1, y1,255); // first point // NB the last point can't be here, because of its previous point (which has to be verified) if (dy < 0){ ystep = -1; dy = -dy; }else ystep = 1; if (dx < 0){ xstep = -1; dx = -dx; }else xstep = 1; ddy = 2 * dy; // work with double values for full precision ddx = 2 * dx; if (ddx >= ddy){ // first octant (0 <= slope <= 1) // compulsory initialization (even for errorprev, needed when dx==dy) errorprev = error = dx; // start in the middle of the square for (i=0 ; i < dx ; i++){ // do not use the first point (already done) x += xstep; error += ddy; if (error > ddx){ // increment y if AFTER the middle ( > ) y += ystep; error -= ddx; // three cases (octant == right->right-top for directions below): if (error + errorprev < ddx) // bottom square also bmp.SetValue(x,y-ystep,255); else if (error + errorprev > ddx) // left square also bmp.SetValue(x-xstep,y ,255); else{ // corner: bottom and left squares also bmp.SetValue(x,y-ystep,255); bmp.SetValue(x-xstep,y,255); } } bmp.SetValue(x,y,255); errorprev = error; } }else{ // the same as above errorprev = error = dy; for (i=0 ; i < dy ; i++){ y += ystep; error += ddx; if (error > ddy){ x += xstep; error -= ddy; if (error + errorprev < ddy) bmp.SetValue(x-xstep,y,255); else if (error + errorprev > ddy) bmp.SetValue(x,y-ystep,255); else{ bmp.SetValue( x-xstep,y,255); bmp.SetValue( x,y-ystep,255); } } bmp.SetValue( x,y,255); errorprev = error; } } // assert ((y == y2) && (x == x2)); // the last point (y2,x2) has to be the same with the last point of the algorithm }
我沒有真比過哪個代碼更高效,理論上應該是後者,因爲後者與Bresenham算法一樣只使用了Integer Arithmetic。
基本圖元的像素化—三角形像素化
三角形的像素化其實有兩種思路去實現,一種是基於像素點的位置,一種是基於直線像素化。
算法5—基於像素位置判斷的三角形像素化
首先我們有比較經典的Point in Triangle算法,StackOverFlow上有各種對“How to determine a point is in a triangle”這樣問題的回答,被引用的比較多的是下面這一段代碼:
static float sign(Point2d p1, Point2d p2, Point2d p3) { return (float)((p1.X - p3.X) * (p2.Y - p3.Y) - (p2.X- p3.X) * (p1.Y - p3.Y)); } static bool PointInTriangle(Point2d pt, Point2d v1, Point2d v2, Point2d v3) { bool b1, b2, b3; b1 = sign(pt, v1, v2) < 0.0f; b2 = sign(pt, v2, v3) < 0.0f; b3 = sign(pt, v3, v1) < 0.0f; return ((b1 == b2) && (b2 == b3)); }
上面的代碼使用了向量叉積和向量夾角的的一些幾何知識,核心思想是判斷點對三角形三條邊的三個張角是不是至少有兩個鈍角。假如有了這個函數,那麼一個簡單粗暴的三角形像素化的方法是,對三角形ABC的BOX範圍的所有像素點,進行一次PointInTriangle的判斷,在三角形內則塗色。不過此法過於粗暴,因而效率不高,不建議使用。
算法6—基於直線像素化的三角形像素化
還有一種做法略顯奇葩,不過在這裏介紹一下,之後的三維體素化會用到這個方法,這個方法可以被稱作“連線法”。其大致思路是:先用Bresenham法連接ABC中任意一邊,假如是BC,連的時候順便記錄下塗上的所有像素。之後使用畫SuperCover線算法來依次連接A與這些像素。
這個方法爲什麼不會漏填像素,可以簡單的用反證法證明一下:例如下圖中,假設漏填了像素P,則根據SuperCover線的定義,不可能有任何連線是經過P的,則連線至多相對P處於圖中那兩個射線的狀態,由於像素方塊的大小都是一樣的,則必然出現比P更遠位置的A像素,其也沒有被任何線穿過。而上述算法中需要依次連接BC上所有的像素點與A,這樣不可能出現A像素,因而矛盾,故這算法是邏輯正確的。
代碼貼上來:
static void FillTriangle_Alg_2(ByteMatrix& bmp,Point2d p0,Point2d p1,Point2d p2) { std::vector<Point2d> plist; Bresenham(bmp,p1,p2,plist); for(int i=0;i<plist.size();i++) { SuperCover(bmp,plist[i],p0); } }
算法7—基於直線像素化的三角形像素化思路2
個人認爲其實在二維平面空間上最具效率的三角形像素化方法是這個方法,叫做填充法。其思路是先使用Super算法塗上三角形ABC的三條邊。然後利用三角形的重心一定在三角形內這個性質,找到三角形重心所對應的像素P,然後以P爲種子點執行漫水填充算法。有相關基礎的人應該都清楚這個漫水填充算法,在之前的博文裏也有詳細的說明這算法的幾種實現方式。以P爲種子點執行8向漫水填充,遇到已經被填充的則會停止,這樣使得內部的像素被塗上顏色,即完成三角形像素化。其實現代碼如下:
static void FillTriangle_Alg_3(ByteMatrix& bmp,Point2d p0,Point2d p1,Point2d p2) { Box2d box; box.UpdateRange(p0.X,p0.Y); box.UpdateRange(p1.X,p1.Y); box.UpdateRange(p2.X,p2.Y); Point2d seed((box.XMin+box.XMax)/2,(box.YMin+box.YMax)/2); SuperCover(bmp,p0,p1); SuperCover(bmp,p0,p2); SuperCover(bmp,p1,p2); FloodFill(bmp,seed); }
測試數據展示
在30*30的ByteMatrix上測試畫線算法的輸出:
算法1-DDA | 算法2-Bresenham | 算法3-CrossPixels | 算法4—Enhanced Bresenham |
在30*30的ByteMatrix上測試畫三角形算法的輸出:
算法5輸出三角形 | 算法6輸出三角形 | 算法7輸出三角形 |
多邊形像素化
綜合了上面講的算法,則不難想出像素化一般平面幾何圖形的方法,無非是下面兩個思路:
- 將多邊形三角化,然後對所有三角形像素化。
- 將多邊形的邊像素化,然後在多邊形內部選擇種子點進行漫水填充算法。
任意多邊形三角化的方法在關於輪廓線的博文中有提到這裏就不重複貼了。第二種採取漫水填充法的思路中涉及到如何找到一個種子點的問題,因爲任意多邊形的重心不見得一定在圖形內部,故漫水填充算法可以採用一種逆向思路,即先填充圖形外部,再反色,即可以得到多邊形的像素化。關於這部分,思路其實比較簡單直接,就不貼代碼了。算法1-7的相關完整工程代碼下載鏈接:
https://github.com/chnhideyoshi/SeededGrow2d/tree/master/DrawLine2d