Fit Ellipse over 2D data using Python and matplotlib

本秂侑毒 提交于 2021-01-29 05:44:12

问题


I am trying to fit an ellipse over my different sets of 2D points using matplotlib. I'm using the ellipse fitting functions from here, and here's the code.

from matplotlib.patches import Ellipse
import matplotlib.pyplot as plt
import numpy

def fitEllipse(x,y):
    x = x[:,np.newaxis]
    y = y[:,np.newaxis]
    D =  np.hstack((x*x, x*y, y*y, x, y, np.ones_like(x)))
    S = np.dot(D.T,D)
    C = np.zeros([6,6])
    C[0,2] = C[2,0] = 2; C[1,1] = -1
    E, V =  eig(np.dot(inv(S), C))
    n = np.argmax(np.abs(E))
    a = V[:,n]
    return a

def ellipse_center(a):
    b,c,d,f,g,a = a[1]/2, a[2], a[3]/2, a[4]/2, a[5], a[0]
    num = b*b-a*c
    x0=(c*d-b*f)/num
    y0=(a*f-b*d)/num
    return np.array([x0,y0])


def ellipse_axis_length( a ):
    b,c,d,f,g,a = a[1]/2, a[2], a[3]/2, a[4]/2, a[5], a[0]
    up = 2*(a*f*f+c*d*d+g*b*b-2*b*d*f-a*c*g)
    down1=(b*b-a*c)*( (c-a)*np.sqrt(1+4*b*b/((a-c)*(a-c)))-(c+a))
    down2=(b*b-a*c)*( (a-c)*np.sqrt(1+4*b*b/((a-c)*(a-c)))-(c+a))
    res1=np.sqrt(up/down1)
    res2=np.sqrt(up/down2)
    return np.array([res1, res2])

def ellipse_angle_of_rotation2( a ):
    b,c,d,f,g,a = a[1]/2, a[2], a[3]/2, a[4]/2, a[5], a[0]
    if b == 0:
        if a > c:
            return 0
        else:
            return np.pi/2
    else:
        if a > c:
            return np.arctan(2*b/(a-c))/2
        else:
            return np.pi/2 + np.arctan(2*b/(a-c))/2

However, when I plot the ellipse using matplotlib, sometimes the ellipse is fitted well, and sometimes I need to rotate the ellipse 90 degrees to have it fit (the blue ellipse is rotated 90, the red ellipse is no additional rotation) Here is my code.

def plot_ellipse(x, y):
    a = fitEllipse(x, y)

    center = ellipse_center(a)

    phi = ellipse_angle_of_rotation2(a)

    axes = ellipse_axis_length(a)

    a, b = axes

    ell = Ellipse(center, 2*a, 2*b, phi*180 / np.pi, facecolor=(1,0,0,0.2), edgecolor=(0,0,0,0.5))

    ell_rotated = Ellipse(center, 2*a, 2*b, phi*180 / np.pi + 90, facecolor=(0,0,1,0.2), edgecolor=(0,0,0,0.5))

    fig, ax = plt.subplots()
    ax.add_patch(ell)
    ax.add_patch(ell_rotated)

    plt.scatter(x, y)

    plt.show()

x1 = np.array([238, 238, 238, 237, 237, 237, 237, 237, 236, 236, 236, 236, 237, 238,
     239, 240, 240, 241, 242, 243, 243, 244, 245, 246, 247, 248, 249, 250,
     251, 252, 253, 254, 255, 255, 256, 257, 258, 259, 260, 261, 262, 263,
     264, 265, 266, 266, 267, 267, 268, 268, 269, 269, 270, 270, 271, 271,
     271, 271, 271, 272, 272, 272, 272, 272, 273, 273, 273, 273, 273, 273,
     274, 274, 274, 274, 274, 274, 275, 275, 275, 275, 275, 275, 275, 275,
     275, 275, 274, 274, 274, 274, 274, 274, 274, 274, 274, 273, 273, 273,
     272, 272, 272, 272, 271, 271, 271, 270, 270, 269, 268, 268, 267, 266,
     266, 265, 265, 264, 263, 262, 261, 260, 259, 258, 257, 256, 256, 255,
     254, 253, 252, 251, 250, 249, 248, 247, 246, 245, 245, 244, 243, 242,
     241, 240, 239, 238, 237, 236, 235, 235, 235, 234, 234, 233, 233, 232,
     232, 232, 231, 231, 230, 230, 229, 229, 229, 229, 229, 229, 229, 229,
     228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228,
     228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228,
     228, 229, 229, 229, 229, 229, 229, 229, 229, 229, 230, 230, 230, 230,
     231, 231, 231, 232, 232, 232, 232, 233, 233, 233, 234, 235, 236, 237,
     238, 239, 240, 241, 242])
