37

I have a set of points which approximate a 2D curve. I would like to use Python with numpy and scipy to find a cubic Bézier path which approximately fits the points, where I specify the exact coordinates of two endpoints, and it returns the coordinates of the other two control points.

I initially thought scipy.interpolate.splprep() might do what I want, but it seems to force the curve to pass through each one of the data points (as I suppose you would want for interpolation). I'll assume that I was on the wrong track with that.

My question is similar to this one: How can I fit a Bézier curve to a set of data?, except that they said they didn't want to use numpy. My preference would be to find what I need already implemented somewhere in scipy or numpy. Otherwise, I plan to implement the algorithm linked from one of the answers to that question, using numpy: An algorithm for automatically fitting digitized curves (pdf.page 622).

Thank you for any suggestions!

Edit: I understand that a cubic Bézier curve is not guaranteed to pass through all the points; I want one which passes through two given endpoints, and which is as close as possible to the specified interior points.

Rainald62
  • 706
  • 12
  • 19
Craig Baker
  • 481
  • 1
  • 4
  • 7

8 Answers8

24

Here's a way to do Bezier curves with numpy:

import numpy as np
from scipy.special import comb

def bernstein_poly(i, n, t):
    """
     The Bernstein polynomial of n, i as a function of t
    """

    return comb(n, i) * ( t**(n-i) ) * (1 - t)**i


def bezier_curve(points, nTimes=1000):
    """
       Given a set of control points, return the
       bezier curve defined by the control points.

       points should be a list of lists, or list of tuples
       such as [ [1,1], 
                 [2,3], 
                 [4,5], ..[Xn, Yn] ]
        nTimes is the number of time steps, defaults to 1000

        See http://processingjs.nihongoresources.com/bezierinfo/
    """

    nPoints = len(points)
    xPoints = np.array([p[0] for p in points])
    yPoints = np.array([p[1] for p in points])

    t = np.linspace(0.0, 1.0, nTimes)

    polynomial_array = np.array([ bernstein_poly(i, nPoints-1, t) for i in range(0, nPoints)   ])

    xvals = np.dot(xPoints, polynomial_array)
    yvals = np.dot(yPoints, polynomial_array)

    return xvals, yvals


if __name__ == "__main__":
    from matplotlib import pyplot as plt

    nPoints = 4
    points = np.random.rand(nPoints,2)*200
    xpoints = [p[0] for p in points]
    ypoints = [p[1] for p in points]

    xvals, yvals = bezier_curve(points, nTimes=1000)
    plt.plot(xvals, yvals)
    plt.plot(xpoints, ypoints, "ro")
    for nr in range(len(points)):
        plt.text(points[nr][0], points[nr][1], nr)

    plt.show()
cjohnson318
  • 3,154
  • 30
  • 33
