How to fit a closed contour?

Question:

I have a data that represents a closed contour (with noise):

contour = [(x1, y1), (x2, y2), ...]

Is there any simple way to fit the contour? There is the numpy.polyfit function. But it fails if x values are repeated and requires some effort to determine an adequate degree of the polynomial.

Asked By: Alex

||

Answers:

If you fix you polynomial degree you can simply use the leastsq function from scipy.optimize

let say that you generate a simple circle. I will divide it into its x and y component

data = [ [cos(t)+0.1*randn(),sin(t)+0.1*randn()] for t in rand(100)*2*np.pi ]
contour = array(data)
x,y = contour.T

the write a simple function that evaluate the difference of each point from the 0 given the coefficients of the polynomial. We are fitting the curve as an circle centered on the origin.

def f(coef):
    a = coef
    return a*x**2+a*y**2-1

We can simply use the leastsq function to find the best coefficients.

from scipy.optimize import leastsq
initial_guess = [0.1,0.1]
coef = leastsq(f,initial_guess)[0]
# coef = array([ 0.92811554])

I take only the first element of the returned tuple because the leastsq return a lot of other information that we don’t need.

if you need to fit a more complicated polynomial, for example an ellipse with a generic center, you can simply use a more complicated function:

def f(coef):
    a,b,cx,cy = coef
    return a*(x-cx)**2+b*(y-cy)**2-1

initial_guess = [0.1,0.1,0.0,0.0]
coef = leastsq(f,initial_guess)[0]
# coef = array([ 0.92624664,  0.93672577,  0.00531   ,  0.01269507])

EDIT:

If for some reason you need an estimation of the uncertainty of the fitted parameters, you can obtain this information from the covariance matrix of the results:

res = leastsq(f,initial_guess,full_output=True)
coef = res[0]
cov  = res[1]
#cov = array([[ 0.02537329, -0.00970796, -0.00065069,  0.00045027],
#             [-0.00970796,  0.03157025,  0.0006394 ,  0.00207787],
#             [-0.00065069,  0.0006394 ,  0.00535228, -0.00053483],
#             [ 0.00045027,  0.00207787, -0.00053483,  0.00618327]])

uncert = sqrt(diag(cov))
# uncert = array([ 0.15928997,  0.17768018,  0.07315927,  0.07863377])

The diagonal of the covariance matrix are the variance of each parameters, so the uncertainty is it’s square root

take a look to http://www.scipy.org/Cookbook/FittingData for more information on the fitting procedure.

The reason I used the leastsq and not the curve_fit function,that is easier to use, is that the curve_fit requires an explicit function in the form y = f(x), and not every implicit polynomial can be transormed into that form (or better, almost no interesting implicit polynomial at all)

Answered By: EnricoGiampieri

The distance from a point to the contour you are trying to fit is a periodic function of the angle in polar coordinates centered at that point. This function can be represented as a combination of sine (or cosine) functions which can be calculated exactly by the Fourier transform. Actually, the linear combination calculated by the Fourier transform truncated to the first N functions is the best fit with those N functions, according to Parseval’s theorem.

To use this in practice, pick a center point (perhaps the center of gravity of the contour), convert the contour to polar coordinates, and calculate the Fourier transform of the distance from the center point. The fitted contour is given by the first few Fourier coefficients.

The one remaining problem is that the contour converted to polar coordinates does not have distance values at evenly spaced angles. This is the Irregular Sampling problem. Since you have presumably a rather high density of samples, you can go around this very simply by using linear interpolation between the 2 closest points to the evenly spaced angle, or (depending on your data) averaging with a small window. Most other solutions to irregular sampling are vastly more complicated and unnecessary here.

EDIT: Sample code, works:

import numpy, scipy, scipy.ndimage, scipy.interpolate, numpy.fft, math

# create simple square
img = numpy.zeros( (10, 10) )
img[1:9, 1:9] = 1
img[2:8, 2:8] = 0

# find contour
x, y = numpy.nonzero(img)

# find center point and conver to polar coords
x0, y0 = numpy.mean(x), numpy.mean(y)
C = (x - x0) + 1j * (y - y0)
angles = numpy.angle(C)
distances = numpy.absolute(C)
sortidx = numpy.argsort( angles )
angles = angles[ sortidx ]
distances = distances[ sortidx ]

# copy first and last elements with angles wrapped around
# this is needed so can interpolate over full range -pi to pi
angles = numpy.hstack(([ angles[-1] - 2*math.pi ], angles, [ angles[0] + 2*math.pi ]))
distances = numpy.hstack(([distances[-1]], distances, [distances[0]]))

# interpolate to evenly spaced angles
f = scipy.interpolate.interp1d(angles, distances)
angles_uniform = scipy.linspace(-math.pi, math.pi, num=100, endpoint=False) 
distances_uniform = f(angles_uniform)

# fft and inverse fft
fft_coeffs = numpy.fft.rfft(distances_uniform)
# zero out all but lowest 10 coefficients
fft_coeffs[11:] = 0
distances_fit = numpy.fft.irfft(fft_coeffs)

# plot results
import matplotlib.pyplot as plt
plt.polar(angles, distances)
plt.polar(angles_uniform, distances_uniform)
plt.polar(angles_uniform, distances_fit)
plt.show()

P.S. There is one special case that may need attention, when the contour is non-convex (reentrant) to a sufficient degree that some rays along an angle through the chosen center point intersect it twice. Picking a different center point might help in this case. In extreme cases, it is possible that there is no center point that doesn’t have this property (if your contour looks like this). In that case you can still use method above to inscribe or circumscribe the shape you have, but this would not be a suitable method for fitting it per se. This method is intended for fitting “lumpy” ovals like a potato, not “twisted” ones like a pretzel 🙂

Answered By: Alex I