用 WPF 挑选时尚






3.39/5 (11投票s)
通过对扫描图片的颜色区域取平均值来生成颜色集。

引言
GUI 设计师需要色彩——大量的色彩!有时你可能会在杂志、图片或大自然中找到一套漂亮的颜色。
所以:拍一张照片,扫描这张照片,然后挑选你喜欢的颜色。问题是照片的质量可能不是那么好:画幅中的任何一个像素可能不如从大面积感知到的颜色那么好。为了获得更好的结果,艺术家们知道结合相邻颜色(消除对比度)是明智的。对整张图片进行平均可能适用于背景颜色(也可能不适用……)。
我在这里展示的程序就能做到这一点:从图像中分离一个矩形区域并平均其颜色。使用漂亮的图片,你可以制作出一套有趣的颜色,并在你的图形界面中使用它们。
背景
此程序使用 Visual Studio 2008 Beta 2 构建。它展示了 XAML GUI 设计的特性:设计菜单和上下文菜单,处理图像、画布、滚动视图、网格和列表框 WPF 控件,以及使用旧的 GDI+ 操作形状和图像内容。结果可以导出到剪贴板。
设计
我期望界面有一个大区域用于显示加载的图像、一个菜单、可能还有一个状态栏以及一个已选颜色的列表。程序将能够通过滚动视图和更改缩放因子来操作图像。可用功能将允许从文件加载图像,裁剪图像的感兴趣部分(并最终将其保存回磁盘),以及平均所选矩形的颜色。提取的颜色将进入一个列表框,并以十六进制字符串的形式提供,可添加到其他代码文件或文档中。主功能(加载、保存、缩放、裁剪和拾取颜色)将在主菜单中按类别分组:“文件
”、“视图
”和“编辑
”,而提取功能将作为每个颜色项在所选颜色列表中的弹出菜单出现。最后,最后一个选择将在状态栏中显示。
首先,打开 VS2008 并创建一个新的“WPF 项目
”。该项目已经包含一个主窗口,它显示在设计器中。在设计器的 XAML 部分,我们可以更改窗口的标题(也可以更改窗口的名称,但由于我不打算创建更多窗口,它将保持原样:“PickColor
”项目中的“Window1
”类。)
为了容纳各种 UI 控件,WPF 提供了一个非常通用的控件:Grid。将一个 Grid 添加到主窗体,并将主区域划分为三行:菜单行、内容行和状态栏行。请注意,有些尺寸后面跟着“*
”——这些将是可变的,根据 Grid 随窗体大小的变化而容纳更多或更少的内容。我还添加了两列,主要是用于图像和已选颜色列表。
<Window x:Class="PickColor.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Pick Fashion" Height="481" Width="377">
<Grid Name="grid1" >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="315*" Name="Col0"/>
<ColumnDefinition Width="40" Name="Col1"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="22" />
<RowDefinition Height="395*" Name="Row1"/>
<RowDefinition Height="28" />
</Grid.RowDefinitions>
为了处理图像,我们预期图像会比窗体中提供的实际空间大。因此,在Row1
和Col0
中,我们添加一个ScrollView
控件;然后在滚动视图中——一个Canvas
,它将容纳图像和一个形状——选择矩形。最后一个将通过代码添加。
<ScrollViewer Grid.Row="1" Grid.Column="0" Name="scrollViewer1"
SizeChanged="scrollViewer1_SizeChanged"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<Canvas Name="canvas1" Height="393" Width="315">
<Image Name="image1" Cursor="Cross" ForceCursor="True"
MouseDown="image1_MouseDown" MouseMove="image1_MouseMove"
MouseUp="image1_MouseUp" Canvas.Top="0" Canvas.Left="0"
HorizontalAlignment="Left" VerticalAlignment="Top"
SizeChanged="image1_SizeChanged" Height="18" Width="66" />
</Canvas>
</ScrollViewer>
主菜单
要填充图像以及其他功能,我们需要一个 MainMenu:在 Edit、View 和 Zoom 菜单中,观察 SubmenuOpened 事件处理程序:这些事件在子菜单打开之前被激活,并为代码提供了检查或启用其他菜单项的机会。
- 编辑菜单仅在私有变量
FileName
不为null且selectedRectangle
形状已初始化时,才启用PickColor
和Crop
菜单项; - Zoom 和 View 菜单在这些菜单项激活之前 just 检查相应的菜单项。
为了显示或隐藏 Color List Box,Colors_Click
处理程序只是改变Col1
的宽度。
Col1.Width = new GridLength((Colors.IsChecked) ? 0.0 : 40.0);请注意
MouseDown
、MouseUp
和MouseMove
事件;它们负责绘制界定要裁剪或平均的区域的矩形。
/// <summary>
/// Start the selection
/// </summary>
private void image1_MouseDown(object sender, MouseButtonEventArgs e)
{
if (!image1.IsMouseCaptured)
{
image1.CaptureMouse();
MouseDownPoint = e.GetPosition(image1);
}
}
/// <summary>
/// Expand the selection rectangle as the mouse moves with tle left button
/// down
/// </summary>
private void image1_MouseMove(object sender, MouseEventArgs e)
{
if (image1.IsMouseCaptured)
{
Point current = e.GetPosition(image1);
// create a selectedRectangle if it does not exists
if (selectedRectangle == null)
{
selectedRectangle = new Rectangle();
selectedRectangle.Stroke = new SolidColorBrush(
System.Windows.Media.Colors.White);
selectedRectangle.Opacity = 0.50; // this makes the line thinner.
canvas1.Children.Add(selectedRectangle);
}
// else recycle the existing one.
selectedRectangle.Width = Math.Abs(MouseDownPoint.X - current.X);
selectedRectangle.Height = Math.Abs(MouseDownPoint.Y - current.Y);
Canvas.SetLeft(selectedRectangle, Math.Min(MouseDownPoint.X,
current.X));
Canvas.SetTop(selectedRectangle, Math.Min(MouseDownPoint.Y,
current.Y));
}
}
/// <summary>
/// Finish the selection.
/// </summary>
private void image1_MouseUp(object sender, MouseButtonEventArgs e)
{
if (image1.IsMouseCaptured)
{
image1.ReleaseMouseCapture();
}
}
更改大小
默认情况下,图像将适合 Canvas 内,并随窗口调整大小而不改变纵横比。然而,有些人可能会发现在此尺寸下工作很困难,除非将窗口最大化到整个屏幕。为了简化这个问题,我引入了缩放因子的概念:没有100%
、200%
……适合页面缩放;相反,图像在缩放因子 1.0 时将完全适合,只有一半——高度或宽度(以较高者为准),依此类推。缩放因子小于 1 没有意义。对于 1.0——滚动视图不显示任何滚动条,而对于更大的因子,控件会显示任何合适的滚动条(至少一个),因此用户可以滚动到任何他们想要的部分。



