Skip to content

Knot vectors synthesized for curve fitting are biased toward zero #120

@GarthSnyder

Description

@GarthSnyder

I've been noticing that some of the splines produced by fitting.approximate_curve seem to have a little hump at one end. Here, for dramatic effect, is a particularly striking example. This is what the input points actually look like:

Screen Shot 2021-02-02 at 5 19 49 PM

And this is the fit:

Screen Shot 2021-02-02 at 5 23 00 PM

I'm showing it in Fusion 360 because it gives a nice view of the control point cage (the dashed orange lines), but you should be able to reproduce it in any visualizer. (The built-in visualizer for NURBS-Python picks a viewpoint that doesn't really show off the issue.)

import geomdl
import geomdl.fitting as fit

points = [
  [-13.132, 21.389, -2.51],
  [-13.065, 21.306, -2.539],
  [-13, 21.225, -2.577],
  [-12.941, 21.151, -2.633],
  [-12.889, 21.088, -2.707],
  [-12.848, 21.038, -2.794],
  [-12.821, 21.001, -2.892],
  [-12.814, 20.992, -2.946],
  [-12.812, 20.989, -3],
  [-12.809, 20.986, -3.055],
  [-12.802, 20.977, -3.11],
  [-12.774, 20.939, -3.21],
  [-12.731, 20.887, -3.298],
  [-12.678, 20.822, -3.371],
  [-12.617, 20.747, -3.428],
  [-12.551, 20.664, -3.465],
  [-12.481, 20.58, -3.492]  
]

curve = fit.approximate_curve(points, degree=3)

The artifacts are reminiscent of the curve degree being too high for the context, but even degree=2 shows the same problem. (degree=1 shows a proper piecewise linear fit.)

I cross-checked this against the fitting of Splipy, and given the same data points and knot vector, Splipy comes up with identical control points. That makes me suspect the knot vector generated by geomdl. I would cross-check to see what knots Splipy comes up with, but as far as I can tell, Splipy wants you to supply the knots.

After looking into this a bit, I think there may be a problem with the way compute_knot_vector2 is interpolating the u-sub-k parameters (that is, the relative positions between 0 and 1 of the actual data points along the hypothetical curve). Here's the original source code for reference:

def compute_knot_vector2(degree, num_dpts, num_cpts, params):
    """ Computes a knot vector ensuring that every knot span has at least one :math:`\\overline{u}_{k}`.

    Please refer to the Equations 9.68 and 9.69 on The NURBS Book (2nd Edition), p.412 for details.

    :param degree: degree
    :type degree: int
    :param num_dpts: number of data points
    :type num_dpts: int
    :param num_cpts: number of control points
    :type num_cpts: int
    :param params: list of parameters, :math:`\\overline{u}_{k}`
    :type params: list, tuple
    :return: knot vector
    :rtype: list
    """
    # Start knot vector
    kv = [0.0 for _ in range(degree + 1)]

    # Compute "d" value - Eqn 9.68
    d = float(num_dpts) / float(num_cpts - degree)
    # Find internal knots
    for j in range(1, num_cpts - degree):
        i = int(j * d)
        alpha = (j * d) - i
        temp_kv = ((1.0 - alpha) * params[i - 1]) + (alpha * params[i])
        kv.append(temp_kv)

    # End knot vector
    kv += [1.0 for _ in range(degree + 1)]

    return kv

Thanks to your wonderful and thorough references, I was able to look up the relevant section of The NURBS Book 2E for comparison. The code does not follow the book; however, that seems understandable because I'm not sure I believe the book on this, starting with the mysterious statement that "we need a total of n + p + 2 knots" with code to match. Is the correct number not n + p + 1? Evidently, you or whoever wrote the original original contribution didn't believe the book either.

But I think the code may be wrong, too. It biases the knots toward zero, resulting in an artificially small first interval. I think that's what's causing the contortions in the fitted splines.

OK, a thought experiment. (My apologies -- this is going to sound like I'm lecturing the author of a NURBS package about basic spline arithmetic, but these details are new to me and I just want to write them down to be sure I'm grasping them.)

Suppose we have 11 data points that are completely evenly spaced and we want 10 control points for a degree 3 spline. There are 7 knot spans and 14 entries in the knot vector, of which 8 are 0s or 1s. We need to generate 6 intermediate knots because the 0s already initiate the first knot span. (The 1s terminate; they don't introduce a new span.) The u-sub-ks are:

>>> params = np.linspace(0, 1, 11)
array([0. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1. ])

In the current code, num_dpts is 11, num_cpts is 10, and degree is 3, so d = 11 / 7 = 1.57. When j = 1, i = 1 and alpha = 0.57. The start of the second knot span is computed as (1 - 0.57) * params[0] + 0.57 * params[1] = 0.43 * 0 + 0.57 * 0.1 = 0.057. Not obviously implausible, right?

But let's look at the last knot when j = num_cpts - degree - 1 = 6. Then d = 9.42 and the last knot span starts at (1 - 0.42) * 0.8 + 0.42 * 0.9 = 0.842. So the last knot span is almost three times larger than the first, even though the params vector is uniform.

I think there are two issues. First, I don't understand why The NURBS Book uses params[i-1] and params[i] rather than params[i] and params[i+1], a convention the code also follows. 1.57 is an address between the second and third data points, not between the first and second, no? Clearly, it should include at least one full data point. However, fixing this just shifts the squeezed area from the front of the knot vector to the end, so there's a scaling issue in there somewhere, too.

I suspect that the numerator of d should be not num_dpts but num_dpts - 1. A set of 11 numbers has only an interval of 10 to distribute. The name of the 11th element is not params[11] but params[10]. Think of it as working in list/array addressing space.

Together, these two changes generate a nice, symmetrical knot vector from the nice, symmetrical params:

num_dpts = 11
num_cpts = 10
degree = 3
params = np.linspace(0, 1, 11)

d = (num_dpts - 1) / (num_cpts - degree)

def knot(d, j):
    i = int(j * d)
    alpha = (j * d) - i
    return (1 - alpha) * params[i] + alpha * params[i + 1]

[knot(d, j) for j in range(1, num_cpts - degree)]

[0.14285714285714288,
 0.28571428571428575,
 0.42857142857142855,
 0.5714285714285715,
 0.7142857142857144,
 0.8571428571428572]

I will do some additional tests tomorrow. If they look reasonable, would you be interested in a PR for this?

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThere is a problem with the code or documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions