如何让 WPF 在处理图像时表现得像 Windows(解决 DPI 问题)





5.00/5 (4投票s)
WPF 在使用 DIP(设备无关像素)时可能过于聪明
引言
当 Windows 在“照片”中显示像 .jpg 文件这样的图片时,它不关心文件的 DPI(每英寸点数)设置。具有不同 DPI 设置但相同像素数量的文件会以相同大小显示。这里有三个文件,它们都使用 500 x 500 像素,但 DPI 设置不同。
WPF 像这样显示相同的图片
如果您编写一个应用程序,它获取由 Windows 程序生成的图像文件,更改图像中的一些内容,然后将其作为新图像文件写回,用户期望 WPF 应用程序表现得像传统的 Windows 应用程序,即以 Windows 显示图像的相同大小显示图像。
在显示器的 DPI 设置方面存在类似的差异。无论显示器的 DPI 设置如何,Windows 都为图像使用相同数量的“像素”。请注意,用户可以在 Windows 中更改用于显示一个“像素”的显示器像素数量。
另一方面,WPF 使用图像文件的 DPI 信息。如果该文件宽 96 像素,DPI 为 96,WPF 会尝试在 96 DPI 显示器和 192 DPI 显示器上将其显示为 1 英寸长。在第一种情况下,它使用 96 个显示器像素,在第二种情况下使用 192 个。
不幸的是,在许多情况下,用户不关心真实尺寸,而是希望看到整个图像,无论用户是在小手机屏幕、笔记本电脑屏幕还是巨大电视屏幕上观看。在 100 英寸电视上以 1 英寸显示图像有什么用?
本文解释了如何编写代码,使 WPF 像 Windows 一样处理图像。解决方案确实很简单,但我花了一个月才弄清楚。
如何在 WPF 中显示图像
显示图像文件的最简单的 WPF 代码是这样的
<Image Source="SomePath\SomeImageFile.jpg"/>
WPF 图形系统在图形以图形命令(矢量图形)定义时效果最佳,例如使用特定粗细和颜色从点 A 到点 B 绘制一条线。
附注
绘制一条黑色 1 显示器像素的线几乎是不可能的。问题在于显示图形的 `Control` 会被包含它的其他控件定位,并且图像边框最常位于 2 个显示器像素之间。这导致线条在 2 个像素上绘制,并且是灰色而不是黑色。还有一个问题。许多人认为当他们 `set StrokeThickness = 1` 时,他们在显示器上得到 1 个像素。但根据显示器的 DPI,1DIP 可能会转换为几个像素或只是一部分像素。有 `SnapsToDevicePixels` 或 `GuidelineSet` 等属性应该对此有所帮助,但我从未设法获得一条真正的黑色 1 像素线。
然而,图像文件是面向像素的,WPF 并不真正喜欢这样。它将图像的像素值存储在位图中,一个名为 `BitmapSource` 的类,它将像素存储在二维数组中,但该数组是隐藏的。如果你想读写像素,你需要使用 `WriteableBitmap`,它继承自 `BitmapSource`。
`Image` 使用一个派生自 `BitmapSource` 的内部类来存储像素。然后它将位图的像素缩放到屏幕像素。
为了允许在显示位图之前对其进行处理,最好先将图像文件读入 `BitmapImage`(它继承自 `BitmapSource`),然后将其分配给 `Image.Source`。
<Image Stretch="None">
<Image.Source>
<BitmapImage UriSource="SomePath\SomeImageFile.jpg" />
</Image.Source>
</Image>
通常,我更喜欢在 XAML 中只定义 UI 相关控件,并在代码后台完成所有其他操作。
<Image Name="MyImage"/>
var bitmapImage= new BitmapImage(new Uri("SomePath\SomeImageFile.jpg"));
MyImage.Source = bitmapSource;
Image 是一个 `FrameworkElement`,因此它的 `Width` 和 `Height` 以 DIP 定义,并且可以是视觉树的一部分。`BitmapImage` 不继承自 `Visual`,因此不能在视觉树中使用。`BitmapImage` 的一些属性都是 `readonly`
PixelWidth
,PixelHeight
: 图像像素中的位图大小DpiX
,DpiY
: 图像文件中指示的每英寸点数Width
,Height
: 位图在设备独立像素中的大小。Width
= 96 /DpiX
*PixelWidth
。它们通常与Image.ActualWidth
和Image.ActualHeight
的值不同。DecodePixelWidth
、DecodePixelHeight
:可在读取图像文件之前用于指示结果位图应有多大。当您已经知道只需要图片的缩小版本(如缩略图)时,这可以最大程度地减小位图大小。但是,如果您想处理图像并将结果稍后在 Windows 中使用,请不要给DecodeXxx
赋值,这意味着位图中的每个像素都将包含与图像文件相同的值。
WPF 位图类继承
在 WPF 中理解位图的一个挑战是,实际上有相当多的位图类。以下是最重要的一些
Image
是一个FrameworkElement
。它不是位图,但它的Source
属性中包含位图或矢量图形。ImageSource
是位图或矢量图形的基类。它只有很少的属性,基本上是使用 DIP 的图像的Height
和Width
。DrawingImage
用于矢量图形,不属于本文讨论范围。BitmapSource
是所有其他位图类的基类。我认为它将位图的实际数据保存在一个二维数组中,该数组对程序员是不可见的。可以从一个包含像素值的整数数组创建BitmapSource
,并将位图导出为整数数组。
这三个类用于创建位图
BitmapImage
: 读取图像文件BitmapFrame
: 用于存储 gif 文件,它可能有多个帧(图像)RenderTargetBitmap
: 将 WPFVisual
渲染到位图中
这三个类用于处理位图。它们以 `BitmapSource` 作为输入,并提供一个更改的位图作为输出。
TransformedBitmap
: 缩放和旋转BitmapSource
CroppedBitmap
: 只裁剪BitmapSource
的一部分WriteableBitmap
: 提供修改位图像素的方法
使 WPF 像 Windows 一样运行的第一个方法
起初,我以为这个问题很容易解决。如果 WPF 显示的图像比 Windows 大两倍,我只需调整 Image 控件的大小。这可以通过布局转换轻松完成,如下所示
//correct the monitor dpi
var dpi = VisualTreeHelper.GetDpi(this);
var correctionX = 96/dpi.PixelsPerInchX;
var correctionY = 96/dpi.PixelsPerInchY;
//correct the image's dpi
var bitmapImage = new BitmapImage(new Uri("MyFile"));
correctionX *= bitmapImage.DpiX/96;
correctionY *= bitmapImage.DpiY/96;
Image.Source = bitmapImage;
Image.LayoutTransform = new ScaleTransform(correctionX, correctionY);
然而,这给我的程序其余部分带来了很多问题。
- 用户以图像像素为单位思考,例如,当他想创建一张宽度为 100 的照片时。他并不关心 DIP 或显示器像素。
- 计算 Image 所需的 DIP 是复杂的,它涉及上述修正。
- 鼠标移动也以 DIP 为单位。如果用户可以使用鼠标定义图像的哪个部分应该写入新文件,则鼠标移动定义的距离必须转换为图像像素,以便用户知道新图像的尺寸。
- 原始图像的尺寸限制了鼠标可以移动的范围。如果用户可以给新图像一个比原始图像更大的宽度,这可能没有意义。这意味着每个鼠标移动都必须与图像像素匹配,然后需要将这些像素转换回 DIP,以便 GUI 可以在图像上绘制将使用的部分。
- 在我的应用中,用户可以放大图像,这可以通过使用
Image.LayoutTransform
来理想地完成。将Image.LayoutTransform
用于不同目的(缩放、DPI 校正)进一步使代码复杂化。
比我更好的程序员可能已经解决了所有这些问题,但我放弃了,并试图找到一种更简单的方法来纠正 DPI 处理。
第二次失败的尝试
我的下一个想法是修正位图大小,以弥补 WPF 对 DPI 的处理。如果 WPF 显示的图像比 Windows 大两倍,我想将图像缩小 50%。转换现有位图可以通过使用 `TransformedBitmap` 来完成,这是另一个继承自 `BitmapSource` 的类。它以任何继承自 `BitmapSource` 的类作为输入,并应用一个转换,在本例中是缩放。
var bitmapImage = new BitmapImage(new Uri("MyFile"));
var dpi = VisualTreeHelper.GetDpi(this);
var bitmapSource = new TransformedBitmap(bitmapImage,
new ScaleTransform(bitmapImage.DpiX/96/dpi.PixelsPerInchX,
bitmapImage.DpiY/96/dpi.PixelsPerInchX));
Image1.Source = bitmapImage;
然而,我的代码仍然无法正常工作,此外,由于转换,图像质量也受到了影响,因为它可能需要将 1 个像素放大到 1.1234 个像素,或者将一个像素缩小到 0.4321 个像素,从而改变了像素值。
最终解决方案
我意识到我不应该改变像素数量,这让我想到可以调整位图的 DPI。如果 WPF 显示的图像比 Windows 大两倍,我不需要缩放图像,只需假装位图的 DPI 与 WPF DPI 设置的值相同。结果是每个位图像素都将被绘制到 1 个显示器像素上,就像 Windows 所做的那样。但有一个问题:`BitmapImage` 的 DPI 设置是只读的!基本上,`BitmapImage` 的构造函数会读取图像文件的 DPI。为了改变 DPI 值,必须将位图内容导出到一个二维数组中,然后使用该数组创建一个新的 `BitmapSource`。是的,这会占用两倍的 RAM,但幸运的是复制大数组很快,而且我们现在有大量的 RAM。
var bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
bitmapImage.UriSource = new Uri(fileName);
bitmapImage.EndInit();
BitmapSource bitmapSource;
var dpi = VisualTreeHelper.GetDpi(this);
if (bitmapImage.DpiX==dpi.PixelsPerInchX && bitmapImage.DpiY==dpi.PixelsPerInchY) {
//use the BitmapImage as it is
bitmapSource = bitmapImage;
} else {
//create a new BitmapSource and use dpi to set its DPI without changing any pixels
PixelFormat pf = PixelFormats.Bgr32;
int rawStride = (bitmapImage.PixelWidth * pf.BitsPerPixel + 7) / 8;
byte[] rawImage = new byte[rawStride * bitmapImage.PixelHeight];
bitmapImage.CopyPixels(rawImage, rawStride, 0);
bitmapSource = BitmapSource.Create(bitmapImage.PixelWidth, bitmapImage.PixelHeight,
dpi.PixelsPerInchX, dpi.PixelsPerInchY, pf, null, rawImage, rawStride);
}
ImageControl.Source = bitmapSource;
如上所述,许多 `BitmapImage` 属性一旦读取图像文件就无法再更改。然而,通过使用 `BeginInit()` 设置一些属性,然后调用 `EndInit()`,只有在此之后文件才会被读取,这是可能的。我不得不在我的应用程序中采用这种方法,因为 `BitmapImage` 还有另一个烦人的问题。即使读取完成,它也保持图像文件打开,这意味着无法向用户显示图像然后让他永久删除它。使用 `BitmapCacheOption.OnLoad` 会在读取后关闭文件。
上面的代码可以简化为
int rawStride = bitmapImage.PixelWidth * 32;
我们已经知道每个像素需要 32 位(ARGB 各 8 位)。其他 `PixelFormats` 可能无法很好地与字边界对齐,因此,微软建议以更复杂的方式计算 `rawStride`。
这样,我就让 WPF 在处理 DPI 时表现得像 Windows 一样。请注意,我没有改变整个 WPF `Window` 的行为,而只改变了 `Image`。我的程序的其余部分变得简单得多,尽管我仍然需要考虑到用户以图像像素思考而 WPF 以 DIP 思考的情况。
最后的 remarks
是的,我知道这读起来很繁琐,最终只产生了少量的代码。我使用 WPF 已经超过 15 年了,但我仍然在努力理解 WPF 中位图的工作原理。所以我认为分享我的学习过程的细节可能会帮助其他面临同样挑战的开发人员。WPF 强大得令人惊叹,但不幸的是也很复杂,有点未完成。很多东西在只阅读微软的文档时并没有真正意义。我希望我的文章能帮助填补一些文档空白。
我最初的意图是写一篇关于如何开发一个 WPF 应用程序的文章,让用户选择照片的一部分并将其保存为新的 .jpg 文件。我仍然会写那篇文章,但我想我需要先解释如何解决 DPI 问题。
如果您对 WPF 感兴趣,我强烈建议您阅读我的其他 WPF 文章
我 最有用 的 WPF 文章
我 最引以为豪 的 WPF 文章
WPF 控件的 不可或缺的测试工具
MS 文档中 严重缺失 的 WPF 信息
我还写了一些非 WPF 的文章。
实现了不可能:
最受欢迎(300 万次浏览,37,000 次下载)
历史
- 2023 年 5 月 11 日:初始版本