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

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2023 年 5 月 24 日

公共领域

9分钟阅读

viewsIcon

12460

downloadIcon

117

允许用户导入、调整和查看图像

引言

这是我上一篇文章的续篇,如何让 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,用户可以手动输入 XY 偏移量,而不是使用鼠标。他还可以输入灰色 **选择矩形** 的 WidthHeight

顺便说一句,这是编写该应用程序时棘手的一部分。用户以图像的像素为单位思考,而 WPF 则使用 DIP 来表示 XYWidthHeight。根据监视器的分辨率 (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 为单位(即 XY)。但由于我们不想让用户将 SelectionRectangle 移出图像,因此我们必须将 SelectionRectangle 的位置(DIP)限制为图像的最大尺寸(像素)。这意味着

  1. 计算鼠标移动了多少 DIP。
  2. 将该 DIP 距离转换为像素数。
  3. 计算新的 SelectionRectangle 的像素位置(newPosition = oldPosition + MouseMovementInPixel)。
  4. 将新的 SelectionRectangle 位置限制在图像的最大尺寸内。
  5. 将限制后的新 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 中显示数据时使用它们。intUserId,它唯一标识每个用户并且永远不会改变。我将图像存储在 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 文章,但您现在就可以下载并玩它。应用程序中提供了很好的帮助,解释了如何玩。

© . All rights reserved.