C# 和 GDI+ 图像处理入门,第一部分 - 逐像素过滤器






4.91/5 (194投票s)
2002年3月21日
9分钟阅读

2551898

21561
一系列文章的第一篇,
欢迎阅读我的第一篇C#文章,也是图像处理系列的第一篇文章。我想,在Nish和Chris Losinger等着挑我毛病的时候,我也会从这篇文章中学到很多东西。
概述
本系列文章的目的是构建一个类,让任何C#程序员都能访问常见和不常见的图像处理功能。之所以用C#来写,仅仅是因为我想学习它,但我们使用的功能在C++中可以通过GDI+获得,而且使用DIBSECTION
来实现相同功能的代码并没有太大的区别。第一篇文章将侧重于逐像素滤镜,也就是说,滤镜将相同的算法应用到每个像素上,而不会考虑其他像素的值。随着我们继续学习,当开始移动像素或根据考虑了周围像素值的计算来改变值时,代码会变得稍微复杂一些。
应用程序
我们将使用的应用程序是一个基本的Windows Forms应用程序(实际上是我第一个)。我包含了使用GDI+加载和保存图像的代码,以及一个我将添加滤镜的菜单。滤镜都是一个名为BitmapFilter
的类中的静态函数,这样就可以传入一个图像(C#按引用传递复杂类型),并返回一个bool
来指示成功或失败。随着系列的进展,我相信应用程序会获得一些其他不错的附加功能,例如缩放和变形,但这很可能是在核心功能到位后,作为另一篇文章的重点。滚动是以标准方式实现的,Paint
方法使用AutoScrollPosition
属性来获取滚动位置,该位置是通过使用AutoScrollMinSize
属性设置的。缩放是通过一个double
实现的,我们在每次改变比例时设置它,并用它来重新设置AutoScrollMinSize
,以及缩放Paint
方法中传递给DrawImage
的Rectangle
。
像素访问,也称不安全代码,以及其他棘手问题
在构建此代码时,我的第一个真正失望之处在于,GDI+中的BitmapData
类不允许我们通过指针以外的方式访问其存储的数据。这意味着我们需要使用unsafe
关键字来界定访问数据的代码块。其最终效果是,我们的代码需要较高的安全级别才能执行,即任何使用BitmapData
类的代码都不太可能从远程客户端运行。不过,这目前对我们来说不是问题,而且这是我们唯一可行的选择,因为GetPixel
/SetPixel
对于遍历任何实际大小的位图来说都太慢了。
另一个缺点是,这个类被设计为可移植的,但任何使用它的人都需要更改项目设置以支持编译不安全的代码。
我从GDI+的第一个Beta版开始就注意到的一个怪癖至今仍然存在,那就是请求一个24位RGB图像会返回一个24位BGR图像。BGR(即像素存储为蓝色、绿色、红色值)是Windows内部存储数据的方式,但我相信当人们第一次使用这个函数并意识到他们没有得到他们想要的东西时,会感到惊讶。
反色滤镜
那么,这是我们的第一个,也是最简单的滤镜——它只是反转位图,意味着我们将每个像素值从255中减去。
public static bool Invert(Bitmap b)
{
// GDI+ still lies to us - the return format is BGR, NOT RGB.
BitmapData bmData = b.LockBits(new Rectangle(0, 0, b.Width, b.Height),
ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);
int stride = bmData.Stride;
System.IntPtr Scan0 = bmData.Scan0;
unsafe
{
byte * p = (byte *)(void *)Scan0;
int nOffset = stride - b.Width*3;
int nWidth = b.Width * 3;
for(int y=0;y < b.Height;++y)
{
for(int x=0; x < nWidth; ++x )
{
p[0] = (byte)(255-p[0]);
++p;
}
p += nOffset;
}
}
b.UnlockBits(bmData);
return true;
}
这个例子非常简单,以至于像素的顺序混乱也无关紧要。stride
成员告诉我们单行有多宽,Scan0
成员是指向数据的指针。在我们不安全的代码块中,我们获取指针并计算偏移量。所有位图都是字对齐的,因此一行的大小和其中像素的数量可能存在差异。必须跳过这个填充,如果我们试图访问它,我们不会简单地失败,我们会崩溃。因此,我们计算每行结束时需要跳转的偏移量,并将其存储为nOffset
。
图像处理的关键在于尽可能多地在循环之外进行处理。一个1024x768的图像将包含786432个单独的像素,如果我们添加一个函数调用或在循环内创建一个变量,会产生大量的额外开销。在这种情况下,我们的x循环
会执行Width*3次迭代,当我们关心每个单独的颜色时,我们将只步进宽度,并为每个像素将指针增加3。
剩下的代码应该相当直观了。我们正在遍历每个像素并将其反转,正如你在此处看到的
灰度滤镜
接下来的例子将越来越少地展示代码,因为你会越来越熟悉其中的样板部分。下一个显而易见的滤镜是灰度滤镜。你可能会认为这涉及到简单地将三个颜色值相加然后除以三,但这没有考虑到我们眼睛对不同颜色的敏感程度。以下代码中使用了正确的平衡
unsafe
{
byte * p = (byte *)(void *)Scan0;
int nOffset = stride - b.Width*3;
byte red, green, blue;
for(int y=0;y < b.Height;++y)
{
for(int x=0; x < b.Width; ++x )
{
blue = p[0];
green = p[1];
red = p[2];
p[0] = p[1] = p[2] = (byte)(.299 * red
+ .587 * green
+ .114 * blue);
p += 3;
}
p += nOffset;
}
}
正如你所见,我们现在迭代b.Width
次,并以3的增量步进指针,单独提取红色、绿色和蓝色值。回想一下,我们提取的是bgr值,而不是rgb值。然后,我们应用公式将它们转换为灰色值,这显然对红色、绿色和蓝色是相同的。最终结果如下所示
关于滤镜效果的说明
在继续之前,值得注意的是,反色滤镜是我们唯一要介绍的非破坏性滤镜。也就是说,灰度滤镜显然会丢失信息,使得无法从剩余数据中重建原始位图。当我们进入需要参数的滤镜时,情况也是如此。将亮度滤镜设置为100,然后再设置为-100,不会得到原始图像——我们会丢失对比度。原因在于值被钳制了——亮度滤镜给每个像素添加一个值,如果我们超过255或低于0,值会相应调整,因此移动到边界的像素之间的差异就会丢失。
亮度滤镜
话虽如此,实际的滤镜非常简单,基于我们已经知道的内容
for(int y=0;y<b.Height;++y)
{
for (int x = 0; x < nWidth; ++x)
{
nVal = (int) (p[0] + nBrightness);
if (nVal < 0) nVal = 0;
if (nVal > 255) nVal = 255;
p[0] = (byte)nVal;
++p;
}
p += nOffset;
}
下面的两个例子分别使用原始图像上的50和-50值
对比度
对比度操作是我们尝试过的最复杂的操作。我们不是简单地将所有像素向同一个方向移动,而是必须增加或减少像素组之间的差异。我们接受-100到100之间的值,但我们将其转换为0到4之间的double
。
if (nContrast < -100) return false;
if (nContrast > 100) return false;
double pixel = 0, contrast = (100.0+nContrast)/100.0;
contrast *= contrast;
我的策略是当输入无效值时返回false,而不是钳制它们,因为它们可能是拼写错误的结果,因此钳制可能无法代表所需内容,而且这样用户就可以找出哪些值是有效的,从而对给定值可能产生的效果有现实的期望。
我们的循环在一次迭代中处理每种颜色,尽管在这个例子中没有必要这样做。
red = p[2];
pixel = red/255.0;
pixel -= 0.5;
pixel *= contrast;
pixel += 0.5;
pixel *= 255;
if (pixel < 0) pixel = 0;
if (pixel > 255) pixel = 255;
p[2] = (byte) pixel;
我们将像素转换为0到1之间的值,然后减去.5。最终结果是对于应该变暗的像素得到负值,对于想要变亮的像素得到正值。我们将此值乘以我们的对比度值,然后反向操作。最后,我们钳制结果以确保它是有效颜色值。以下图像分别使用30和-30的对比度值。
伽马
首先,对这个滤镜做一个解释。以下关于伽马的解释是在网上找到的:在电视的早期,人们发现CRT产生的亮度与输入电压不成正比。相反,CRT产生的亮度与输入电压的伽马次幂成正比。伽马的值因CRT而异,但通常接近2.5。CRT的伽马响应是由电子枪中的静电效应引起的。换句话说,我屏幕上的蓝色可能和你屏幕上的蓝色不同。伽马滤镜试图纠正这一点。它通过构建一个伽马斜坡来实现,这是一个包含256个值的数组,用于红色、绿色和蓝色,基于输入的伽马值(在.2到5之间)。数组的构建方式如下
byte [] redGamma = new byte [256];
byte [] greenGamma = new byte [256];
byte [] blueGamma = new byte [256];
for (int i = 0; i < 256; ++i)
{
redGamma[i] = (byte)Math.Min(255, (int)(( 255.0
* Math.Pow(i/255.0, 1.0/red)) + 0.5));
greenGamma[i] = (byte)Math.Min(255, (int)(( 255.0
* Math.Pow(i/255.0, 1.0/green)) + 0.5));
blueGamma[i] = (byte)Math.Min(255, (int)(( 255.0
* Math.Pow(i/255.0, 1.0/blue)) + 0.5));
}
你会注意到在这个开发阶段,我发现了Math
类。
构建了这个斜坡后,我们遍历图像,并将我们的值设置为数组中存储的值。例如,如果一个红色值为5,它将被设置为redGamma[5]
。执行此操作的代码不言而喻,我将直接跳到示例。我在两个示例中使用了Gamma值为.6和3,并且始终以原始图像作为第一个进行比较。我对红色、绿色和蓝色使用了相同的值,但滤镜允许它们不同。
颜色滤镜
我们的最后一个滤镜是颜色滤镜。它非常简单——它只是给每种颜色加或减一个值。使用此滤镜最有用的方法是将两种颜色设置为-255,以便去除它们,从而只看到图像的一个颜色分量。我想你现在应该知道这段代码会是什么样子了,所以我就用我儿子的红色、绿色和蓝色分量来结束。希望你觉得这篇文章很有启发,下一篇将介绍卷积滤镜,例如边缘检测、平滑、锐化、简单浮雕等。下次再见!!!