一个支持格式转换、从 URL 获取位图、叠加等功能的位图处理类。






4.87/5 (55投票s)
2003年9月1日
13分钟阅读

354184

2171
本文概述并讨论了作者的 BitmapManipulator C# 类,包括每个功能的示例,以及许多 .NET/GDI+ 的注意事项。
摘要
随着 GDI+ API 的发布,微软极大地增强了其图形 API 的功能和灵活性,同时也增加了本已模糊的 GDI API 的复杂性和表面积。幸运的是,.NET Framework 提供了 System.Drawing
命名空间层次结构,以(某种程度上)可控的接口封装了大部分 GDI+ API。不幸的是,许多复杂性仍然存在,因此许多简单的图像处理任务,例如调整位图大小、裁剪区域或在一种格式与其他格式之间转换,要么很复杂,要么不显而易见,有时两者兼而有之。
在本文中,我将介绍我用 C# 编写的 BitmapManipulator
类,它实现了许多常见的图像处理函数,以及更高级的功能,如将叠加图像 alpha 混合到一个位图上,同时隐藏了 GDI+ 的复杂细节。我最初开发这个类是为了用于一个基于 Web 的相册应用程序,因此强调简单易用性和性能。尽管有其起源,但该类及其代码应该适用于各种应用程序,如源代码存档中包含的 Windows Forms 测试程序所示。
引言
本文介绍了 BitmapManipulator
类,回顾了其功能,并带领读者了解源代码的关键元素,指出有趣的技巧或注意事项。对于只想在应用程序中使用该类的读者,可以跳过代码讲解部分,但鼓励所有读者阅读全文。
随附的源代码存档包含 Visual Studio.NET 2002 项目文件,但源代码应该可以与 VS.NET 2K2 或 VS.NET 2K3 配合使用。
背景
BitmapManipulator
是基于 .NET Framework 的 GDI+ 包装类构建的,这些类位于 System.Drawing
命名空间中。虽然对 System.Drawing
进行全面概述远超本文范围,但做一个粗略的回顾可能会很有用。
所有图像都由 System.Drawing.Image
类或其派生类表示。 Image
提供了适用于所有图像类型的基本属性和方法。它的子类 System.Drawing.Bitmap
和 System.Drawing.Imaging.Metafile
分别为位图图像(即光栅图像;由像素描述的图像)和图元文件图像提供了附加功能。在本文的其余部分,我们将重点关注 Bitmap
类,该类不仅用于表示 BMP 文件,还用于表示 JPEG、GIF、PNG、TIFF 以及 GDI+ 支持的所有其他光栅文件格式。
除了 Bitmap
类之外,System.Drawing.Graphics
在 BitmapManipulator
的代码中也占有重要地位。Graphics
是用于表示“显示设备”的抽象,但在这种情况下,“显示设备”的含义比监视器更广。概念上,在 GDI+ 中,位图的几乎所有操作(如旋转、裁剪等)实际上都不是直接在 Bitmap
对象上执行的,而是在 Graphics
对象上执行的。反过来,该 Graphics
对象可以表示屏幕、打印机或(在我们的例子中)Bitmap
对象中的图像内容。熟悉 Win32 GDI API 的人会认出 Graphics
是 GDI 设备上下文 (DC) 的面向对象形式。
除了 Bitmap
和 Graphics
之外,下面还会遇到一些枚举,如 PixelFormat
,以及类,如 ImageFormat
。幸运的是,这些实例大多数都相当容易理解,因此上面的概述足以开始回顾 BitmapManipulator
的实现。
BitmapManipulator 结构
BitmapManipulator
是一个 static
类,在一个 C# 源文件中实现。从设计角度来看,它遵循我同事 Richard Cooper 所称的“函数桶”模式;也就是说,它只是公开完全自包含的 static
方法,这些方法最多只与其他方法有浅层关联。虽然这种模式在某些情况下可能不理想,但在本例中,它造就了一个连贯、简洁、易于使用的类,可以轻松地集成到任何地方。
一般来说,每个函数都对位图执行某种操作。为此,每个函数至少接受一个 Bitmap
对象,以及执行操作所需的任何参数。此外,每个函数都返回一个 Bitmap
,该位图始终是与传入的位图不同的新实例。在我编写 BitmapManipulator
时,由于一些不常用的原因需要此功能,但在其他情况下它也可能是有利的。然而,必须记住,当不再需要输入 Bitmap
时,要调用 Dispose()
,以最有效地利用资源。
下面是一个 BitmapManipulator
函数的示例。
public static Bitmap ScaleBitmap(Bitmap inputBmp, double scaleFactor)
在这种情况下,除了输入的 Bitmap
参数之外,还有一个缩放因子用于控制要执行的操作。绝大多数情况下,BitmapManipulator
的大多数方法都采用这种形式。
位图操作
GetBitmapFromUri
public static Bitmap GetBitmapFromUri(String uri);
public static Bitmap GetBitmapFromUri(Uri uri);
public static Bitmap GetBitmapFromUri(String uri, int timeoutMs);
public static Bitmap GetBitmapFromUri(Uri uri, int timeoutMs);
虽然提供了此方法的各种重载形式,但它们都执行相同的任务:从 URI 中检索图像文件,将该文件加载到 Bitmap
中,并返回所得对象。
这些方法中唯一值得注意的一点是对 WebRequest
/WebResponse
类及其相关异常的包装:在我编写 BitmapManipulator
时,我需要为用户提供有意义的错误消息,以便在图像下载失败时显示,因此在这些方法中,我捕获与常规 HTTP 错误相关的异常,并将它们打包到一个通用的 BitmapManipException
中,并附带一个(相对)用户友好的错误消息。当然,原始异常始终可以通过 InnerException
属性访问,但此功能使得将图像下载功能集成到应用程序中变得容易,而不会向用户暴露任何尖锐的边缘。
ConvertBitmap
public static Bitmap ConvertBitmap(Bitmap inputBmp, String destMimeType);
public static Bitmap ConvertBitmap(Bitmap inputBmp,
System.Drawing.Imaging.ImageFormat destFormat);
顾名思义,此方法的所有重载都用于将一种位图格式转换为另一种格式(例如,JPEG 到 GIF 或 TIFF 到 PNG)。执行此操作所需的代码是 GDI+ 中常见的简单但出人意料的复杂操作的示例。
//Create an in-memory stream which will be used to save
//the converted image
System.IO.Stream imgStream = new System.IO.MemoryStream();
//Save the bitmap out to the memory stream,
//using the format indicated by the caller
inputBmp.Save(imgStream, destFormat);
//At this point, imgStream contains the binary form of the
//bitmap in the target format. All that remains is to load it
//into a new bitmap object
Bitmap destBitmap = new Bitmap(imgStream);
没错;图像被保存到内存流中,然后从流中加载到新的 Bitmap
对象中。荒谬,但有效。
ConvertBitmapToJpeg
public static Bitmap ConvertBitmapToJpeg(Bitmap inputBmp, int quality);
作为 ConvertBitmap
的一个特殊情况,此函数将位图转换为 JPEG,并可选择指定 JPEG 编码器的质量参数。此参数在 0(严重失真,极佳压缩)到 100(无损,最小压缩)之间。我最初需要此功能是因为,在我的相册应用程序中,我想将用户照片的质量强制设置为 50 或更低,以最大限度地减少磁盘空间使用。将 quality
传递 -1 等同于使用 ConvertBitmap
并将目标格式设置为 JPEG。
不幸的是,为 JPEG 编码器指定质量参数并非易事,也并非显而易见。由于这似乎是一个经常被问到的问题,因此很有必要展示所需的代码。
//Create an in-memory stream which will be used to save
//the converted image
System.IO.Stream imgStream = new System.IO.MemoryStream();
//Get the ImageCodecInfo for the desired target format
ImageCodecInfo destCodec = FindCodecForType
(MimeTypeFromImageFormat(ImageFormat.Jpeg));
if (destCodec == null) {
//No codec available for that format
throw new ArgumentException("The requested format " +
MimeTypeFromImageFormat(ImageFormat.Jpeg) +
" does not have an available codec installed",
"destFormat");
}
//Create an EncoderParameters collection to contain the
//parameters that control the dest format's encoder
EncoderParameters destEncParams = new EncoderParameters(1);
//Use quality parameter
EncoderParameter qualityParam = new
EncoderParameter(Encoder.Quality, quality);
destEncParams.Param[0] = qualityParam;
//Save w/ the selected codec and encoder parameters
inputBmp.Save(imgStream, destCodec, destEncParams);
//At this point, imgStream contains the binary form of the
//bitmap in the target format. All that remains is to load it
//into a new bitmap object
Bitmap destBitmap = new Bitmap(imgStream);
总而言之,您必须找到目标格式(在此例中为 JPEG)的 ImageCodecInfo
对象,知道未公开的 JPEG 编码器接受一个名为 Encoder.Quality
的参数,其值在 0 到 100 之间,创建一个 EncoderParameter
对象来表示此参数,以及一个 EncoderParameters
集合来包含 EncoderParameterObject
。最后,将 ImageCodecInfo
和 EncoderParameters
对象传递给 Bitmap
类的 Save
方法,即可完成。
ConvertBitmapToTiff
public static Bitmap ConvertBitmapToTiff(Bitmap inputBmp,
TiffCompressionEnum compression);
概念上,此方法与 ConvertBitmapToJpeg
相同,只是此方法将位图转换为 TIFF 格式。虽然 TIFF 不支持 JPEG 编码器提供的质量参数,但它支持基于图像颜色深度的多种压缩算法。因此,ConvertBitmapToTiff
不是使用质量参数,而是接受一个 BitmapManipulator.TiffCompressionEnum
类型的参数,该参数可以具有以下任何值:
CCITT3
CCITT4
LZW
RLE
无
Unspecified(未指定)
请注意,CCITT3
、CCITT4
和 RLE
在处理 24 或 32 位 TIFF 文件时似乎无法正常工作;会从 GDI+ 内部引发异常。考虑到 GDI+ 的这一领域几乎没有文档记录,一些怪癖是意料之中的。可以使用源代码存档中包含的示例应用程序比较容易地探索此问题。
传递 TiffCompressionEnum.Unspecified
等同于调用 ConvertBitmap
并将 TIFF 指定为目标格式。
在 ConvertBitmapToTiff
中使用的代码与 ConvertBitmapToJpeg
相同,只是魔术参数是压缩而不是质量。
ScaleBitmap
public static Bitmap ScaleBitmap(Bitmap inputBmp, double scaleFactor);
public static Bitmap ScaleBitmap(Bitmap inputBmp,
double xScaleFactor, double yScaleFactor);
显然,此函数通过缩放因子缩放位图的尺寸。传递 1.0 会返回输入位图的精确副本,2.0 会生成原始位图两倍大小的位图,0.5 会生成一半大小的位图,依此类推。
对 ScaleBitmap
的实现值得注意的是其反直觉之处。
//Create a new bitmap object based on the input
Bitmap newBmp = new Bitmap(
(int)(inputBmp.Size.Width*xScaleFactor),
(int)(inputBmp.Size.Height*yScaleFactor),
PixelFormat.Format24bppRgb);
//Graphics.FromImage doesn't like Indexed pixel format
//Create a graphics object attached to the new bitmap
Graphics newBmpGraphics = Graphics.FromImage(newBmp);
//Set the interpolation mode to high quality bicubic
//interpolation, to maximize the quality of the scaled image
newBmpGraphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
newBmpGraphics.ScaleTransform((float)xScaleFactor, (float)yScaleFactor);
//Draw the bitmap in the graphics object, which will apply
//the scale transform
//Note that pixel units must be specified to
//ensure the framework doesn't attempt
//to compensate for varying horizontal resolutions
//in images by resizing; in this case,
//that's the opposite of what we want.
Rectangle drawRect = new Rectangle(0, 0,
inputBmp.Size.Width, inputBmp.Size.Height);
newBmpGraphics.DrawImage(inputBmp, drawRect,
drawRect, GraphicsUnit.Pixel);
//Return the bitmap, as the operations on the graphics object
//are applied to the bitmap
newBmpGraphics.Dispose();
//newBmp will have a RawFormat of MemoryBmp because it was created
//from scratch instead of being based on inputBmp.
//Since it it inconvenient
//for the returned version of a bitmap to be of a
//different format, now convert
//the scaled bitmap to the format of the source bitmap
return ConvertBitmap(newBmp, inputBmp.RawFormat);
在这里,创建了一个 Graphics
对象,并将其绑定到一个具有与输入位图相同尺寸的新空白位图上,并根据缩放因子进行了缩放。插值模式已设置为适当的模式,在本例中是速度最慢但质量最高的一种。接下来,在 Graphics
对象上调用 ScaleTransform
,并将 x 和 y 缩放因子作为参数传递。请注意,此时,输入图像尚未复制到 Graphics
对象;相反,ScaleTransform
向 Graphics
对象的“管道”添加了一个变换,因此对 Graphics
对象的任何未来操作都将经历一个缩放。这是一个强大的概念,但最初是反直觉的。
最后,将输入图像绘制到 Graphics
对象上,由于缩放变换已安装,因此图像将在渲染到先前创建的空白位图之前进行缩放。
这种变换管道的概念是 GDI+ 支持的许多变换的核心。
ResizeBitmap
public static Bitmap ResizeBitmap(Bitmap inputBmp,
int imgWidth, int imgHeight);
对 ResizeBitmap
的实现非常简单。
//Simply compute scale factors that result in the desired size,
//then call ScaleBitmap
return ScaleBitmap(inputBmp,
(float)imgWidth/(float)inputBmp.Size.Width,
(float)imgHeight/(float)inputBmp.Size.Height);
由于 GDI+ 不提供 resize 变换的概念,因此必须通过缩放因子来实现调整大小。由于浮点数的精度,经过四舍五入后,生成的图像可能会有一像素的偏差,但对于大多数应用程序来说,这并不关键。
ThumbnailBitmap
到目前为止,我们检查过的所有方法都有些平庸。而此方法则提供了一个非常有用且有些晦涩的功能:给定一个位图和一个边界矩形,返回输入位图的副本,该副本被缩放到可以容纳在该边界矩形内的最大尺寸,而不会改变图像的宽高比。
再次,我为我的相册应用程序创建了此功能,以便我可以显示一个缩略图表,其中每个表单元格的大小都统一。
此方法实现本身并不值得注意;正如人们所料,它依赖于 ScaleBitmap
来完成繁重的工作。
RotateBitmapRight
public static Bitmap RotateBitmapRight90(Bitmap inputBmp);
public static Bitmap RotateBitmapRight180(Bitmap inputBmp);
public static Bitmap RotateBitmapRight270(Bitmap inputBmp);
将位图分别向右旋转 90、180 和 270 度。令人惊讶的是,实现非常简单直接,在处理 GDI+ 时这是一种例外。考虑 RotateBitmapRight90
:
//Copy bitmap
Bitmap newBmp = (Bitmap)inputBmp.Clone();
newBmp.RotateFlip(RotateFlipType.Rotate90FlipNone);
//The RotateFlip transformation converts bitmaps to memoryBmp,
//which is uncool. Convert back now
return ConvertBitmap(newBmp, inputBmp.RawFormat);
令人惊讶的是,Bitmap
类提供了一个 RotateFlip
方法,该方法执行旋转。不需要 Graphics
对象或繁琐的其他代码行。幸运的是,这种直观性本身是异常的,这使得一些混淆得以保留。
ReverseBitmap 和 FlipBitmap
public static Bitmap ReverseBitmap(Bitmap inputBmp);
public static Bitmap FlipBitmap(Bitmap inputBmp);
分别反转(镜像)和翻转(上下颠倒)图像。这些也使用了 Bitmap.RotateFlip
方法,几乎仅此而已。
CropBitmap
public static Bitmap CropBitmap(Bitmap inputBmp, Rectangle cropRectangle);
裁剪输入图像,返回一个包含输入位图中由裁剪矩形包围的部分的位图。此方法的实现是另一个简单但出人意料的解决方案:
//Create a new bitmap object based on the input
Bitmap newBmp = new Bitmap(cropRectangle.Width,
cropRectangle.Height,
PixelFormat.Format24bppRgb);
//Graphics.FromImage doesn't like Indexed pixel format
//Create a graphics object and attach it to the bitmap
Graphics newBmpGraphics = Graphics.FromImage(newBmp);
//Draw the portion of the input image in the crop rectangle
//in the graphics object
newBmpGraphics.DrawImage(inputBmp,
new Rectangle(0, 0, cropRectangle.Width, cropRectangle.Height),
cropRectangle,
GraphicsUnit.Pixel);
//Return the bitmap
newBmpGraphics.Dispose();
//newBmp will have a RawFormat of MemoryBmp because it was created
//from scratch instead of being based on inputBmp.
//Since it it inconvenient
//for the returned version of a bitmap to be
//of a different format, now convert
//the scaled bitmap to the format of the source bitmap
return ConvertBitmap(newBmp, inputBmp.RawFormat);
请注意,GDI+ 并不提供任何显式的裁剪支持。相反,会创建一个新的位图,其尺寸与裁剪矩形相同。然后,将一个 Graphics
对象绑定到此新 Bitmap
,并调用 Graphics.DrawImage
将裁剪矩形内的输入图像数据绘制到 Graphics
对象上,从而绘制到新位图上。简单,但不直观。
OverlayBitmap
public static Bitmap OverlayBitmap(Bitmap destBmp,
Bitmap bmpToOverlay, Point overlayPoint);
public static Bitmap OverlayBitmap(Bitmap destBmp,
Bitmap bmpToOverlay, ImageCornerEnum corner);
public static Bitmap OverlayBitmap(Bitmap destBmp,
Bitmap bmpToOverlay, int overlayAlpha, Point overlayPoint);
public static Bitmap OverlayBitmap(Bitmap destBmp,
Bitmap bmpToOverlay, int overlayAlpha, ImageCornerEnum corner);
此方法是使此类有别于无数其他 C# 图像类的主要元素。通过此方法,可以将一个(假定较小)位图叠加到另一个(假定较大)位图上,可以放在角落、中心或任意点,并带有任意 alpha(透明度)。此功能是为了让我的相册应用程序可以在每张照片上放置一个小的、半透明的水印。
使用此方法非常简单:传递放置叠加图像的图像、要叠加的图像、一些指定叠加图像位置的说明,以及一个可选的 alpha 值(0 到 100;0 为透明,100 为不透明)。
此方法的实现绝非直观。虽然将一个图像叠加到另一个图像上相对简单(只需将 Graphics
对象绑定到输入位图,然后调用 Graphics.DrawImage
来复制叠加图像),但包含叠加的透明度则相当复杂。
//Convert alpha to a 0..1 scale
float overlayAlphaFloat = (float)overlayAlpha / 100.0f;
//Copy the destination bitmap
//NOTE: Can't clone here, because if destBmp is indexed instead of just RGB,
//Graphics.FromImage will fail
Bitmap newBmp = new Bitmap(destBmp.Size.Width,
destBmp.Size.Height);
//Create a graphics object attached to the bitmap
Graphics newBmpGraphics = Graphics.FromImage(newBmp);
//Draw the input bitmap into this new graphics object
newBmpGraphics.DrawImage(destBmp,
new Rectangle(0, 0,
destBmp.Size.Width,
destBmp.Size.Height),
0, 0, destBmp.Size.Width, destBmp.Size.Height,
GraphicsUnit.Pixel);
//Create a new bitmap object the same size as the overlay bitmap
Bitmap overlayBmp = new Bitmap(bmpToOverlay.Size.Width,
bmpToOverlay.Size.Height);
//Make overlayBmp transparent
overlayBmp.MakeTransparent(overlayBmp.GetPixel(0,0));
//Create a graphics object attached to the bitmap
Graphics overlayBmpGraphics = Graphics.FromImage(overlayBmp);
首先,创建一个与输入位图尺寸相同的新 Bitmap
对象。将一个 Graphics
对象绑定到 Bitmap
对象,并使用 DrawImage(Unscaled)
将输入 Bitmap
复制到新 Bitmap
对象。同样,为叠加位图创建一个新的 Bitmap
对象,将叠加位图中的第一个像素设置为透明像素(这是可选的;如果叠加位图不应该有透明背景,则删除它),并将一个 Graphics
对象绑定到叠加 Bitmap
的副本。
//Create a color matrix which will be applied to the overlay bitmap
//to modify the alpha of the entire image
float[][] colorMatrixItems = {
new float[] {1, 0, 0, 0, 0},
new float[] {0, 1, 0, 0, 0},
new float[] {0, 0, 1, 0, 0},
new float[] {0, 0, 0, overlayAlphaFloat, 0},
new float[] {0, 0, 0, 0, 1}
};
ColorMatrix colorMatrix = new ColorMatrix(colorMatrixItems);
//Create an ImageAttributes class to contain a color matrix attribute
ImageAttributes imageAttrs = new ImageAttributes();
imageAttrs.SetColorMatrix(colorMatrix,
ColorMatrixFlag.Default, ColorAdjustType.Bitmap);
//Draw the overlay bitmap into the graphics object,
//applying the image attributes
//which includes the reduced alpha
Rectangle drawRect = new Rectangle(0, 0,
bmpToOverlay.Size.Width, bmpToOverlay.Size.Height);
overlayBmpGraphics.DrawImage(bmpToOverlay,
drawRect,
0, 0, bmpToOverlay.Size.Width, bmpToOverlay.Size.Height,
GraphicsUnit.Pixel,
imageAttrs);
overlayBmpGraphics.Dispose();
接下来,创建一个 5x5 矩阵,该矩阵由单位矩阵组成,其中单元格 (4,4) 设置为从 0 到 1 缩放的 alpha 值。任何具有线性代数经验的读者都会认出这里正在构建一个线性变换,尽管其目的可能尚不清楚。
此矩阵用于创建一个 ImageAttributes
对象,并将其传递给 ImageAttributes.SetColorMatrix
。该矩阵有效地编码了图像中每个像素颜色的变换,其中颜色是一个五元组,第四个元素是 alpha。再次,一些线性代数经验将使读者得出结论,所有这些矩阵工作都是一种冗长的方式来表示“将 alpha 通道按缩放因子(0-1)进行缩放”。
最后,使用熟悉的 Graphics.DrawImage
将叠加 Bitmap
绘制到叠加副本 Bitmap
中,将叠加 Bitmap
通过颜色变换矩阵,因此结果是原始叠加位图,但透明度信息根据 alpha 值设置。
//overlayBmp now contains bmpToOverlay w/ the alpha applied.
//Draw it onto the target graphics object
//Note that pixel units must be specified
//to ensure the framework doesn't attempt
//to compensate for varying horizontal resolutions
//in images by resizing; in this case,
//that's the opposite of what we want.
newBmpGraphics.DrawImage(overlayBmp,
new Rectangle(overlayPoint.X, overlayPoint.Y,
bmpToOverlay.Width, bmpToOverlay.Height),
drawRect,
GraphicsUnit.Pixel);
newBmpGraphics.Dispose();
//Recall that newBmp was created as a memory bitmap;
//convert it to the format
//of the input bitmap
return ConvertBitmap(newBmp, destBmp.RawFormat);
现在,给定一个带有透明度的叠加 Bitmap
版本,另一个 Graphics.DrawImage
调用将其叠加到输入位图上。除了清理和形式化之外,这就是半透明叠加的全部内容。
显然,这是 GDI+ 混淆和复杂操作中最痛苦的例子。然而,最终结果是输入位图和叠加位图的专业、引人注目的混合,非常值得付出努力。
杂项
一些其他公共方法用于 MIME 类型转换等,但它们不执行任何实质性的位图操作。
示例应用程序
本文源代码存档中包含一个简单的 C# Windows Forms 应用程序,它演示了 BitmapManipulator
中的所有功能。请注意,此应用程序是为了演示 BitmapManipulator
类的使用并为读者提供即时满足感而编写的,而不是为了展示作者在 Windows Forms 开发方面的精湛技艺。错误处理和输入验证已被省略,UI 清理留给读者作为练习。
结论
本文介绍了 BitmapManipulator
类,并深入探讨了它在 GDI+ 中的内部工作原理。希望读者在此过程中对基本的 GDI+ 编程概念有所了解,或者至少对未来与 GDI+ 的任何接触都产生了健康的规避。鼓励有雄心的读者扩展 BitmapManipulator
以执行其他有用的功能。特别是,即将发布的文章将探讨在不同颜色深度之间进行抖动的问(net.wrapper 封装 GDI+ 的功能缺失)。
历史
- 2003-9-1 首次发布