reptilicus
  • 10,290
  • 6
  • 55
  • 79
  • 8
    Thank you for the information, but this does not appear to be an algorithm for fitting a Bézier curve, it is an algorithm for evaluating a given Bézier curve. – Craig Baker Apr 23 '13 at 18:09
  • 1) I suppose you mean "a way to do Bezier curves with *Matplotlib*", in contrast e.g. with Tkinter canvas, PyPI bezier 0.8.0, etc. Numpy offers just a method of creating arrays. 2) There's no need for `ntimes=1000`. It's a total waste and inefficient programming. The same result is achieved with `ntimes=50`! (Test it if you don't believe me.) – Apostolos Aug 02 '18 at 20:40
  • You have `return comb(n, i) * ( t**(n-i) ) * (1 - t)**i`. I guess it should be `return comb(n, i) * ( t**i ) * (1 - t)**(n-i)` – Krzysztof Przygodzki Dec 21 '20 at 12:07
21

Here is a piece of python code for fitting points:

'''least square qbezier fit using penrose pseudoinverse
    >>> V=array
    >>> E,  W,  N,  S =  V((1,0)), V((-1,0)), V((0,1)), V((0,-1))
    >>> cw = 100
    >>> ch = 300
    >>> cpb = V((0, 0))
    >>> cpe = V((cw, 0))
    >>> xys=[cpb,cpb+ch*N+E*cw/8,cpe+ch*N+E*cw/8, cpe]            
    >>> 
    >>> ts = V(range(11), dtype='float')/10
    >>> M = bezierM (ts)
    >>> points = M*xys #produces the points on the bezier curve at t in ts
    >>> 
    >>> control_points=lsqfit(points, M)
    >>> linalg.norm(control_points-xys)<10e-5
    True
    >>> control_points.tolist()[1]
    [12.500000000000037, 300.00000000000017]

'''
from numpy import array, linalg, matrix
from scipy.misc import comb as nOk
Mtk = lambda n, t, k: t**(k)*(1-t)**(n-k)*nOk(n,k)
bezierM = lambda ts: matrix([[Mtk(3,t,k) for k in range(4)] for t in ts])
def lsqfit(points,M):
    M_ = linalg.pinv(M)
    return M_ * points

Generally on bezier curves check out Animated bezier and bezierinfo

Roland Puntaier
  • 3,250
  • 30
  • 35
  • This sounds more like what I'm looking for, thank you. I'm trying to understand what the variable `xys` in the commented example should be. The two links are very useful, I'm reading "bezierinfo" and will report back here if it clears things up. – Craig Baker Apr 03 '13 at 21:28
  • It seems that the example contains a few errors. First, the line that starts with `ts = V...` rounds all of the `t`s to integers 0 or 1; adding the argument `dtype='float'` fixes this. Second, the variable `xys` is introduced erroneously, and should be replaced with `points` in all instances, where `points` is an array of data points to fit having shape [t, 2]. Let me know if I'm incorrect. The paper [Bézier curve fitting](http://www.dtic.mil/dtic/tr/fulltext/u2/a350611.pdf) helped me to understand this approach. Accepting answer since it is the only one to address Bézier curve fitting. – Craig Baker Apr 23 '13 at 17:55
  • The rounding for `ts = ` is probably because you use Python 2.x. Sorry for the extraneous `xys`, but you've worked it out all right. – Roland Puntaier Jan 09 '15 at 14:33
  • 2
    Thanks a lot for this - first time I'm looking at it. I would love some more comments in the code as to what the variables are / do if you have time... Ie, if I have a list of points I'm trying to fit, how can I integrate that into what you have – keynesiancross Mar 08 '17 at 19:56
  • As of SciPy 1.0 "comb is deprecated! ... Use scipy.special.comb instead." https://docs.scipy.org/doc/scipy-1.2.1/reference/generated/scipy.misc.comb.html: "comb is deprecated! ... Use scipy.special.comb instead." – Wes Apr 28 '22 at 19:52
15

Resulting Plot

Building upon the answers from @reptilicus and @Guillaume P., here is the complete code to:

  • Get the Bezier Parameters i.e. the control points from a list of points.
  • Create the Bezier Curve from the Bezier Parameters i.e. the control points.
  • Plot the original points, the control points and the resulting Bezier Curve.

Getting the Bezier Parameters i.e. the control points from a set of X,Y points or coordinates. The other parameter needed is the degree for the approximation and the resulting control points will be (degree + 1)

import numpy as np
from scipy.special import comb

def get_bezier_parameters(X, Y, degree=3):
    """ Least square qbezier fit using penrose pseudoinverse.

    Parameters:

    X: array of x data.
    Y: array of y data. Y[0] is the y point for X[0].
    degree: degree of the Bézier curve. 2 for quadratic, 3 for cubic.

    Based on https://stackoverflow.com/questions/12643079/b%C3%A9zier-curve-fitting-with-scipy
    and probably on the 1998 thesis by Tim Andrew Pastva, "Bézier Curve Fitting".
    """
    if degree < 1:
        raise ValueError('degree must be 1 or greater.')

    if len(X) != len(Y):
        raise ValueError('X and Y must be of the same length.')

    if len(X) < degree + 1:
        raise ValueError(f'There must be at least {degree + 1} points to '
                         f'determine the parameters of a degree {degree} curve. '
                         f'Got only {len(X)} points.')

    def bpoly(n, t, k):
        """ Bernstein polynomial when a = 0 and b = 1. """
        return t ** k * (1 - t) ** (n - k) * comb(n, k)
        #return comb(n, i) * ( t**(n-i) ) * (1 - t)**i

    def bmatrix(T):
        """ Bernstein matrix for Bézier curves. """
        return np.matrix([[bpoly(degree, t, k) for k in range(degree + 1)] for t in T])

    def least_square_fit(points, M):
        M_ = np.linalg.pinv(M)
        return M_ * points

    T = np.linspace(0, 1, len(X))
    M = bmatrix(T)
    points = np.array(list(zip(X, Y)))
    
    final = least_square_fit(points, M).tolist()
    final[0] = [X[0], Y[0]]
    final[len(final)-1] = [X[len(X)-1], Y[len(Y)-1]]
    return final

Create the Bezier curve given the Bezier Parameters i.e. control points.

def bernstein_poly(i, n, t):
    """
     The Bernstein polynomial of n, i as a function of t
    """
    return comb(n, i) * ( t**(n-i) ) * (1 - t)**i


def bezier_curve(points, nTimes=50):
    """
       Given a set of control points, return the
       bezier curve defined by the control points.

       points should be a list of lists, or list of tuples
       such as [ [1,1], 
                 [2,3], 
                 [4,5], ..[Xn, Yn] ]
        nTimes is the number of time steps, defaults to 1000

        See http://processingjs.nihongoresources.com/bezierinfo/
    """

    nPoints = len(points)
    xPoints = np.array([p[0] for p in points])
    yPoints = np.array([p[1] for p in points])

    t = np.linspace(0.0, 1.0, nTimes)

    polynomial_array = np.array([ bernstein_poly(i, nPoints-1, t) for i in range(0, nPoints)   ])

    xvals = np.dot(xPoints, polynomial_array)
    yvals = np.dot(yPoints, polynomial_array)

    return xvals, yvals

Sample data used (can be replaced with any data, this is GPS data).

points = []
xpoints = [19.21270, 19.21269, 19.21268, 19.21266, 19.21264, 19.21263, 19.21261, 19.21261, 19.21264, 19.21268,19.21274, 19.21282, 19.21290, 19.21299, 19.21307, 19.21316, 19.21324, 19.21333, 19.21342]
ypoints = [-100.14895, -100.14885, -100.14875, -100.14865, -100.14855, -100.14847, -100.14840, -100.14832, -100.14827, -100.14823, -100.14818, -100.14818, -100.14818, -100.14818, -100.14819, -100.14819, -100.14819, -100.14820, -100.14820]
for i in range(len(xpoints)):
    points.append([xpoints[i],ypoints[i]])

Plot the original points, the control points and the resulting Bezier Curve.

import matplotlib.pyplot as plt
# Plot the original points
plt.plot(xpoints, ypoints, "ro",label='Original Points')
# Get the Bezier parameters based on a degree.
data = get_bezier_parameters(xpoints, ypoints, degree=4)
x_val = [x[0] for x in data]
y_val = [x[1] for x in data]
print(data)
# Plot the control points
plt.plot(x_val,y_val,'k--o', label='Control Points')
# Plot the resulting Bezier curve
xvals, yvals = bezier_curve(data, nTimes=1000)
plt.plot(xvals, yvals, 'b-', label='B Curve')
plt.legend()
plt.show()
Aklelka
  • 221
  • 2
  • 4
  • 1
    This exactly what I was looking for, thanks @Aklelka ! – ponadto Jan 09 '22 at 12:28
  • Thank you so much for the code! .... I noticed the code consider 2D trajectories, I am interested in n-dimensional ones. Would you have an extended version? I will be working on it otherwise – leandro souza rosa May 11 '23 at 16:15
8

@keynesiancross asked for "comments in [Roland's] code as to what the variables are" and others completely missed the stated problem. Roland started with a Bézier curve as input (to get a perfect match), which made it harder to understand both the problem and (at least for me) the solution. The difference from interpolation is easier to see for input that leaves residuals. Here is both paraphrased code and non-Bézier input -- and an unexpected outcome.

import matplotlib.pyplot as plt
import numpy as np
from scipy.special import comb as n_over_k
Mtk = lambda n, t, k: t**k * (1-t)**(n-k) * n_over_k(n,k)
BézierCoeff = lambda ts: [[Mtk(3,t,k) for k in range(4)] for t in ts]

fcn = np.log
tPlot = np.linspace(0. ,1. , 81)
xPlot = np.linspace(0.1,2.5, 81)
tData = tPlot[0:81:10]
xData = xPlot[0:81:10]
data = np.column_stack((xData, fcn(xData))) # shapes (9,2)

Pseudoinverse = np.linalg.pinv(BézierCoeff(tData)) # (9,4) -> (4,9)
control_points = Pseudoinverse.dot(data)     # (4,9)*(9,2) -> (4,2)
Bézier = np.array(BézierCoeff(tPlot)).dot(control_points)
residuum = fcn(Bézier[:,0]) - Bézier[:,1]

fig, ax = plt.subplots()
ax.plot(xPlot, fcn(xPlot),   'r-')
ax.plot(xData, data[:,1],    'ro', label='input')
ax.plot(Bézier[:,0],
        Bézier[:,1],         'k-', label='fit')
ax.plot(xPlot, 10.*residuum, 'b-', label='10*residuum')
ax.plot(control_points[:,0],
        control_points[:,1], 'ko:', fillstyle='none')
ax.legend()
fig.show()

This works well for fcn = np.cos but not for log. I kind of expected that the fit would use the t-component of the control points as additional degrees of freedom, as we would do by dragging the control points:

manual_points = np.array([[0.1,np.log(.1)],[.27,-.6],[.82,.23],[2.5,np.log(2.5)]])
Bézier = np.array(BézierCoeff(tPlot)).dot(manual_points)
residuum = fcn(Bézier[:,0]) - Bézier[:,1]

fig, ax = plt.subplots()
ax.plot(xPlot, fcn(xPlot),   'r-')
ax.plot(xData, data[:,1],    'ro', label='input')
ax.plot(Bézier[:,0],
        Bézier[:,1],         'k-', label='fit')
ax.plot(xPlot, 10.*residuum, 'b-', label='10*residuum')
ax.plot(manual_points[:,0],
        manual_points[:,1],  'ko:', fillstyle='none')
ax.legend()
fig.show()

The cause of failure, I guess, is that the norm measures the distance between points on the curves instead of the distance between a point on one curve to the nearest point on the other curve.

Rainald62
  • 706
  • 12
  • 19
2

Short answer: you don't, because that's not how Bezier curves work. Longer answer: have a look at Catmull-Rom splines instead. They're pretty easy to form (the tangent vector at any point P, barring start and end, is parallel to the lines {P-1,P+1}, so they're easy to program, too) and always pass through the points that define them, unlike Bezier curves, which interpolates "somewhere" inside the convex hull set up by all the control points.

