目录
介绍
当Windows在“照片”中显示类似.jpg文件的图片时,它不关心文件的dpi(每英寸点数)设置。具有不同dpi设置但相同像素数的文件以相同的大小显示。以下是三个文件,它们都使用500 x 500像素,但dpi设置不同:
WPF显示相同的图片,如下所示:
如果编写的应用采用Windows程序生成的图像文件,更改图像中的某些内容,然后将其作为新的图像文件写回,则用户希望WPF应用的行为类似于传统的Windows应用,即以与Windows相同的大小显示图像。
显示器的dpi设置也存在类似的差异。Windows对图像使用相同数量的“像素”,而不考虑监视器的dpi设置。请注意,用户可以在Windows中更改用于显示一个“像素”的监视器像素数。
另一方面,WPF使用图像文件的dpi信息。如果该文件的宽度为96像素,DPI为96,则WPF会尝试在1 dpi监视器和96 dpi监视器上显示它192英寸长。在第一种情况下,它使用96个显示器像素,在第二种情况下使用192个显示器像素。
不幸的是,在许多情况下,用户并不关心实际尺寸,而是希望看到整个图像,无论用户是看小手机屏幕、笔记本电脑屏幕还是巨大的电视屏幕。在1英寸电视上显示100英寸的图像有什么用?
本文介绍如何编写代码,以便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的类,该类将像素存储在二维数组中,但该数组是隐藏的。如果要读取和写入像素,则需要使用继承自BitmapSource的WriteableBitmap。
Image使用派生自BitmapSource的内部类来存储像素。然后,它将位图的像素缩放为屏幕像素。
要允许在显示位图之前对其进行处理,最好先将图像文件读入继承自BitmapSource的BitmapImage,然后将其分配给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;
图像是DIP中定义的FrameworkElement,并且具有Width和Height定义,并且可以是可视化树的一部分。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:将WPF Visual呈现为位图
这三个类用于处理位图。它们将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上绘制将要使用的部分。
- 在我的应用程序中,用户可以放大图像,理想情况下,可以使用Image.LayoutTransform。使用Image.LayoutTransform不同目的(缩放、DPI校正)使代码进一步复杂化。
比我更好的程序员可能能够解决所有这些问题,但我放弃了,并试图找到一种更简单的方法来纠正DPI处理。
第二种失败的方法
我的下一个想法是更正位图大小,以补偿WPF对DPI的处理。如果WPF显示的图像是Windows的两倍,我想将图像缩小50%。转换现有位图可以通过使用另一个继承自BitmapSource的类TransformedBitmap来完成。它将继承自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的两倍,则不需要缩放图像,只需向WPF假装位图具有与WPF DPI设置值相同的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可能不能很好地与单词边界对齐,因此,Microsoft建议以更复杂的方式进行计算rawStride。
有了这个,我让WPF在DPI方面表现得像Windows。请注意,我没有更改整个WPF Window的行为,而只是更改了Image。我的程序的其余部分变得简单得多,尽管我仍然必须满足用户在图像像素和DIP中的WPF的思考。
结语
是的,我知道这是相当乏味的阅读,导致只有几行代码。我使用WPF已经超过15年了,但我仍然在努力理解位图在WPF中的工作原理。因此,我认为分享我的学习过程的细节可能会帮助其他面临同样挑战的开发人员。WPF非常强大,但不幸的是,它也很复杂,而且有点未完成。当只阅读Microsoft的文档时,很多事情都没有意义。我希望我的文章有助于缩小一些文档空白。
我的初衷是写一篇关于如何开发WPF应用程序的文章,该应用程序允许用户选择照片的一部分并将其存储为新的.jpg文件。我仍然会写那篇文章,但我想我需要先解释一下如何解决DPI问题。
如果你对WPF感兴趣,我强烈建议你阅读我的其他一些WPF文章:
我最有用的WPF文章:
WPF控件不可或缺的测试工具:
MS文档中严重缺乏 WPF信息:
https://www.codeproject.com/Articles/5360403/How-to-Make-WPF-Behave-like-Windows-when-Dealing-w