y1 = np.array([283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 293, 294, 295,
     296, 297, 298, 299, 300, 301, 301, 301, 301, 301, 301, 301, 301, 301,
     301, 301, 301, 301, 301, 301, 301, 300, 300, 299, 299, 298, 298, 297,
     297, 296, 296, 296, 295, 294, 293, 292, 291, 290, 289, 288, 287, 286,
     286, 285, 284, 283, 282, 281, 280, 279, 278, 277, 276, 276, 275, 274,
     273, 272, 271, 270, 269, 268, 267, 266, 265, 264, 264, 263, 262, 261,
     260, 259, 258, 257, 256, 255, 254, 253, 252, 252, 251, 250, 249, 248,
     247, 246, 245, 244, 243, 242, 242, 241, 240, 239, 238, 237, 236, 235,
     234, 233, 233, 232, 232, 231, 230, 230, 229, 228, 228, 227, 227, 227,
     227, 226, 226, 226, 226, 226, 226, 225, 225, 225, 225, 226, 226, 227,
     228, 229, 229, 230, 231, 231, 232, 232, 233, 234, 235, 236, 237, 238,
     239, 240, 241, 242, 243, 244, 245, 246, 246, 247, 248, 249, 250, 251,
     252, 253, 254, 255, 256, 257, 257, 258, 259, 260, 261, 262, 263, 264,
     265, 266, 267, 268, 269, 270, 271, 272, 273, 273, 274, 275, 276, 277,
     278, 279, 280, 281, 282, 283, 284, 285, 285, 286, 287, 288, 289, 290,
     291, 292, 293, 294, 295, 296, 297, 298, 299, 299, 300, 301, 301, 302,
     303, 304, 304, 305, 306])

x2 = np.array(    [235, 236, 237, 238, 239, 240, 241, 242, 243, 243, 244, 245, 246, 247,
     248, 249, 250, 251, 252, 253, 254, 255, 255, 256, 257, 258, 259, 260,
     261, 262, 263, 264, 265, 266, 266, 267, 268, 269, 270, 270, 271, 272,
     273, 274, 274, 274, 275, 275, 276, 276, 277, 277, 278, 278, 279, 279,
     279, 279, 280, 280, 280, 280, 281, 281, 281, 281, 282, 282, 282, 282,
     282, 282, 282, 282, 282, 281, 281, 281, 281, 281, 281, 281, 281, 281,
     280, 280, 280, 280, 280, 279, 279, 279, 279, 278, 278, 277, 277, 276,
     276, 275, 275, 274, 274, 274, 273, 272, 271, 270, 269, 268, 267, 266,
     265, 264, 263, 263, 262, 261, 260, 259, 258, 257, 256, 255, 254, 253,
     252, 252, 251, 250, 249, 248, 247, 246, 245, 244, 243, 242, 241, 240,
     240, 239, 238, 237, 236, 235, 234, 233, 232, 232, 231, 231, 230, 230,
     229, 229, 228, 228, 227, 227, 227, 227, 227, 227, 227, 227, 227, 227,
     226, 226, 226, 226, 226, 226, 226, 226, 226, 226, 226, 226, 226, 227,
     227, 227, 227, 227, 227, 227, 227, 228, 228, 228, 228, 228, 228, 229,
     229, 230, 230, 231, 231, 232, 232, 233, 233, 234, 234, 235, 235, 235,
     236, 237, 238, 239, 240, 241, 242, 243, 244])

