65.9K
CodeProject 正在变化。 阅读更多。
Home

BorderBug

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (14投票s)

2006年7月20日

8分钟阅读

viewsIcon

105145

downloadIcon

486

解决 DrawImage 边框问题的解决方法。

Demo project

引言

边缘问题让你烦恼? 调整图片或位图大小时出现模糊的边框? 自定义控件无法正常绘制? 本应不透明的透明边缘? 像素偏移? DrawImage 存在问题? 你可能正遭受着可怕的 BorderBug(边框错误)!

DrawImage 方法似乎存在一个 bug。 或者,正如微软喜欢说的,“这种行为是设计使然”。 如果你正在将一张图片的一部分或全部复制到另一张图片中,并且目标矩形大于源矩形,那么你应该准备好面对意外的结果和奇怪的边缘。

本文演示了一种似乎能解决该问题的方法。

背景

在编写自定义控件时,在 OnPaint 事件中进行自定义绘制是很常见的。

讲究的开发者可能更喜欢使用 DrawRectangleDrawPolygon 等图形方法来完成大部分绘制。 对于可调整大小的控件来说,这些方法非常棒,因为所有内容都会被清晰地重绘。 但对于图形复杂的控件来说,这可能涉及大量工作。 如果你正在克隆类似 MS Office 2007 滚动条的东西,那么要想精确匹配微软的颜色和布局会让你抓狂。 经过数小时的混合、渐变、颜色数组以及猜测微软设计师到底用了哪些图形方法之后,你可能还在忙于绘制顶部的滚动按钮。

那些有紧迫截止日期、或者有更重要事情要做而不是像素堆砌的人,更倾向于“作弊”:绘制一个位图(或截屏),然后将生成的图像或其所需部分绘制到他们的控件上。 这样做的好处是快速简便——特别是当控件需要多种皮肤时。 坏处是,如果位图需要调整大小,它可能会失去锐度。 如果它需要被显著拉伸,而你又不知道避免这种情况的简单技巧,那么可怕的 BorderBug 就会显现,边缘就会出现异常。

为什么会发生这种情况?

示例项目展示了四种将图片的一部分复制到控件的方法。

下面的代码以“显而易见”的方式进行操作,并绘制了我们演示解决方案中的顶部控件,该控件显示了意外的红色和绿色边缘。

工作原理如下:以下代码行定义了我们的源图像,这是一个包含九个彩色方块的 120 x 120 像素的图像。

Bitmap sourceImage = Resource1.SourceImage;

为了绘制我们的控件,我们将源矩形定义为我们想要的源图像的一部分。然后我们定义一个目标矩形,并执行 DrawImage 方法来完成工作。简单!

protected override void OnPaint(PaintEventArgs e)
{
    // This rectangle defines the part
    // we want to use from the source image
    Rectangle sourceRectangle = new Rectangle(40, 40, 40, 40);
    
    // This rectangle defines where we want to draw on the control
    Rectangle destinationRectangle = new Rectangle(0, 0, 
                                     this.Width, this.Height);
    
    // And this procedure draws it for us. Easy. OR IS IT? 
    e.Graphics.DrawImage(sourceImage, destinationRectangle, 
                         sourceRectangle, GraphicsUnit.Pixel);

    // Bother! It didn't work properly!
    
    base.OnPaint(e);

}

我们得到了什么?

Bad edges!

发生了什么? 我们是不是错了源矩形,包含了红色和绿色方块的一部分? 显然不是。 如果目标矩形和源矩形的大小相同,或者非常接近,那么就看不到明显的红色和绿色边缘。

答案在于 DrawImage 方法需要将图像拉伸以适应目标。由于像素不会一对一匹配,因此需要计算目标图像中的每个像素。大多数尺寸调整计算都非常复杂,原始图像的像素不是孤立考虑的,而是与周围像素结合考虑的。对于原始矩形边缘的像素,它们的周围像素**包括了不在源矩形内的像素**。这就是红色和绿色像素效果渗透进来的地方。尽管它们不在选定区域之外,但它们仍然参与了计算。

如果目标矩形与源矩形大小完全相同,则可以实现完美复制,因为没有拉伸发生。您可以通过调整演示解决方案中的窗体大小来看到这一点。如果三个黄色控件的大小与源图像中的黄色方块大致相同,它们几乎是完美的。只有在更大的尺寸下,它们才会开始出现问题。