Mike 'Pomax' Kamermans
  • 49,297
  • 16
  • 112
  • 153
  • 2
    Thank you for the response, but I'm not looking for a curve that exactly passes through each point in the set, I'm looking for the Bezier curve which approximately fits the points. – Craig Baker Sep 10 '13 at 21:57
  • sorry, which is it: "exactly", or "approximately"? The answer you picked is a linear regression polynomial fitting. That's an approximate curve. If you need exact, unless you have only as many points as the curve order you need, getting a true Bezier curve is almost guaranteed impossible, unless you want a poly-Bezier curve, in which case you can just do piecewise curve fitting, and then a catmull rom split is far more useful (and converts to, and from, a poly-Bezier curve) – Mike 'Pomax' Kamermans Sep 11 '13 at 00:52
  • 2
    Approximately. I understand that a single cubic bezier is not guaranteed to be able pass through more than three given points. I'm not sure why I've gotten several responses restating this information, I tried to make it clear in the original question but maybe I was unclear. – Craig Baker Sep 12 '13 at 03:33
  • 1
    To say you can't use Bézier to approximate a set of points is incorrect. Any polynomial curve can be expressed as a set of Bezier control points ... – PierreBdR May 04 '16 at 12:32
  • @PierreBdR it would be, but no one said that. It's impossible to *exactly* match if you have more points, so if Craig needed exact fitting, Beziers would not have been the right choice. What was said instead was that approximation was the *only* choice. – Mike 'Pomax' Kamermans May 04 '16 at 16:27
  • 1
    @Mike'Pomax'Kamermans That's exactly what you wrote in your short answer! Fitting a curve to a set of points means to approximate, not to exactly match the points. – PierreBdR May 10 '16 at 13:48
  • I certainly did not, and it certainly doesn't. Curve fitting can be either exact or approximate. An exact fit will pass through every point in your dataset (any same-or-higher-order-than-there-are-points polynomial regression will find an exact fit), and an approximate fit will not overlap at least one data point. "Curve fitting" is an approach, with two possible results. – Mike 'Pomax' Kamermans May 10 '16 at 16:23
