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

签名框,使签名看起来更逼真

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.79/5 (28投票s)

2011 年 2 月 16 日

Apache

6分钟阅读

viewsIcon

79777

downloadIcon

2700

一个简单的 .NET 手持应用程序签名框。使用贝塞尔曲线使生成的签名看起来更像纸笔书写版本。

引言

你是否曾注意到,当你在手持设备上的**签名**框里写下你的名字时,你的**签名**看起来是多么幼稚且不精确?与标准笔相比,触控笔的尺寸很小,触控笔在屏幕上的摩擦力几乎可以忽略不计,而且手持设备通常是悬在空中而不是稳固地放在桌子上,这三个物理原因导致了这些难看的**签名**。另一个原因是触控屏经常会将输入错误发送到你的控件。这些错误可能在 1 到 4 像素之间。点击按钮中间时可能不明显,但在**签名**框中采样时,它们就成了一个麻烦。通过降低采样率并使用**贝塞尔**曲线插值,可以减轻所有这些因素的影响。


没有贝塞尔插值,签名看起来很笨拙

使用贝塞尔插值,大多数采样错误都得到了纠正

背景

在做一个项目时,一位客户要求我为他的应用程序添加一个**签名**输入框。我在网上找了很久,希望能找到一个足够好的开源实现。最后,我决定自己创建一个,顺便通过使用**贝塞尔**曲线来改进这个概念,看看效果能有多好。把它开源对我来说是自然而然的事情。现在这个项目可以在 SourceForge.net 上找到。

**贝塞尔**曲线被广泛应用于许多计算机图形应用中。例如,TrueType 字体使用三次**贝塞尔**样条来渲染平滑的字符曲线。**贝塞尔**插值也用于 3D 图形动画中,以渲染平滑自然的运动。当用于平滑长而复杂的曲线时,最好使用**贝塞尔**路径,它是在每四个点上计算的样条,而不是整个点集。使用三次样条处理包含四个点的点集,比使用通用的**贝塞尔**递归算法获得相同结果要快得多。三次样条、二次样条和线性插值分别与四点、三点和两点的采样一起使用。当采样包含超过四个点时,使用通用的**贝塞尔**曲线算法会更容易。关于**贝塞尔**曲线的数学背景和示例,请参阅 维基百科文章。其中提供的动画 GIF 和解释是入门的好起点。

Using the Code

使用 SignatureBox 控件非常简单明了。只需将**签名**框添加到您的应用程序中,并使其呈现您想要的形状。CreateImage 方法会根据采样点创建一个图像,无论是否使用**贝塞尔**(取决于 IsBezierEnabled 属性值)。Clear 方法用于清除 SignatureBox 的内容。非常简单,关于这个控件就没有更多可说的了。因此,我将在接下来的两节中详细介绍如何降低控件的采样率以及我的**贝塞尔**实现如何工作。

降低采样率

该算法根据当前点与使用 pictureBox_MouseDownpictureBox_MouseMouvepictureBox_MouseUp 事件采样的最后一个点之间的距离来判断是否保留当前点。在 pictureBox_MouseDown 时,会将 MouseEventArgs 提供的点添加到内部点列表中,并设置为 lastPoint 字段。在每次 pictureBox_MouseMove 时,会计算当前点与 lastPoint 之间的距离,如果大于内部常量 SAMPLING_INTERVAL,则保留该点。然后,当 pictureBox_MouseUp 事件发生时,会向内部点列表中添加一个 Point.Empty 并将其设置为 lastPoint 字段。Point.Empty 值随后会被绘图算法解释,以重现笔离开触摸屏表面的瞬间。

现在我们来看看代码是如何实现的

private const float SAMPLING_INTERVAL = 1.5f; // How far a new point 
				// must be from the previous one to be sampled.

private List<Point> points;
private Point lastPoint;

private void pictureBox_MouseDown(object sender, MouseEventArgs e)
{
    this.lastPoint = new Point(e.X, e.Y);
    this.points.Add(this.lastPoint);
}

private void pictureBox_MouseMove(object sender, MouseEventArgs e)
{
    Point newPoint = new Point(e.X, e.Y);
    if (Graph.Distance(this.lastPoint, newPoint) > SAMPLING_INTERVAL)
    {
        this.Draw(newPoint);
        this.lastPoint = newPoint;
        this.pictureBox.Refresh();
    }
}

private void pictureBox_MouseUp(object sender, MouseEventArgs e)
{
    this.StopDraw();
}