解决方案的探索

在任何论坛上提出这个问题,我几乎可以保证你会得到建议:更改图形设置,例如将 InterpolationMode 更改为其他值——可能是 NearestNeighbor。 除非你采取其他步骤,否则它不起作用。相信我,它不会自己解决问题。

一个看起来有希望的解决方案是将源图像的所需部分复制到一个相同大小的中间图像中。理论上,这应该是一个像素到像素的复制,因为没有拉伸发生。不应该有来自杂乱的红色或绿色像素的影响,我们应该得到一个干净的图像,然后可以将其拉伸到目标控件中。

下面的代码绘制了我们演示解决方案中的中间控件,该控件具有黑色边缘。

protected override void OnPaint(PaintEventArgs e)
{
    // This rectangle defines the part
    // we want to use from the source image
    Rectangle sourceRectangle = new Rectangle(40, 40, 40, 40);

    // Define an intermediate image the same size
    // as the part we are taking from the source image
    Bitmap intermediateImage = new Bitmap(40, 40);

    // Get the graphics object of the intermediate image,
    // so we can draw to it.
    Graphics graphics = Graphics.FromImage(intermediateImage);

    // This rectangle defines where we want
    // to draw in the intermediate image
    Rectangle intermediateRectangle = new Rectangle(0, 0, 
      intermediateImage.Width, intermediateImage.Height);

    // Draw the part we want in the intermediate image.
    graphics.DrawImage(sourceImage, intermediateRectangle, 
                       sourceRectangle, GraphicsUnit.Pixel);
    graphics.Dispose(); // Let's be tidy!

    // This rectangle defines where we want to draw on the control
    Rectangle destinationRectangle = new Rectangle(0, 0, 
                                     this.Width, this.Height);

    // Draw the intermediate image on the control
    e.Graphics.DrawImage(intermediateImage, destinationRectangle, 
                         intermediateRectangle, GraphicsUnit.Pixel);
    intermediateImage.Dispose(); // Let's be tidy!

    // Surely that nailed it? Apparently not!!!!!
    // Now the edges are transparent.
 
    base.OnPaint(e);
}

该代码将我们想要的源图像部分复制到一个相同大小的中间图像中。然后它将中间图像拉伸到控件上。

奏效了吗?

Bad edges!

没有。这次发生了什么?我们得到了黑色边缘!

仔细检查图像和进行一些诊断分析表明,我们实际上得到了**透明**的边缘。它们看起来是黑色的,因为控件的背景色是黑色,所以黑色透过来了。(我之所以将其设置为黑色,是为了显示问题。如果控件背景色和窗体背景色相同,控件看起来就像在边缘逐渐消失。)

为什么会这样?看来 DrawImage 方法在计算边缘时仍然考虑了选定区域之外的像素。当然,既然我们选择了整个图像,那么选定区域**之外就没有像素**了。

DrawImage 只是想象出它们,并在计算中将它们视为 null。不幸的是,一个 null 像素似乎是完全透明的,也就是说,其 alpha 通道值为零,我想这符合所有值为零的预期。当将其纳入计算时,这会在边缘产生一些透明度。

我们还没有成功,但我们正在接近。我们得到了想要的图像。我们应该能够通过调整透明度来解决问题。

解决方法

我们可以简单地重复以上步骤,但在将最终图像绘制到控件之前,我们可以重置 Alpha(透明度)值。

下面的代码绘制了我们演示解决方案中的底部控件,该控件没有边缘问题。

