曲线(笔迹)简化算法
场景是这样的,在一端进行书写,另一端还原书写的笔迹。要实现笔迹的还原,就得记录笔迹经过的轨迹点,当然这些都是可以在书写过程获取到的。但是问题在于,笔迹中的轨迹点比较多,数据量比较大,不便于传输。因此,我们需要通过某种算法,剔除某些不重要的点,从而减少数据量。
下面的代码是在
.NET Framework
框架下编写的,引用了System.Windows.Media
命名空间中的一些类型。大多是数学算法,可以轻松地转换为其它平台的代码。
径向距离算法
径向距离算法
非常简单,如果笔迹中的相邻两点的距离小于设定的阈值,则舍去其中一个点。首先,以笔迹中的第一个点为基准点,计算第二个点与第一个点之间的距离,如果此距离小于给定的阈值,则舍去第二个点;如果此距离大于给定的阈值,则保留第二个点,并以其为新的基准点。然后再拿第三个点来与基准点判断距离,如此向后循环。
public override PointCollection Filter(PointCollection points)
{
if (points.Count < 2)
{
return new PointCollection(points);
}
else
{
var previousPoint = points[0];
var newPoints = new PointCollection() { previousPoint };
// Tolerance 是容差值,用距离的平方来判断相邻点的距离
var toleranceSquared = Tolerance * Tolerance;
for (var i = 1; i < points.Count; i++)
{
var point = points[i];
if ((point - previousPoint).LengthSquared < toleranceSquared)
{
newPoints.Add(point);
previousPoint = point;
}
}
// 确保最后一点在过滤后的点集中,这样还原效果会更佳
if (newPoints.Last() != points.Last())
{
newPoints.Add(points.Last());
}
return newPoints;
}
}public override PointCollection Filter(PointCollection points)
{
if (points.Count < 2)
{
return new PointCollection(points);
}
else
{
var previousPoint = points[0];
var newPoints = new PointCollection() { previousPoint };
// Tolerance 是容差值,用距离的平方来判断相邻点的距离
var toleranceSquared = Tolerance * Tolerance;
for (var i = 1; i < points.Count; i++)
{
var point = points[i];
if ((point - previousPoint).LengthSquared < toleranceSquared)
{
newPoints.Add(point);
previousPoint = point;
}
}
// 确保最后一点在过滤后的点集中,这样还原效果会更佳
if (newPoints.Last() != points.Last())
{
newPoints.Add(points.Last());
}
return newPoints;
}
}
曲率变化算法
曲率变化法即判断每个点处的曲率变化是否较大,如果大过了阈值,则认为此点为关键点,需要保留。假设要判断 P2
点处的曲率,则取其前后两个相邻点 P1
和 P3
,并构建向量 P1P2
和 P2P3
,然后计算这两个向量间的夹角。如果夹角大于指定的阈值,则认为 P2
点的曲率较大。
为了后续讨论的方便,我们暂且称 P1P2
为 P2
点的 前置向量
,P2P3
为 P2
点的后置向量。
如果第 P2
点被保留了,那么继续向后判断 P3
点的曲率情况,分别取 P2P3
和 P3P4
为 P3
点的 前置向量
和 后置向量
,然后向上面那样判断,如是循环。
但是,如果 P2
点被剔除了,那么我们在判断 P3
点的曲率时应该如何取 前置向量
呢?此处有三种取法:P1P2
、P1P3
、P2P3
,无论哪种取法,P3
的后置向量都是 P3P4
。这三种取法各有利弊,前两者效果较好,后面一种处理平滑曲线的能力较弱。
以上三种 前置向量
取法的代码差不多,下面给大家展示一下 1-3-4
曲率变化算法的代码:
public override PointCollection Filter(PointCollection points)
{
if (points.Count < 3)
{
return new PointCollection(points);
}
else
{
var point1 = points[0];
var newPoints = new PointCollection() {point1};
for (int i = 1; i < points.Count - 2; i++)
{
var point2 = points[i];
var point3 = points[i + 1];
var vector1 = point2 - point1;
var vector2 = point3 - point2;
if (IsAngleTolerable(vector1, vector2))
{
// 不是特征拐点,舍去之
}
else
{
newPoints.Add(point2);
point1 = point2;
}
}
newPoints.Add(points.Last());
return newPoints;
}
}
protected bool IsAngleTolerable(Vector vector1, Vector vector2)
{
return Math.Abs(Vector.AngleBetween(vector2, vector1)) < Tolerance;
}
道格拉斯-普克算法
最后给大家介绍 道格拉斯-普克
提出的一种迭代适应点算法,它将曲线近似表示为一系列点,并减少点的数量。算法的基本思路是:对每一条曲线的首末点虚连一条直线,求所有点与直线的距离,并找出最大距离值 dmax
,用 dmax
与阈值 D
相比。若 dmax < D
,这条曲线上的中间点全部舍去;若 dmax ≥ D
,则保留 dmax
对应的坐标点,并以该点为界,把曲线分为两部分,对这两部分循环使用该方法。
整个过程如下动图所示:
public override PointCollection Filter(PointCollection points)
{
if (points.Count < 2)
{
return new PointCollection(points);
}
else
{
int? first = 0;
int? last = points.Count - 1;
var markers = new bool[points.Count];
markers[first.Value] = markers[last.Value] = true;
var index = 0;
var stack = new Stack<int>();
var newPoints = new PointCollection();
while (last.HasValue && first.HasValue)
{
var maxDistance = 0.0d;
for (var i = first.Value + 1; i < last.Value; i++)
{
var distance = GetPointToSegmentDistance(points[i], points[first.Value], points[last.Value]);
if (distance > maxDistance)
{
index = i;
maxDistance = distance;
}
}
if (maxDistance > Tolerance * Tolerance)
{
markers[index] = true;
stack.Push(first.Value);
stack.Push(index);
stack.Push(index);
stack.Push(last.Value);
}
if (stack.Count > 0)
{
last = stack.Pop();
}
else
{
last = null;
}
if (stack.Count > 0)
{
first = stack.Pop();
}
else
{
first = null;
}
}
for (var i = 0; i < markers.Length; i++)
{
if (markers[i])
{
newPoints.Add(points[i]);
}
}
return newPoints;
}
}
/// <summary>
/// 计算点到线段的距离。
/// </summary>
/// <param name="p">需要计算距离的点</param>
/// <param name="p1">线段的起点</param>
/// <param name="p2">线段的终点</param>
/// <returns></returns>
private static double GetPointToSegmentDistance(Point p, Point p1, Point p2)
{
// 根据向量叉积的定义来推算点到线段的距离
// (a cross b) = |a||b|sin(θ) => |a|sin(θ) = (a cross b)/|b|
var a = p - p1;
var b = p2 - p1;
if (b.LengthSquared < double.Epsilon)
{
return a.LengthSquared;
}
var distance = Vector.CrossProduct(a, b) / b.Length;
return Math.Pow(distance, 2);
}
算法对比
对比三种算法来看,道格拉斯-普克法
的时间复杂度和空间复杂度要明显高于其它两种,但是它的效果较好,剔除率和剔除后的符合度均较高。在 曲率变化算法
算法中,从 前置向量
的效果来看, 1-3-4
> 1-2-4
> 2-3-4
。在我们的项目应用中,如果对时间没较大要求,可以先进行 径向直径
算法,然后再使用 曲线变化率算法
或 道格拉斯-普克法
中的一种。
最后,为了尽可能地减少数据量,可以将阈值设大一些,在还原时可以采用贝塞尔算法来进行拟合,可参考:一种简单的贝塞尔拟合算法 。