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

用 WPF 挑选时尚

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.39/5 (11投票s)

2007年10月29日

CPOL

7分钟阅读

viewsIcon

42515

downloadIcon

592

通过对扫描图片的颜色区域取平均值来生成颜色集。

Screenshot - image001.png

引言

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>

为了处理图像,我们预期图像会比窗体中提供的实际空间大。因此,在Row1Col0中,我们添加一个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不为nullselectedRectangle形状已初始化时,才启用PickColorCrop菜单项;
  • Zoom 和 View 菜单在这些菜单项激活之前 just 检查相应的菜单项。

为了显示或隐藏 Color List Box,Colors_Click处理程序只是改变Col1的宽度。

Col1.Width = new GridLength((Colors.IsChecked) ? 0.0 : 40.0);
请注意MouseDownMouseUpMouseMove事件;它们负责绘制界定要裁剪或平均的区域的矩形。

/// <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——滚动视图不显示任何滚动条,而对于更大的因子,控件会显示任何合适的滚动条(至少一个),因此用户可以滚动到任何他们想要的部分。

Screenshot - image004.pngScreenshot - image006.pngScreenshot - image008.png

由于选择矩形形状与图像位于同一个 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乘以相应的网格单元(Col0Row1)来计算。由于图像大小的改变,Canvas 会被重新调整以匹配图像的 Actual Size,并且selectedRectangle(如果存在)会被缩放。

啊——裁剪!……GDI

作为使用缩放因子的替代方法,用户可以选择“裁剪”选定的矩形并继续处理一个较小的图像。

Screenshot - image013.png

不幸的是,我没有找到比这更好的方法来裁剪和平均矩形像素,而不需要使用古老的 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 的往返操作——也许有人会给我们提供更好的方法,但在此之前,我希望这些方法对我们很多人都很有用。

请注意,SaveSaveAs功能尚未实现(谁在乎?!)

我也希望实现一个命令模式来处理裁剪/反裁剪、选择/取消选择等。
敬请关注——我可能会带着一些改进回来,其中包括——使用 XML 进行窗体持久化。

© . All rights reserved.