protected override void OnPaint(PaintEventArgs e)
{
    ...

    // Now create another intermediate image,
    // this time the size of our destination
    Bitmap intermediateImage2 = new Bitmap(this.Width, this.Height);

    // This rectangle defines where we want
    // to draw on the second intermediate image
    Rectangle intermediateRectangle2 = new Rectangle(0, 0, 
     intermediateImage2.Width, intermediateImage2.Height);

    // Get the graphics object of the second
    // intermediate image, so we can draw to it.
    Graphics graphics2 = Graphics.FromImage(intermediateImage2);

    // Draw the first intermediate image on the second
    // intermediate image (this is where it gets stretched)
    graphics2.DrawImage(intermediateImage, intermediateRectangle2, 
                       intermediateRectangle, GraphicsUnit.Pixel);
    intermediateImage.Dispose(); // Let's be tidy!
    graphics2.Dispose(); // Let's be tidy!

    // Remove the alpha channel from the second
    // intermediate image by cloning it to itself
    intermediateImage2 = intermediateImage2.Clone(intermediateRectangle2, 
                                             PixelFormat.Format24bppRgb);
      
    // This rectangle defines where we want to draw on the control
     Rectangle destinationRectangle = 
               new Rectangle(0, 0, this.Width, this.Height);

    // Draw the second intermediate image on the control AT THE SAME SIZE
    e.Graphics.DrawImage(intermediateImage2, destinationRectangle, 
                         intermediateRectangle2, GraphicsUnit.Pixel);
    intermediateImage2.Dispose(); // Let's be tidy!

    ...
}

这段代码将我们的中间图像(仍然与源图像大小相同)复制到第二个中间图像中,该图像的大小与目标图像相同,即将其拉伸,第二个中间图像将具有透明边缘。

代码中棘手的部分是重置 alpha,使第二个中间图像再次变得完全不透明。

intermediateImage2 = intermediateImage2.Clone(intermediateRectangle2, 
                     PixelFormat.Format24bppRgb);

我们只是将图像克隆给自己,使用了不包含 alpha 通道的像素格式。这会剥离 alpha 信息。如果转换回 alpha 像素格式,所有 alpha 值将默认为 255。我们已经消除了透明度。现在我们可以将图像以相同的大小绘制到控件上,这样就不会有进一步的拉伸或边缘效果。

奏效了吗?

Fixed it!

当然,奏效了!但有点像 hack,而且需要几个步骤。肯定有更好的方法……

更好的方法。

这种意外行为的根本原因是,该方法将源矩形的起点视为**上左像素的中间**,而不是该像素的左上角。这似乎有点奇怪,但你可以看到,对于其他图形操作(如旋转),将像素定义为其中心可能是可取的。

通过将我们的矩形向上和向左移动半个像素来绕过这个问题。这可以补偿该方法向下和向右移动半个像素的起点,也就是说,我们的起点现在是原始像素的左上角。

选择半个像素可能看起来有点奇怪,因为我们倾向于将它们视为离散单元,但这是完全有效的。否则为什么会有 RectangleF

这是代码

protected override void OnPaint(PaintEventArgs e)
{
    // This rectangle defines the part we want to use from the source image,
    // but this time we offset the start point half a pixel up and to the 
    // left!
    RectangleF sourceRectangle = new RectangleF(39.5f, 39.5f, 40, 40);

    // This rectangle defines where we want to draw on the control
    RectangleF destinationRectangle = new RectangleF(0, 0, this.Width, 
        this.Height);

    // Set the interpolation mode to NearestNeighbor. (It should work now!)
    e.Graphics.InterpolationMode = 
                 System.Drawing.Drawing2D.InterpolationMode.NearestNeighbor;

    // And this procedure draws it for us.
    e.Graphics.DrawImage(sourceImage, destinationRectangle, sourceRectangle, 
         GraphicsUnit.Pixel);

    // YES!!! A fast and easy solution
      
    base.OnPaint(e);
}

请注意,我们还将插值模式设置为 NearestNeighbor。这以前可能没有帮助,但现在应该可以了。

Fixed it!

……确实可以。这是解决问题的最佳方法。

致谢

我第一次听说 DrawImage 的这种特殊性被称为 BorderBug 是在 Joel Neubeck 的文章:《Resizing a Photographic image with GDI+ for .NET》的讨论串中。我认为是 BigAndy 创造了这个词,并提出了第一个有效的解决方法,即用一个 10 像素的匹配边框包围所需图像,以便 DrawImage 获得适当的源矩形外像素。在“黑暗边缘解决方案”的讨论串中有一些对此的讨论。

但最大的感谢应该归功于 GDI+ 大师 darrellp,感谢他在 GDI+ 的丛林中给予的宝贵指导。谢谢 Darrell。

历史

  • 2006 年 7 月 19 日 - 提交我的第一篇 CodeProject 文章。
  • 2006 年 7 月 27 日 - 修改以包含 darrellp 提出的一个更好的解决方法。
© . All rights reserved.