两种 2D 折线(Polyline)平滑算法【C#】【VB.NET】

引言

当使用地图 (GIS) 或图表数据时,您将拥有2D点、线、折线和多边形等形状的对象。这些物体有很多不同的名字:形状、路径、面积、区域等等。在这篇文章中,我们把点定义为单个的 (x,y) 坐标对,即顶点,简单标记为 P;直线被定义为以起点 P_{1} 和终点 P_{2} 为顶点对);多个顶点(从 P_{1}P_{n} ,其中n为> 2)组成一条折线。在折线中,顶点按它们在集合中出现的顺序连接;多边形就是一个简单的折线,其中的起始点连接到终止点或是一个闭合的折线。参见下面的图以获得图形化的解释。

多边形

 在绘制这些对象时,通常需要平滑折线的顶点。我意识到 GDI 有 DrawCurve、DrawBezier 方法,但本文将稍微深入地研究平滑折线的生成问题,它将展示如何处理超过一定数量(DrawBezier 方法使用的是 4)的顶点以及如何调整“光滑”的问题。

 平滑折线可以通过两种方式:

  1.  通过插值,即在新的平滑曲线上,原始的折线点仍保持不变;
  2.  通过近似,这意味着新的平滑曲线将近似原始的折线,但原始点不会被保留下来。

第一个也称为基数样条或正则样条,第二个通常由二次或三次贝塞尔曲线求解。请再次参阅下一个图,以获得图形化的解释。

平滑折线的方法

 网上有很多关于样条曲线,贝塞尔曲线等的信息。显而易见的起点: Wikipedia on Splines 和一个特别喜欢的博客: Splines and curves part I – Bézier Curves by Martin Doms.。在 Codeproject 上有一篇优秀的深度文章:Spline Interpolation - history, theory and implementation ,作者是 Kenneth Haugland (顺便说一句,我就是他所说的懒程序员的典型例子)。样条曲线和贝塞尔曲线非常强大,因为它们是参数化的:它们表现为一个数学函数,可以用来估计、近似或插值原始数据点之间的数据点。事实上,它们可以作为数据点的线性或多项式回归的一种替代方法。-

然而,在为绘图而平滑折线时,您并不一定对底层函数感兴趣,而是更关注性能和使用的简单性。使用样条有一个缺点,即在分段方法中一次使用 3 或 4 个控制点或顶点。如果你想把它应用到整体的一系列点上你必须把这些一条条曲线连接在一起。这是可以做到的,而且已经做到了,但它很容易变得复杂。

本文将展示如何使用一个简单的样条插值的方法,称为 Catmull-Rom 样条一个非样条近似计算的方法,由 Chaikin 发明,称为 ChaikinCornerCutting  算法。关于后一种方法的一篇可读性很强的文章见: Chaikin’s Algorithms for Curves ( Kenneth I. Joy 所作)。

代码

首先,很容易使用 Double 类型的 X 和 Y 来定义 Point类,而且还可以为这个类定义标准的数学运算符,以便能够对 PointC = PointA * PointB 进行编码。这是我的课堂重点:

Public Class PointD
    
    Public Sub New()
    End Sub
    
    Public Sub New(nx As Double, ny As Double)
        X = nx
        Y = ny
    End Sub
    
    Public Sub New(p As PointD)
        X = p.X
        Y = p.Y
    End Sub
    
    Public X As Double = 0
    Public Y As Double = 0
    
    Public Shared Operator +(p1 As PointD, p2 As PointD) As PointD
        Return New PointD(p1.X+p2.X,p1.Y+p2.Y)
    End Operator
    
    Public Shared Operator +(p As PointD, d As Double) As PointD
        Return New PointD(p.X+d,p.Y+d)
    End Operator
    
    Public Shared Operator +(d As Double, p As PointD) As PointD
        Return p+d
    End Operator
    
    'and similar operators for "-", "*" and "/"
    '...
    
End Class

源文件中我提供了完整的类定义。其次,当使用折线绘图时,我更喜欢使用 System.Windows.FormsDataVisualization 命名空间中的 Chart Windows forms control 控件。,因为它 .net 4 是内置的。这些都是在提供下载的项目源代码中设置了。

1. Catmull-Rom 插值

在网上有许多关于编码 Catmull-Rom 样条的例子,例如我在上面提到的 Codeproject 的 Kenneth Haugland 的文章。我把代码从 Nice Curves! 并改编成:

Private Function getSplineInterpolationCatmullRom(points As List(Of PointD), nrOfInterpolatedPoints As Integer) As List(Of PointD)
    Try
        'Catmull-Rom 样条需要至少 4 个点,因此从 3 个点进行外插是可以的,但不能从 2 个点进行外插,因为你会得到一条直线
        If points.Count < 3 Then Throw New Exception("Catmull-Rom Spline requires at least 3 points")
        
        '可以在接下来抛出一个错误,但它是很容易隐式地进行修正
        If nrOfInterpolatedPoints < 1 Then nrOfInterpolatedPoints = 1
        
        '第 1 部分
        '创建一个新的点列表来进行样条插值,如果不这样做,原始的点列表就会被扩展成外插过的点集
        Dim spoints As New List(Of PointD)
        For Each p As PointD In points
            spoints.Add(New PointD(p))
        Next
        
        '第一个点和最后一个点总是需要外插
        Dim dx As Double = spoints(1).X-spoints(0).X
        Dim dy As Double = spoints(1).Y-spoints(0).Y
        spoints.Insert(0,New PointD(spoints(0).X-dx,spoints(0).Y-dy))

        dx = spoints(spoints.Count-1).X-spoints(spoints.Count-2).X
        dy = spoints(spoints.Count-1).Y-spoints(spoints.Count-2).Y
        spoints.Insert(spoints.Count, _
            New PointD(spoints(spoints.Count-1).X+dx,spoints(spoints.Count-1).Y+dy))
        
        '第 2 部分
        '注意 nrOfInterpolatedPoints 作为一个用于归一化的张力因子(两点线段之间插值的点数)
        Dim t As Double = 0
        Dim spoint As PointD
        Dim spline As New List(Of PointD)
        
        For i As Integer = 0 To spoints.Count-4
            spoint = New PointD()
            For intp As Integer = 0 To nrOfInterpolatedPoints-1

                ' t 被定义为基于需要插值的点数,在 P2、P3 之间所定义的分式(比例)
                t = 1/nrOfInterpolatedPoints*intp
                
                spoint = 0.5*( _
                    2 * spoints(i+1) + (-1 * spoints(i) + spoints(i+2)) * t + _
                    (2 * spoints(i) - 5 * spoints(i+1) + 4 * spoints(i+2) - spoints(i+3)) * t^2 + _
                    (-1 * spoints(i) + 3 * spoints(i+1) - 3 * spoints(i+2) + spoints(i+3)) * t^3)
                
                spline.Add(New PointD(spoint))
            Next
            
        Next
        
        '第 3 部分
        '添加最后一个点,但是跳过插入的最后一个点,所以是倒数第二个
        spline.Add(spoints(spoints.Count-2))
        Return spline
    Catch exc As Exception
        'Debug.Print(exc.ToString)
        Return Nothing
    End Try
End Function

我所做的修改如下:

1. 参见上面代码部分的第 1 部分:在遍历折线顶点通过插值进行平滑之前,我创建了一个从折线 P_{1}P_{0} 的外插值点,并将其插入原始点列表 (即 spoints As New List(PointD)) 副本的开头。类似的,将从 P_{n}P_{n+1} 的外插值点插入到 spoint 的末尾。我这样做的原因是,Catmull-Rom 算法是在 P_{2}  和 P_{3} 之间的样条上(从原始分段的折线上依据 4 个点)计算点 P_{t}。通过外插原始折线的第一个和最后一个点,我将在平滑插值输出中保留这些点。

2.参见上面代码部分的第2部分:P_{t} 的实际计算数量取决于所需的内插点的数量。t 是 P_{2} 和 P_{3} 之间的归一化距离,即 [0, 1],其中插值点 P_{t} 的坐标是通过样条函数计算出来的。因此,外部的 For 循环通过迭代 spoints,每次从中取出 4 个点来;而内部的 For 循环依据上一步取出的 4 个点来计算在 t = 1/nrOfInterpolatedPoints*intp 时的插入点,其中 intp 表示从 P_{2}  到 P_{3} 插值的点数,1/nrOfInterpolatedPoints 表示步长。举个栗子:如果需要在 P_{2} 和 P_{3} 之间计算三个新插入点,则它们的归一化距离分别为 t = 1/3*0 = 0, 1/3*1 = 0.3333, 1/3*2 = 0.6667,计算出新的点 spoint 并将其添加到新的点列表 spline 的末尾。注意,输入参数可以看作一种平滑度:在两个原始顶点之间插入的点越多,中间的线就越平滑。然而,如果达到了一定最佳平滑程度将不再受该参数影响。

上面代码部分的第 3 部分的最后一步将最后一个点添加到新的 spline 点列表中。注意,所谓的 “最后一个点” 是 spoints 点列表的倒数第二个(即 spoint.count-2 )点,因为我们不想添加第 1 部分中推断出来的最后一个点。下面的两幅图展示了带有不同数量插值点的 Catmull-Rom 样条。