由于选择矩形形状与图像位于同一个 Canvas 中,而 Canvas 又在ScrollView
控件内,因此矩形会随着图像一起移动,但是使用缩放因子调整图像大小需要进行调整。这就像这样
double zoomFactor = 2.0;
/// <summary>
/// Record the zoom factor and adjust the image size
/// as a multiple of the corresponding grid cell.
/// </summary>
/// <param name="zoom">The new zoom factor.</param>
private void doZoom(double zoom)
{
zoomFactor = zoom;
image1.Width = Col0.ActualWidth * zoomFactor;
image1.Height = Row1.ActualHeight * zoomFactor;
}
/// <summary>
/// ScroolView changes with the window or its cell.
/// </summary>
private void scrollViewer1_SizeChanged(object sender, SizeChangedEventArgs e)
{
doZoom(zoomFactor);
}
/// <summary>
/// Image zoom factor changes as a resault of a zoom menu item selection.
/// </summary>
private void ZoomX_Click(object sender, RoutedEventArgs e)
{
doZoom(Convert.ToDouble(((MenuItem)sender).Tag));
}
/// <summary>
/// When the image size changes, the canvas has to accomodate its dimmensions,
/// so does the selection rectangle.
/// </summary>
private void image1_SizeChanged(object sender, SizeChangedEventArgs e)
{
canvas1.Width = image1.ActualWidth;
canvas1.Height = image1.ActualHeight;
if (selectedRectangle != null)
{
double scaleX = e.NewSize.Width / e.PreviousSize.Width;
double scaleY = e.NewSize.Height / e.PreviousSize.Height;
selectedRectangle.Width = scaleX * selectedRectangle.Width;
selectedRectangle.Height = scaleY * selectedRectangle.Height;
Canvas.SetLeft(selectedRectangle, scaleX * Canvas.GetLeft(
selectedRectangle));
Canvas.SetTop(selectedRectangle, scaleY * Canvas.GetTop(
selectedRectangle));
}
}
当图像大小因窗口大小调整或ZoomX
菜单项单击而改变时,图像大小将通过将zoomFactor
乘以相应的网格单元(Col0
、Row1
)来计算。由于图像大小的改变,Canvas 会被重新调整以匹配图像的 Actual Size,并且selectedRectangle
(如果存在)会被缩放。
啊——裁剪!……GDI
作为使用缩放因子的替代方法,用户可以选择“裁剪”选定的矩形并继续处理一个较小的图像。

