Search code examples
pythonnumpyscipycurve-fittingpolar-coordinates

How to fit a closed contour?


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.


Solution

  • 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 :)