nrOfInterpolatedPoints=2
nrOfInterpolatedPoints=10

2. Chaikin 近似

有时你不希望在折线的顶点上插值点,因为它可能是有噪声的、锯齿状的、抖动的或者有几个奇怪的离群点。在这种情况下,需要一个近似值。当然,你可以使用样条逼近,但有一个更简单,使用更少的数学计算的方法称为 Corner Cutting 。在我上面引用的 Kenneth I. Joy 的文章中,有如下算法示意图:

从折线的每个线段上,在距离起始点的 1/4 处和距离终点的 3/4 处定义一个新的线段。如此反复几次,你的折线将会在原始点之间平滑地近似。简单又优雅~

注意

  1. 该方法不是参数意义上的,你可以在新的光滑折线的每个点上继续计算;
  2. 从新的折线上,起始点 P_{0} 和终点 P_{4} 将会被切除(删除)。

这个代码相对简单,我又做了一些修改:

Private Function getCurveSmoothingChaikin(points As List(Of PointD), tension As Double, nrOfIterations As Integer) As List(Of PointD)
    '检查
    If points Is Nothing OrElse points.Count < 3 Then Return Nothing
    
    If nrOfIterations < 1 Then nrOfIterations = 1
    
    If tension < 0 Then
        tension = 0
    ElseIf tension > 1
        tension = 1
    End If
    
    '张力因子定义了线段半长切角距离的一个尺度,即在0.05 ~ 0.45之间。相反的角点将被相反的方向(即1个切割距离)切割以保持对称。当张力值为0.5时,这等于原来的 Chaikin 值0.25 = 1/4和0.75 = 3/4

    Dim cutdist As Double = 0.05 + (tension*0.4)
    
    '复制点列表并迭代它
    Dim nl As New List(Of PointD)
    For i As Integer = 0 To points.Count-1
        nl.Add(New PointD(points(i)))
    Next
    
    For i As Integer = 1 To nrOfIterations
        nl = getSmootherChaikin(nl,cutdist)
    Next
    
    Return nl
End Function

Private Function getSmootherChaikin(points As List(Of PointD), cuttingDist As Double) As List(Of PointD)
    Dim nl As New List(Of PointD)
    
    '总是添加第一个点
    nl.Add(New PointD(points(0)))
    
    Dim q, r As PointD
    
    For i As Integer = 0 To points.Count-2
        q = (1-cuttingDist)*points(i) + cuttingDist*points(i+1)
        r = cuttingDist*points(i) + (1-cuttingDist)*points(i+1)
        nl.Add(q)
        nl.Add(r)
    Next
    
    '总是添加最后一个点
    nl.Add(New PointD(points(points.Count-1)))
    
    Return nl
End Function

我所作的修改如下:

1. 代码由包装器 getCurveSmoothingChaikin() 组成,它定义了从角点开始的切割距离或 cutdist。在 Chaikin 算法中,是0.25 和 0.75 的相对距离。我把它转换成一个 0 、1之间的张力因子 tension,通过计算 0.05+(tension*0.4) 定义了一个在 0.05 和0.45 之间的相对距离 cutdist,然后与之相对的补角就是1-cutdistt=0.5 的值表示原始 Chaikin 算法切割距离 0.25。

2. 第二个修改是迭代方法 getSmootherChaikin(),在这个方法中,我可以将折线的初始起点和终点添加到新的平滑折线中。

通过上述方法,生成的折线的平整度和原折线边角处的张力都是可控的。下面三幅图展示了 Chaikin 近似算法的迭代和张力变化的例子。

迭代一次,tension=0.25
迭代3次,tension=0.5
迭代 10 次,tension=0.9

最后,接下来的两幅图显示,这两种方法都可以通过类似的方式应用于一个闭合的折线(或多边形)。注意插值和近似的区别。敏锐的读者会注意到闭合多边形的起点和终点(即同一点)没有被平滑。您可以通过检查第一个点和最后一个点是否重叠来解决这个问题,如果重叠了,那么在复制点列表时,就在该点周围插入一条线段。

应用 Catmull-Rom 算法的闭合多边形
应用 Chaikin 算法的闭合多边形

上面的小Windows应用程序可以在提供的源文件中找到。

编码快乐!

英文原文链接

VB.NET 版

C# 版 Github 地址


附: 

我写 C++ 版的 Catmull-Rom 和其他两种算法的效果比较(设置不同粗细是为了方便比较形状):

蓝色:3次 Beizer,绿色:B-Spline,红色:Catmull-Rom
发布了233 篇原创文章 · 获赞 221 · 访问量 106万+

猜你喜欢

转载自blog.csdn.net/panda1234lee/article/details/103966189