不幸的是,我没有找到比这更好的方法来裁剪和平均矩形像素,而不需要使用古老的 GDI 库。互联网上的两个例程在这个过程中被证明非常有用。
/// <summary>
/// From Forum:
/// http://forums.microsoft.com/MSDN/ShowPost.aspx?PostID=912762&SiteID=1
/// </summary>
/// <param name="bm">Source bitmap (System.Drawing)</param>
/// <param name="rect">Rectangle to extract/crop</param>
/// <returns></returns>
public BitmapSource ConvertGDI_To_WPF(System.Drawing.Bitmap bm,
Int32Rect rect)
{
BitmapSource bms = null;
if (bm != null)
{
IntPtr h_bm = bm.GetHbitmap();
bms = Imaging.CreateBitmapSourceFromHBitmap(h_bm, IntPtr.Zero, rect,
BitmapSizeOptions.FromEmptyOptions());
}
return bms;
}
/// <summary>
/// From Page:
/// How-to-use-ImageSource
/// _2800_no-handler_2900_-in-WinForms-as-System.Drawing.Bitmap-
/// _2800_hbitmap_2900_.aspx
/// </summary>
/// <param name="source">BitmapSource</param>
/// <returns>System.Drawing.Bitmap</returns>
private System.Drawing.Bitmap BitmapSource2GDI(BitmapSource source)
{
int width = source.PixelWidth;
int height = source.PixelHeight;
int stride = width * ((source.Format.BitsPerPixel + 7) / 8);
byte[] bits = new byte[height * stride];
source.CopyPixels(bits, stride, 0);
unsafe
{
fixed (byte* pBits = bits)
{
IntPtr ptr = new IntPtr(pBits);
System.Drawing.Bitmap bitmap = new System.Drawing.Bitmap(
width, height, stride,
System.Drawing.Imaging.PixelFormat.Format32bppPArgb,
ptr);
return bitmap;
}
}
有了这些,裁剪过程如下
/// <summary>
/// Crop the image from inside the selectedRectangle.
/// </summary>
/// <remarks>
/// The Selected Rectangle does not need to be tested for not null,
/// because this button may be clicked only when the selection is valid.
/// </remarks>
private void Crop_Click(object sender, RoutedEventArgs e)
{
using (System.Drawing.Bitmap source = BitmapSource2GDI(
(BitmapSource)image1.Source))
{
Int32Rect rect = new Int32Rect();
double scaleX = source.Width / image1.ActualWidth;
double scaleY = source.Height / image1.ActualHeight;
rect.X = (int)(scaleX * Canvas.GetLeft(selectedRectangle));
rect.Y = (int)(scaleY * Canvas.GetTop(selectedRectangle));
rect.Width = (int)(scaleX * selectedRectangle.Width);
rect.Height = (int)( scaleY * selectedRectangle.Height);
image1.Source = ConvertGDI_To_WPF(source, rect);
canvas1.Children.Remove(selectedRectangle);
selectedRectangle = null;
FileName = "*";
this.Title = FileName;
}
}
请注意,我们可以从文件中读取System.Drawing.Bitmap
;然而,如果连续裁剪,这种方法会很不方便——除非我们关心保存和重命名新图像。最后,我清理了selectedRectangle
,因为对于新图像它没有意义,并重置了FileName
变量,以便Save
菜单项知道默认保存到SaveAs
。
平均所选矩形中的颜色也是如此。
/// <summary>
/// Calculate the adverage color of the selection.
/// </summary>
/// <remarks>
/// The Selected Rectangle does not need to be tested for not null,
/// because this button may be clicked only when the selection is valid.
/// </remarks>
private void PickColor_Click(object sender, RoutedEventArgs e)
{
using (System.Drawing.Bitmap source = BitmapSource2GDI(
(BitmapSource)image1.Source))
{
double scaleX = source.Width / image1.ActualWidth;
double scaleY = source.Height / image1.ActualHeight;
int x0 = (int)(Canvas.GetLeft(selectedRectangle) * scaleX);
int y0 = (int)(Canvas.GetTop(selectedRectangle) * scaleY);
int x1 = x0 + (int)(selectedRectangle.Width * scaleX);
int y1 = y0 + (int)(selectedRectangle.Height * scaleY);
long n = 0;
long r = 0; long g = 0; long b = 0;
for (int y = y0; y < y1; y++)
{
for (int x = x0; x < x1; x++)
{
System.Drawing.Color c = source.GetPixel(x, y);
n++;
r += c.R;
g += c.G;
b += c.B;
}
}
System.Windows.Media.Color a = System.Windows.Media.Color.FromArgb(
255, (byte)(r / n), (byte)(g / n), (byte)(b / n));
label1.Background = new SolidColorBrush(a);
label1.Content = a.ToString();
Shape s = new Rectangle();
s.Width = 32;
s.Height = 25;
s.Fill = new SolidColorBrush(a);
s.ContextMenu = ColorItemMenu;
s.ToolTip = a.ToString();
ColorListBox.Items.Add(s);
}
}
平均结果
在将源获取为System.Drawing.Bitmap
后,代码只需迭代所选矩形区域的像素行和列,并平均每个分量。最后,它从这些分量中生成一个System.Windows.Media.Color
,并用颜色标记状态栏。使用相同的颜色,它创建一个新的 Shape 对象并将其添加到右侧的ColorListBox
中,靠近图像。新的列表项有一个工具提示,显示其颜色的十六进制值,以及一个上下文菜单,允许将颜色值提取到剪贴板,并删除不需要的项。ContextMenu
是所有项的通用项,它在窗口的构造函数中创建。
public Window1()
{
InitializeComponent();
// hide the ColorListBox at start
Col1.Width = new GridLength(0.0);
MenuItem miCopy = new MenuItem();
miCopy.Header = "Copy to Clipboard";
miCopy.Click += new RoutedEventHandler(miCopy_Click);
ColorItemMenu.Items.Add(miCopy);
MenuItem miCopyAll = new MenuItem();
miCopyAll.Header = "Copy All to Clipboard";
miCopyAll.Click += new RoutedEventHandler(miCopyAll_Click);
ColorItemMenu.Items.Add(miCopyAll);
MenuItem miDelete = new MenuItem();
miDelete.Header = "Delete";
miDelete.Click += new RoutedEventHandler(miDelete_Click);
ColorItemMenu.Items.Add(miDelete);
}
/// <summary>
/// Copy the selected indes's color hex value to clipboard.
/// </summary>
void miCopy_Click(object sender, RoutedEventArgs e)
{
Clipboard.SetData("Text",
((SolidColorBrush)(((Rectangle)(
ColorListBox.Items[ColorListBox.SelectedIndex])).Fill)).Color.ToString());
}
/// <summary>
/// Copy all listed colors' hex value to clipboard.
/// </summary>
void miCopyAll_Click(object sender, RoutedEventArgs e)
{
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ColorListBox.Items.Count; i++)
{
sb.AppendLine(((SolidColorBrush)(
((Rectangle)(ColorListBox.Items[i])).Fill)).Color.ToString());
}
Clipboard.SetData("Text", sb.ToString());
}
/// <summary>
/// Delete the selected list item.
/// </summary>
void miDelete_Click(object sender, RoutedEventArgs e)
{
ColorListBox.Items.Remove(ColorListBox.Items[ColorListBox.SelectedIndex]);
}
我已经在代码中隐藏了ColorListBox
,因为在设计时打开它更方便。
右键单击任何列表项并选择“全部复制到剪贴板”,剪贴板将填充以下文本
#FFBE3F67
#FFE07E82
#FFED9C35
#FF56CD6A
#FFE9C787
#FF73513C
这正是本项目想要达到的目标!
关注点
WPF 和 GDI 在本项目中的应用就到这里!我希望我能从这个程序中移除 GDI 的往返操作——也许有人会给我们提供更好的方法,但在此之前,我希望这些方法对我们很多人都很有用。
请注意,Save
和SaveAs
功能尚未实现(谁在乎?!)
我也希望实现一个命令模式来处理裁剪/反裁剪、选择/取消选择等。
敬请关注——我可能会带着一些改进回来,其中包括——使用 XML 进行窗体持久化。