private void StopDraw()
{
    if (this.bezierEnabled)
    {
        if ((this.pointCount > 0) && (this.pointCount < 4))
        {
            Point[] p = new Point[this.pointCount];
            for (int i = 0; i < this.pointCount; i++)
                p[i] = this.points[this.points.Count - this.pointCount + i];
            this.graphics.DrawLines(this.pen, p);
        }
    }
    this.lastPoint = Point.Empty;
    this.points.Add(Point.Empty);
    this.pointCount = 0;
}

Graph.Distance 是一个简单的距离计算

public static double Distance(Point a, Point b)
{
    return Math.Sqrt(Math.Pow(b.X - a.X, 2) + Math.Pow(b.Y - a.Y, 2));
}

距离为 1.5,意味着一个点只有在距离最后一个采样点至少 1.5 像素时才会被采样。下表展示了像素的采样方式。每个方框代表一个像素,并包含其与中间点(即最后一个采样点)的距离

采样演示
2.83 2.24 2.00 2.24 2.83
2.24 1.41 1.00 1.41 2.24
2.00 1.00 0.00 1.00 2.00
2.24 1.41 1.00 1.41 2.24
2.83 2.24 2.00 2.24 2.83

贝塞尔通用算法

通用的**贝塞尔**递归算法非常简单。要了解递归是如何工作的,可以看看 Wikipedia.org 上的这个动画。共有五个点,构成总共四个灰色的初始线段。

贝塞尔演示,来自 Wikipedia.org

对于点集中的每个线段,使用“t”(介于 01 之间)的分数进行线性插值来计算一个新点。此操作使点集减少一个,从而使线段数量减少一个。重复此操作,直到算法只接收到两个点(参见上面 GIF 动画中的品红色线段)。此时,不是再次调用 Bezier.Interpolate 方法,而是从最后一个线段进行线性插值并返回最后一个点。要确定算法的精度并绘制完整的曲线,请将**贝塞尔**插值重复“n”次,其中“t = 1 / n”。

我的解释相当粗略,可能不如本科时学到的数学那么精确。我希望我的代码足够清晰,能够消除我可能在解释中造成的困惑。

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Text;

namespace GravelInnovation.BezierSignature
{
    public static class Bezier
    {
        /// ...
        public static Point[] Interpolate(int nbPoints, PointF[] points)
        {
            float step = 1.0f / (nbPoints - 1);
            Point[] retPoints = new Point[nbPoints];
            int i = 0;
            for (float t = 0; t < 1.0f; t += step)
            {
                PointF interpolatedPoint = InterpolatePoint(t, points)[0];
                retPoints[i] = new Point(
                    (int)Math.Round(interpolatedPoint.X), 
                    (int)Math.Round(interpolatedPoint.Y));
                i++;
            }

            PointF lastPoint = points[points.Length - 1];
            retPoints[retPoints.Length - 1] = new Point(
                (int)lastPoint.X, 
                (int)lastPoint.Y);

            return retPoints;
        }

        private static PointF[] InterpolatePoint(float t, params PointF[] points)
        {
            // There is only two points, return a simple linear interpolation.
            if (points.Length == 2)
                return new PointF[] {new PointF(
                    t * (points[1].X - points[0].X) + points[0].X, 
                    t * (points[1].Y - points[0].Y) + points[0].Y)};

            // For more than two points, call the Interpolate method with two
            // points to do a linear interpolation. This will reduce the 
            // number of points.
            PointF[] newPoints = new PointF[points.Length - 1];
            for (int i = 0; i < points.Length - 1; i++)
                newPoints[i] = InterpolatePoint(t, points[i], points[i + 1])[0];

            // This is where the recursion magic occurs
            return InterpolatePoint(t, newPoints);
        }
    }
}

请注意,我本应该创建一个 private static Point LinearInterpolate(double t, Point p1, Point p2) 方法来使整个代码更清晰,而不是调用 InterpolatePoint 并使用“t”和两个点来计算线性插值。同样清楚的是,使用递归算法来解决三次问题是过度且效率低下的,不如使用三次样条。对于**签名**而言,开销是察觉不到的,因为只有一个点数组。当用于平滑包含数千个点数组(每个数组本身又包含数千个点)的 3D 动画的运动时,算法的任何速度提升都是受欢迎的。

关注点

使用**贝塞尔**路径可以显著提高签名质量,同时又不会过多地降低控件的绘图速率。如果不需要此功能,可以通过更改布尔属性 IsBezierEnabled 来关闭它。

历史

  • 2011年2月16日:初次发布
  • 2011年2月22日:添加了代码片段和大量解释
© . All rights reserved.