1

A Bezier curve isn't guaranteed to pass through every point you supply it with; control points are arbitrary (in the sense that there is no specific algorithm for finding them, you simply choose them yourself) and only pull the curve in a direction.

If you want a curve which will pass through every point you supply it with, you need something like a natural cubic spline, and due to the limitations of those (you must supply them with increasing x co-ordinates, or it tends to infinity), you'll probably want a parametric natural cubic spline.

There are nice tutorials here:

Cubic Splines

Parametric Cubic Splines

michael.orchard
  • 486
  • 4
  • 16
  • 4
    Thank you for the information, but I must not have specified the question clearly enough. I understand that a cubic Bézier curve is not guaranteed to pass through all the points; I want one which passes through two given endpoints, and which is as close as possible to the specified interior points. – Craig Baker Sep 28 '12 at 18:47
1

I had the same problem as detailed in the question. I took the code provided Roland Puntaier and was able to make it work. Here:

def get_bezier_parameters(X, Y, degree=2):
    """ Least square qbezier fit using penrose pseudoinverse.

    Parameters:

    X: array of x data.
    Y: array of y data. Y[0] is the y point for X[0].
    degree: degree of the Bézier curve. 2 for quadratic, 3 for cubic.

    Based on https://stackoverflow.com/questions/12643079/b%C3%A9zier-curve-fitting-with-scipy
    and probably on the 1998 thesis by Tim Andrew Pastva, "Bézier Curve Fitting".
    """
    if degree < 1:
        raise ValueError('degree must be 1 or greater.')

    if len(X) != len(Y):
        raise ValueError('X and Y must be of the same length.')

    if len(X) < degree + 1:
        raise ValueError(f'There must be at least {degree + 1} points to '
                         f'determine the parameters of a degree {degree} curve. '
                         f'Got only {len(X)} points.')

    def bpoly(n, t, k):
        """ Bernstein polynomial when a = 0 and b = 1. """
        return t ** k * (1 - t) ** (n - k) * comb(n, k)

    def bmatrix(T):
        """ Bernstein matrix for Bézier curves. """
        return np.matrix([[bpoly(degree, t, k) for k in range(degree + 1)] for t in T])

    def least_square_fit(points, M):
        M_ = np.linalg.pinv(M)
        return M_ * points

    T = np.linspace(0, 1, len(X))
    M = bmatrix(T)
    points = np.array(list(zip(X, Y)))
    return least_square_fit(points, M).tolist()

To fix the end points of the curve, ignore the first and last parameter returned by the function and use your own points.

0

What Mike Kamermans said is true, but I also wanted to point out that, as far as I know, catmull-rom splines can be defined in terms of cubic beziers. So, if you only have a library that works with cubics, you should still be able to do catmull-rom splines:

David Sanders
  • 4,069
  • 1
  • 24
  • 38