5.读图是快了,处理怎么还是慢?
GDI+的Bitmap类提供了两个罪恶的函数GetPixel, SetPixel,用来获取某个像素点的颜色值。这个2个函数如果只调用一次两次也就罢了,万一我想把整张图片加红一点,用下面的代码,我估计你等到黄花菜都凉了,还没有算完呢。 看看下面的代码是怎么写的。
2 Image img = Image.FromStream(fs, false, false);
3 Bitmap bmp = new Bitmap(img);
4 img.Dispose();
5 fs.Close();
6
7 for (int j = 0; j < bmp.Height; j++)
8 {
9 for (int i = 0; i < bmp.Width; i++)
10 {
11 Color color = bmp.GetPixel(i, j);
12 color = Color.FromArgb(color.R + 20, color.G, color.B);
13 bmp.SetPixel(i, j, color);
14 }
15 }
代码逻辑很清楚,第1到第5行,写得很好,用了我们在前几节里面的方法,读图速度飞快且不锁文件。当然如果不用覆盖原始文件,不用复制都可以,速度就更快了。接下来我们对图像做一个循环,一行一行更新图像的数据。殊不知GetPixel和SetPixel是GDI里面耗费最大的函数之一,此外bmp.Height和bmp.Width也是慢得够呛,如果处理一张500M像素的照片,您可以去喝杯茶,睡一觉再回来了。
Bitmap有个方法叫LockBits,就是把图像的内存区域根据格式锁定,拿到那块内存的首地址。这样就可以直接改写这段内存了。这个方法的设计是挺好,可惜都是C++作为源泉来的,.NET Framework里面根本就不推荐用指针,VB里面根本也就没有指针。最后设计了一个鸡肋的IntPtr,将就掉了这个问题。C#其实还好,可以用unsafe code,也算是间接用了指针,VB.NET就痛苦了,需要用Marshal.Copy把内容Copy到一个byte数组里面,然后处理完了再Copy回去。所以结论就是,要用GDI+做图像处理,最好别用VB.NET,否则内存翻倍。
让我们来看看快速的写法,注意在编译的时候加上unsafe开关,允许C#使用指针:
2 Image img = Image.FromStream(fs, false, false);
3 Bitmap bmp = new Bitmap(img);
4 img.Dispose();
5 fs.Close();
6
7 int width = bmp.Width;
8 int height = bmp.Height;
9 BitmapData bmData = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);
10 byte* p = (byte*)bmData.Scan0;
11 int offset = bmData.Stride - width * 3; //only correct when PixelFormat is Format24bppRgb
12
13 for (int j = 0; j < height; j++)
14 {
15 for (int i = 0; i < width; i++)
16 {
17 p[2] += 20; //should check boundary
18 p += 3;
19 }
20 p += offset;
21 }
22
23 bmp.UnlockBits(bmData);
24 bmp.Dispose();
1-5行一样的,不多说了。第7,8行保存一个临时变量,不要每次都调用,bmp.Width, bmp.Height。
第9行是把图像内容锁定到系统内存。这个函数有2个重载,第二种比较复杂,是把用户内存中的内容锁定。这个以后再说。第一种,也就是我们现在使用的这种,是把图像的内容根据一定的格式放到内存里面,这里我们使用的是Format24bppRgb,也就是24位色。在这种格式下3个字节表示一种颜色,也就是我们通常所知道的R,G,B, 所以每个字节表示颜色的一个分量。
第10行用了一个指针,指向这段内存的首地址。 这样我们可以直接来修改图像的内存信息。因为每个颜色分量都是一个字节,所以用byte的指针,如果是Format48bppRgb,每个分量用2个字节,那就该用Short指针了。
第11行需要多说两句,Stride是指图像每一行需要占用的字节数。根据BMP格式的标准,Stride一定要是4的倍数。据个例子,一幅1024*768的24bppRgb的图像,每行有效的像素信息应该是1024*3 = 3072。因为已经是4的倍数,所以Stride就是3072。那么如果这幅图像是35*30,那么一行的有效像素信息是105,但是105不是4的倍数,所以填充空字节,Stride应该是108。这一行计算出来的offset就是3。这里再留个问题,如果是16色图,也就是4位色,一幅50*50的图像,Stride又应该是多少呢?
第13-21行就是循环的处理。其中第17行需要注意以下。这句话其实是错的,因为p是byte,没有符号,最大值是255, 加20可能会溢出。如果你不希望它溢出,那么可以改成行1,如果希望溢出就是最大值,那么可以用行2。
2 p[2] = (byte)Math.Min(byte.MaxValue, p[2] + 20);
大多处情况下,图像处理用的都是行2的做法,但是这样的话性能损失还是比较厉害的,需要计算一个最大值,还有一个类型的转换,我还在研究有什么更快的办法。还有一点,BMP图像里面用的是小数端的存储方式(Little Endian),所以实际存储的图像顺序是G,B,R,这里要把图像的红色分量增加一些,改变的是第三个字节而不是第一个。
最后两行就不说了,处理完毕需要Unlock,bitmap必须被dispose掉。
用以上代码进行图像处理的速度已经飞快了,最慢的部分就是调用那个LockBits的函数,这个速度基本上跟GDI是差不多的。但是如果你还是觉得慢,那还有以下2种办法可以提高性能,不过它已经远远超过GDI+的范畴了。
1.别用GDI+的方法读图像,自己把图像文件读出来。如果是BMP倒是好办,要是用JPG,TIF,PNG,GIF....如果你足够牛,可以随手写个FFT或者DCT,那我倒是建议可以自己写写看,把JPG解压缩出来,自己修改修改再压缩回去。要是PNG/GIF/GIF 98,就是那个会动的GIF,我就无语了。GIF的标准是公开的,不过时人家的专利,你写了是要付钱的。所以,还是算了吧。自己读BMP可以非常快地把图像处理掉,连LockBits都不需要。
2.如果图像超级大,你又用了后面的行2进行处理,你对性能的要求又无比苛刻,那还是有条路可以走。直接用MMX/SSE/3D Now指令集,4个字节同时上。你去谢谢Intel/Amd给了那么好的指令集把,可惜C#你就别想了,没有办法直接用的。自己用C++调用MMX指令集写个Dll,然后用interop调用。Interop的过程当中也是有损失的,Managed的内存空间用C#里面的Marshal是需要被转换的。所以如果真的要这样做,我推荐使用C++.NET,两块东西都能写。这里我就不给出用MMX/SSE的例子了。最后再提一句,MMX对于图像处理也足够了,SSE主要是给浮点运算的,如果你有图像渲染的那种,用SSE会快很多,那是图形学的内容了,偶不懂,不多说,怕被人用棒子打。