如何为您的 WPF 应用程序添加简单的照片处理功能





5.00/5 (1投票)
允许用户导入、调整和查看图像
引言
这是我上一篇文章的续篇,如何让 WPF 在处理图像时表现得像 Windows(解决 DPI 问题),其中解释了如何防止 WPF 显示的图像尺寸与 Windows 不同。在本文中,我将介绍用户导入和显示图片所需的所有其他功能。
从剪贴板导入图像
用户可能希望从不同的来源导入图像
- 他在 Windows 资源管理器中选择一个图片文件并将其复制到剪贴板
- 他将图片文件的路径复制到剪贴板
- 他截屏并将其存储在剪贴板中
图像进入剪贴板后,他会按 Ctrl + V 将图像粘贴到 WPF 应用程序中。
要捕获 Ctrl + V,请在用户可以导入图像的 Window
中添加一个 KeyDown
事件处理程序。
private void Window_KeyDown(object sender, KeyEventArgs e) {
if (e.KeyboardDevice.Modifiers == ModifierKeys.Control) {
if (e.Key == Key.V) {
pasteFromClipboard();
}
}
}
更具挑战性的是读取剪贴板。理论上,这应该很简单,类似于
if (Clipboard.ContainsText()) {
var filePath = Clipboard.GetText();
//check if the filePath really is an image file path and read the file
} else if (Clipboard.ContainsImage()) {
var image = System.Windows.Clipboard.GetImage();
//process the image
}
经过大量的试错,我发现 Clipboard.ContainsText()
的工作方式不如预期,我不得不将代码更改为这样
BitmapSource? bitmapSource;
if (Clipboard.ContainsImage()) {
bitmapSource = Clipboard.GetImage();
} else {
var dataObject = Clipboard.GetDataObject();
var formats = dataObject.GetFormats();
if (formats.Contains("FileName")) {
var f = dataObject.GetData("FileName");
var fn = ((string[])Clipboard.GetData("FileName"))[0];
var extension = System.IO.Path.GetExtension(fn)[1..].ToLowerInvariant();
//WPF Imaging includes a codec for BMP, JPEG, PNG, TIFF, Windows Media Photo,
//GIF, and ICON image formats
if (extension == "jpg" || extension == "png" || extension == "gif" ||
extension == "bmp" || extension == "tiff" || extension == "icon")
{
bitmapSource = new BitmapImage(new Uri(fn));
}
}
}
BitmapSource
是基类,其中包含一个隐藏的位图,该位图存储图像的每个像素的值。派生类有助于创建或处理 BitmapSource
。
在图像显示给用户之前,必须撤消 WPF 使用的 DIP(设备无关像素)。
Windows 不使用图像文件的 DPI(每英寸点数)信息。显示的图像大小仅取决于图像包含的像素数。
另一方面,WPF 会以不同的大小显示具有相同像素数但 DPI 不同的图像。
为避免混淆用户,在将图片显示给用户之前,必须撤消 WPF 的 DPI 处理。这可以这样完成
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;
有关这如何工作的详细解释,请参阅我题为如何让 WPF 在处理图像时表现得像 Windows(解决 DPI 问题)的文章。
注意
重要的是使用 BeginInit()
来构造 bitmapImage
。还有一个构造函数 BitmapImage(string URL)
。它更简单易用,但当用户决定删除文件时,以后会带来麻烦。这会产生错误消息 File.Delete("imageFilePath")
无法访问该文件,因为另一个进程正在访问它。另一个进程实际上是我们的应用程序,因为 BitmapImage(string URL)
在 bitmapSource
被垃圾回收之前不会释放文件。BitmapImage
甚至没有 Dispose()
。这有多疯狂?
bitmapSource
仅存储图像的像素值。ImageControl
是一个 FrameworkElement
,可用于显示 bitmapSource
的内容。
让用户选择要导入图像的哪个部分
用户可能只想导入图像的一部分,并将其放大或缩小。我的 WPF 应用程序 **Photo Importer** 正是这样做的。
首先,用户在 **Windows** 应用程序(如 **Windows 资源管理器**)中选择一张图片,将其复制到剪贴板,然后切换到 **Photo Importer** 并按 Ctrl + V。用户可以通过右侧的 **缩放滚动条** 进行放大和缩小。用鼠标,他可以移动灰色的 **选择矩形**。矩形内的区域将被存储在应用程序中的图像部分。
在页脚还有 TextBoxes
,用户可以手动输入 X
和 Y
偏移量,而不是使用鼠标。他还可以输入灰色 **选择矩形** 的 Width
和 Height
。
顺便说一句,这是编写该应用程序时棘手的一部分。用户以图像的像素为单位思考,而 WPF 则使用 DIP 来表示 X
、Y
、Width
和 Height
。根据监视器的分辨率 (DPI),1 个图像像素可能覆盖多个监视器像素或仅一部分监视器像素。
这一行读取监视器的 DPI。
var dpi = VisualTreeHelper.GetDpi(this);
从 **图像像素**(WidthTextBox
)翻译到 **WPF DIP**(SelectionRectangle.Width
)
SelectionRectangle.Width = int.Parse(WidthTextBox.Text)/dpi.DpiScaleX;
从 **WPF DIP**(鼠标移动)翻译到 **图像像素**(定位 Selection Rectangle)
private void SelectionRectangle_MouseMove(object sender, MouseEventArgs e) {
if (e.LeftButton==MouseButtonState.Released) return;
var newMousePosition = e.GetPosition(ImageGrid);
newMousePosition.Offset((selectionPositionStartX - mouseStartPosition.X),
(selectionPositionStartY - mouseStartPosition.Y));
setSelectionPosition
(newMousePosition.X*dpi.DpiScaleX, newMousePosition.Y*dpi.DpiScaleY);
}
上面的代码可能难以理解。这里用伪代码进行解释。
首先,我们计算鼠标移动了多少 DIP。
var mouseTravelDistance = newMousePosition - mouseStartPosition;
然后我们将此差值加到原始 SelectionRectangle
的位置。
var newSelectionRectanglePosition = selectionPositionStart + mouseTravelDistance
最后,我们将 newSelectionRectanglePosition
乘以 dpi.DpiScaleX
以获得以图像像素数为单位的选择位置。
理论上,鼠标位置的 DIP 变化可以直接用于计算 SelectionRectangle
的新位置,该位置也以 DIP 为单位(即 X
和 Y
)。但由于我们不想让用户将 SelectionRectangle
移出图像,因此我们必须将 SelectionRectangle
的位置(DIP)限制为图像的最大尺寸(像素)。这意味着
- 计算鼠标移动了多少 DIP。
- 将该 DIP 距离转换为像素数。
- 计算新的
SelectionRectangle
的像素位置(newPosition = oldPosition + MouseMovementInPixel
)。 - 将新的
SelectionRectangle
位置限制在图像的最大尺寸内。 - 将限制后的新
SelectionRectangle
位置转换回 DIP,并使用该值来定位 Selection Rectangle。
实际上,情况甚至更复杂一些,有关详细信息,请参阅本文附加的代码。
幸运的是,使用 LayoutTransform
可以轻松实现放大和缩小。
ImageControl.LayoutTransform = new ScaleTransform(zoomFactor, zoomFactor);
zoomFactor
为 1 不会改变任何内容,0.5 会将显示的图像缩小一半,2 会将图像放大一倍。
注意
缩放 Scrollbar
的值刻度必须是对数/指数的。假设用户将 Scrollbar 移动一英寸,图像大小会翻倍,即 zoomFactor
变为 2。如果用户再移动一英寸,图像大小应该再次翻倍,zoomFactor
变为 4,而不是 2。有关详细信息,请参阅附加的代码。
保存新图像
此时,用户已经选择了要使用的图像部分。这就是 **Photo Importer** 中的所有功能。图像如何保存非常具体于应用程序,例如,保存在硬盘目录或数据库中。同样,图像如何与其他数据链接也取决于应用程序。
我附上了 ImageCache
类的代码。它将图像存储在 RAM 和 Windows 目录中。即使 WPF 在显示存储在 SSD 驱动器上的图像时速度惊人,但我觉得最好先将图片读入缓存,然后再显示它,因为在我的应用程序中,相同的图像会显示多次。此外,我想要每个图像的一个小缩略图,然后我可以使用它在 DataGrid
中显示。
我希望 ImageCache
不在 UI 层,而是在一个不引用 WPF 的较低层。不幸的是,必须使用 BitmapSource
来将图像存储在 WPF 的 RAM 中,所以我将我的 ImageCache
放在了 UI 层。当然,我也可以将 ImageCache
放在它自己的 DLL 中,这样的好处是更容易编写单元测试。
我发现最好将所有图像存储在两个不同的 Dictionary<int, BitmapSource>
中,一个用于正常尺寸的图片,一个用于缩略图,我经常在 DataGrid
中显示数据时使用它们。int
是 UserId
,它唯一标识每个用户并且永远不会改变。我将图像存储在 SSD 目录中,文件名是 Pic999.jpg,其中 999 代表实际的 UserId
。
注意
我预计我的应用程序最多存储 1000 张图片,并且不介意为此使用 1GB 的 RAM。如果您的应用程序处理大量图片,您可以编写一个更复杂的缓存,它会从缓存中删除未使用的图片,或者根本不使用缓存,因为从 SSD 驱动器读取图像文件速度惊人。即使您不使用 ImageCache
,在编写读取、存储和删除图像的代码时,也可以参考它的代码。
显示图像
以存储的尺寸显示图像很简单。
<Border HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="5" BorderBrush="Black" BorderThickness="2">
<Image x:Name="ImageControl" Stretch="None"/>
</Border>
在代码隐藏中
ImageControl.Source = bitmapSource;
在 DataGrid
中显示缩略图版本如下所示。
<DataGridTemplateColumn Header="Pic" Width="SizeToHeader">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Image Source="{Binding Pic}" Margin="-1"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
DataGrid.DataContext
绑定到用户记录列表,该列表有一个名为 Pic
、类型为 BitmapSource
的属性。缩略图的创建如下。
const double maxWidth = 19;
const double maxHeight = 30;
var scaleFactor = Math.Min(maxWidth / bitmapSource.Width,
maxHeight / bitmapSource.Height);
userRecord.Pic =
new TransformedBitmap(bitmapSource, new ScaleTransform(scaleFactor, scaleFactor));
我知道阅读这篇文章很乏味。但当我开始我的应用程序时,我希望有人写过这篇文章。我希望它对其他人有所帮助。
推荐阅读
如果您对 WPF 感兴趣,我强烈建议您查看我在 CodeProject 上发布的一些其他 WPF 文章,它们读起来更有趣。
我 最有用 的 WPF 文章
我 最引以为豪 的 WPF 文章
WPF 控件的 不可或缺的测试工具
MS 文档中 严重缺失 的 WPF 信息
我还写了一些非 WPF 的文章。
实现了不可能:
最受欢迎(300 万次浏览,37,000 次下载)
最有趣的:
我写 MasterGrab 是在 6 年前,从那时起,我几乎每天在开始编程之前都会玩它。击败 3 个试图抢占随机地图上所有 200 个国家的机器人大约需要 10 分钟。一旦一个玩家拥有所有国家,游戏就结束了。由于地图每次看起来都完全不同,所以游戏每天都充满乐趣和新鲜感。机器人带来了一些动态,它们之间的竞争程度与与人类玩家的竞争程度一样。如果您愿意,甚至可以编写自己的机器人,游戏是开源的。我大约花了两个星期写了我的机器人(整个游戏花了一年),但我很惊讶击败机器人有多难。与它们对战时,必须制定一种策略,让机器人攻击彼此而不是您。我迟早会为此写一篇 CodeProject 文章,但您现在就可以下载并玩它。应用程序中提供了很好的帮助,解释了如何玩。