y2 = np.array(
    [279, 280, 281, 282, 283, 284, 285, 286, 287, 287, 287, 288, 288, 289,
     289, 290, 290, 290, 291, 291, 292, 292, 292, 292, 291, 291, 290, 290,
     289, 289, 288, 288, 287, 287, 287, 286, 285, 284, 283, 282, 281, 280,
     279, 278, 278, 277, 276, 275, 274, 273, 272, 271, 270, 269, 268, 267,
     267, 266, 265, 264, 263, 262, 261, 260, 259, 258, 257, 256, 255, 255,
     254, 253, 252, 251, 250, 249, 248, 247, 246, 245, 244, 244, 243, 242,
     241, 240, 239, 238, 237, 236, 235, 234, 234, 233, 232, 231, 230, 229,
     228, 227, 226, 225, 224, 224, 223, 222, 222, 221, 220, 219, 218, 217,
     217, 216, 215, 215, 215, 215, 215, 215, 215, 214, 214, 214, 214, 214,
     214, 214, 214, 215, 215, 216, 216, 217, 217, 217, 218, 218, 219, 219,
     219, 220, 221, 222, 223, 223, 224, 225, 226, 226, 227, 228, 229, 230,
     231, 232, 233, 234, 235, 236, 236, 237, 238, 239, 240, 241, 242, 243,
     244, 245, 246, 247, 248, 249, 250, 251, 251, 252, 253, 254, 255, 256,
     257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 268, 269,
     270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 282,
     283, 284, 284, 285, 286, 287, 287, 288, 289])

plot_ellipse(x1, y1)
plot_ellipse(x2, y2)

And here are screenshots of the plots:

x1, y1 plot

x2, y2 plot

As you can see, the non-rotated (red) ellipse fits the x1,y1 data well, but the rotated ellipse (blue) fits the x2,y2 data.

I'm confused if I'm missing something here, when do I need to rotate the ellipse by 90º and when do I not need to?


回答1:


Here is my guess, without checking the math 100%. Looking at the definition and the Lagrangian to be solved, everything looks fine and logic. I think the vector a is correct and such are the internal parameters a to f. However, the code mentions that the function to be minimized is independent of a scaling factor. So when calculating the axis angle, one might run into an sign issue. My guess is, that this takes place in the arctan() function. One solution might be to add a sign check in the calculation of the a vector, i.e. a -> -a if a[-1] < 0.

Even better and probably also working (I just tested 2 cases from OP) is to replace

if a > c:
    return np.arctan( 2 * b / ( a - c ) ) / 2
else:
    return np.pi / 2 + np.arctan( 2 * b / (a - c ) ) / 2

by

if a > c:
    return np.arctan2( 2 * b, ( a - c ) ) / 2
else:
    return np.pi / 2 + np.arctan2( 2 * b, ( a - c) ) / 2

If it is clear that the argument of the arctan is coming from a division, arctan2 is the one to go for.

One additional remark: I think that code with multiple return statements in one function should be omitted. In this short functions it is still easy to handle, but I don't think of it as good practice.

Update

When contacting the author of the original fit code, he mentioned that arctan2 is in use in version he provides on GitHub

This code looks much cleaner and I recommend to use that instead of the snippets from the home page.

Additional thoughts

Actually, I think it can be done in a more consistent and easier to follow way, how the parameters of the ellipse are extracted from the a vector. so I wrote this code down

# -*- coding: utf-8 -*-
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse
import numpy as np

RAD = 180. / np.pi
DEGREE = 1. / RAD

def rot( a ):
    """
    simple rotation matrix in 2D
    """
    return np.array( 
        [ [ +np.cos( a ), -np.sin( a ) ],
          [ +np.sin( a ), +np.cos( a ) ] ] 
    )

def fit_ellipse( x, y ):
    """
    main fit from the original publication:
    http://nicky.vanforeest.com/misc/fitEllipse/fitEllipse.html
    """
    x = x[ :, np.newaxis ]
    y = y[ :, np.newaxis ]
    D =  np.hstack( ( x * x, x * y, y * y, x, y, np.ones_like( x ) ) )
    S = np.dot( D.T, D )
    C = np.zeros( [ 6, 6 ] )
    C[ 0, 2 ] = +2 
    C[ 2, 0 ] = +2
    C[ 1, 1 ] = -1
    E, V =  np.linalg.eig( np.dot( np.linalg.inv( S ), C ) )
    n = np.argmax( np.abs( E ) )
    a = V[ :, n ]
    return a

