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






4.79/5 (28投票s)
一个简单的 .NET 手持应用程序签名框。使用贝塞尔曲线使生成的签名看起来更像纸笔书写版本。
引言
你是否曾注意到,当你在手持设备上的**签名**框里写下你的名字时,你的**签名**看起来是多么幼稚且不精确?与标准笔相比,触控笔的尺寸很小,触控笔在屏幕上的摩擦力几乎可以忽略不计,而且手持设备通常是悬在空中而不是稳固地放在桌子上,这三个物理原因导致了这些难看的**签名**。另一个原因是触控屏经常会将输入错误发送到你的控件。这些错误可能在 1 到 4 像素之间。点击按钮中间时可能不明显,但在**签名**框中采样时,它们就成了一个麻烦。通过降低采样率并使用**贝塞尔**曲线插值,可以减轻所有这些因素的影响。
![]() 没有贝塞尔插值,签名看起来很笨拙
|
![]() 使用贝塞尔插值,大多数采样错误都得到了纠正
|
背景
在做一个项目时,一位客户要求我为他的应用程序添加一个**签名**输入框。我在网上找了很久,希望能找到一个足够好的开源实现。最后,我决定自己创建一个,顺便通过使用**贝塞尔**曲线来改进这个概念,看看效果能有多好。把它开源对我来说是自然而然的事情。现在这个项目可以在 SourceForge.net 上找到。
**贝塞尔**曲线被广泛应用于许多计算机图形应用中。例如,TrueType 字体使用三次**贝塞尔**样条来渲染平滑的字符曲线。**贝塞尔**插值也用于 3D 图形动画中,以渲染平滑自然的运动。当用于平滑长而复杂的曲线时,最好使用**贝塞尔**路径,它是在每四个点上计算的样条,而不是整个点集。使用三次样条处理包含四个点的点集,比使用通用的**贝塞尔**递归算法获得相同结果要快得多。三次样条、二次样条和线性插值分别与四点、三点和两点的采样一起使用。当采样包含超过四个点时,使用通用的**贝塞尔**曲线算法会更容易。关于**贝塞尔**曲线的数学背景和示例,请参阅 维基百科文章。其中提供的动画 GIF 和解释是入门的好起点。
Using the Code
使用 SignatureBox
控件非常简单明了。只需将**签名**框添加到您的应用程序中,并使其呈现您想要的形状。CreateImage
方法会根据采样点创建一个图像,无论是否使用**贝塞尔**(取决于 IsBezierEnabled
属性值)。Clear
方法用于清除 SignatureBox
的内容。非常简单,关于这个控件就没有更多可说的了。因此,我将在接下来的两节中详细介绍如何降低控件的采样率以及我的**贝塞尔**实现如何工作。
降低采样率
该算法根据当前点与使用 pictureBox_MouseDown
、pictureBox_MouseMouve
和 pictureBox_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 上的这个动画。共有五个点,构成总共四个灰色的初始线段。

对于点集中的每个线段,使用“t
”(介于 0
和 1
之间)的分数进行线性插值来计算一个新点。此操作使点集减少一个,从而使线段数量减少一个。重复此操作,直到算法只接收到两个点(参见上面 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日:添加了代码片段和大量解释