def ell_parameters( a ):
    """
    New function substituting the original 3 functions for 
    axis, centre and angle.
    We start by noting that the linear term is due to an offset. 
    Getting rid of it is equivalent to find the offset. 
    Starting with the Eq.
    xT A x + bT x + c = 0 and transforming x -> x - t 
    we get a new linear term. By demanding that this term vanishes
    we get the Eq.
    b = (AT + A ) t. 
    Hence, an easy way to write down how to get t
    """
    A = np.array( [ [ a[0], a[1]/2. ], [ a[1]/2., a[2] ] ] )
    b = np.array( [ a[3], a[4] ] )
    t = np.dot( np.linalg.inv( np.transpose( A ) + A ), b )
    """
    the transformation changes the constant term, which we need
    for proper scaling
    """
    c = a[5]
    cnew =  c - np.dot( t, b ) + np.dot( t, np.dot( A, t ) )
    Anew = A / (-cnew)
    # ~cnew = cnew / (-cnew) ### debug only
    """
    now it is in the form xT A x - 1 = 0
    and we know that A is a rotation of the matrix 
        ( 1 / a²   0 )
    B = (            )
        ( 0   1 / b² )
    where a and b are the semi axes of the ellipse
    it is hence A = ST B S
    We note that rotation does not change the eigenvalues, which are 
    the diagonal elements of matrix B. Moreover, we note that 
    the matrix of eigenvectors rotates B into A
    """
    E, V = np.linalg.eig( Anew )
    """
    so we have
    B = VT A V
    and consequently
    A = V B VT
    where V is of a form as given by the function rot() from above
    """
    # ~B = np.dot( np.transpose(V), np.dot( Anew, V ) ) ### debug only
    phi = np.arccos( V[ 0, 0 ] )
    """
    checking the sin for changes in sign to detect angles above 180°
    """
    if V[ 0, 1 ] < 0: 
        phi = 2 * np.pi - phi
    ### cw vs ccw and periodicity of pi
    phi = -phi % np.pi
    return np.sqrt( 1. / E ), phi * RAD, -t
    """
    That's it. One might put some additional work/thought in the 180° 
    and cw vs ccw thing, as it is a bit messy. 
    """

"""
creating some test data
"""
xl = np.linspace(-3,2.5, 10)
yl = np.fromiter( (2.0 * np.sqrt( 1 - ( x / 3. )**2 ) for x in xl ), np.float )
xl = np.append(xl,-xl)
yl = np.append(yl,-yl)
R = rot( -103.01 * DEGREE ) ### check different angles
# ~R = rot( 153 * DEGREE ) results in singular matrix !!!...strange
xyrot = np.array( [ np.dot(R, [ x, y ] )for x, y  in zip( xl, yl ) ] )
xl = xyrot[:,0] + 7
yl = xyrot[:,1] + 16.4

"""
fitting
"""
avec = fit_ellipse( xl, yl )
(a, b), phi, t = ell_parameters( avec )

ell = Ellipse(
    t, 2 * a, 2 * b, phi, 
    facecolor=( 1, 0, 0, 0.2 ), edgecolor=( 0, 0, 0, 0.5 )
)

"""
plotting
"""
fig = plt.figure()
ax = fig.add_subplot( 1, 1, 1 )
ax.add_patch( ell )
ax.scatter( xl ,yl )
plt.show()

I think this code should not have the 90° problem either. If removing all comments, it is quite compact and ( from the math point of view) readable.

Note

I encountered a problem in inv( S ). This matrix became singular. One workaround might be: rotate all data by a small angle and rotate the calculated t back. Also subtract the angle from the calculated angle phi.



来源:https://stackoverflow.com/questions/61593411/fit-ellipse-over-2d-data-using-python-and-matplotlib

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!