Extracting ellipse parmeters from rings

During a powder diffraction experiment, the scattering occures along cconcentric cones, originating from the sample position and named after 2 famous scientists: Debye and Scherrer.

Debye-Scherrer rings

Those cones are intersected by the detector and all the calibration step in pyFAI comes down is fitting the “ring” seen on the detector into a meaningful experimental geometry.

In the most common case, a flat detector is mounted orthogonal to the incident beam and all pixel have the same size. The diffraction patern is then a set of concentric cercles. When the detector is still flat and all the pixels are the same but the mounting may be a bit off, or maybe for other technical reason one gets a set of concentric ellipses. This procedures explains how to extract the center coordinates, axis lengths and orientation.

The code in pyFAI is heavily inspired from: https://github.com/ndvanforeest/fit_ellipse It uses a SVD decomposition in a similar way to the Wolfgang Kabsch’s algorithm (1976) to retrieve the best ellipse fitting all point without actually performing a fit.

[1]:
%matplotlib inline
#For documentation purpose, `inline` is used to enforce the storage of the image in the notebook
%matplotlib widget
[2]:
import sys
from matplotlib import pyplot
from pyFAI.utils.ellipse import fit_ellipse
import inspect
print(inspect.getsource(fit_ellipse))
def fit_ellipse(pty, ptx, _allow_delta=True):
    """Fit an ellipse

    Math from
    https://mathworld.wolfram.com/Ellipse.html #15

    inspired from
    http://nicky.vanforeest.com/misc/fitEllipse/fitEllipse.html

    :param pty: point coordinates in the slow dimension (y)
    :param ptx: point coordinates in the fast dimension (x)
    :raise ValueError: If the ellipse can't be fitted
    """
    x = ptx[:, numpy.newaxis]
    y = pty[:, numpy.newaxis]
    D = numpy.hstack((x * x, x * y, y * y, x, y, numpy.ones_like(x)))
    S = numpy.dot(D.T, D)
    try:
        inv = numpy.linalg.inv(S)
    except numpy.linalg.LinAlgError:
        if not _allow_delta:
            raise ValueError("Ellipse can't be fitted: singular matrix")
        # Try to do the same with a delta
        delta = 100
        ellipse = fit_ellipse(pty + delta, ptx + delta, _allow_delta=False)
        y0, x0, angle, wlong, wshort = ellipse
        return Ellipse(y0 - delta, x0 - delta, angle, wlong, wshort)

    C = numpy.zeros([6, 6], dtype=numpy.float64)
    C[0, 2] = C[2, 0] = 2.0
    C[1, 1] = -1.0
    E, V = numpy.linalg.eig(numpy.dot(inv, C))

    # First of all, sieve out all infinite and complex eigenvalues and come back to the Real world
    m = numpy.logical_and(numpy.isfinite(E), numpy.isreal(E))
    E, V = E[m].real, V[:, m].real

    # Ensures a>0, invert eigenvectors concerned
    V[:, V[0] < 0] = -V[:, V[0] < 0]
    # See https://mathworld.wolfram.com/Ellipse.html #15
    # Eigenvector must meet constraint (ac - b^2)>0 to be valid.
    A = V[0]
    B = V[1] / 2.0
    C = V[2]
    D = V[3] / 2.0
    F = V[4] / 2.0
    G = V[5]

    # Condition 1: Delta = det((a b d)(b c f)(d f g)) !=0
    Delta = A * (C * G - F * F) - G * B * B + D * (2 * B * F - C * D)
    # Condition 2: J>0
    J = (A * C - B * B)

    # Condition 3: Delta/(A+C)<0, replaces by Delta*(A+C)<0, less warnings
    m = numpy.logical_and(J > 0, Delta != 0)
    m = numpy.logical_and(m, Delta * (A + C) < 0)

    n = numpy.where(m)[0]
    if len(n) == 0:
        raise ValueError("Ellipse can't be fitted: No Eigenvalue match all 3 criteria")
    else:
        n = n[0]
    a = A[n]
    b = B[n]
    c = C[n]
    d = D[n]
    f = F[n]
    g = G[n]

    # Calculation of the center:
    denom = b * b - a * c
    x0 = (c * d - b * f) / denom
    y0 = (a * f - b * d) / denom

    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) * sqrt(1 + 4 * b * b / ((a - c) * (a - c))) - (c + a))
    down2 = (b * b - a * c) * ((a - c) * sqrt(1 + 4 * b * b / ((a - c) * (a - c))) - (c + a))
    a2 = up / down1
    b2 = up / down2
    if a2 <= 0 or b2 <= 0:
        raise ValueError("Ellipse can't be fitted, negative sqrt")

    res1 = sqrt(a2)
    res2 = sqrt(b2)

    if a == c:
        angle = 0  # we have a circle
    elif res2 > res1:
        res1, res2 = res2, res1
        angle = 0.5 * (pi + atan2(2 * b, (a - c)))
    else:
        angle = 0.5 * (pi + atan2(2 * b, (a - c)))
    return Ellipse(y0, x0, angle, res1, res2)

[3]:
from matplotlib import patches
from numpy import rad2deg

def display(ptx, pty, ellipse=None):
    """A function to overlay a set of points and the calculated ellipse
    """
    fig = pyplot.figure()
    ax = fig.add_subplot(111)
    if ellipse is not None:
        error = False
        y0, x0, angle, wlong, wshort = ellipse
        if wshort == 0:
            error = True
            wshort = 0.0001
        if wlong == 0:
            error = True
            wlong = 0.0001
        patch = patches.Arc((x0, y0), width=wlong*2, height=wshort*2, angle=rad2deg(angle))
        if error:
            patch.set_color("red")
        else:
            patch.set_color("green")
        ax.add_patch(patch)

        bbox = patch.get_window_extent()
        ylim = min(y0 - wlong, pty.min()), max(y0 + wlong, pty.max())
        xlim = min(x0 - wlong, ptx.min()), max(x0 - wlong, ptx.max())
    else:
        ylim = pty.min(), pty.max()
        xlim = ptx.min(), ptx.max()
    ax.plot(ptx, pty, "ro", color="blue")
    ax.set_xlim(*xlim)
    ax.set_ylim(*ylim)
    pyplot.show()
[4]:
from numpy import sin, cos, random, pi, linspace
arc = 0.8
npt = 100
R = linspace(0, arc * pi, npt)
ptx = 1.5 * cos(R) + 2 + random.normal(scale=0.05, size=npt)
pty = sin(R) + 1. + random.normal(scale=0.05, size=npt)

ellipse = fit_ellipse(pty, ptx)
print(ellipse)
display(ptx, pty, ellipse)
Ellipse(center_1=1.342218248453694, center_2=2.1627393910083916, angle=3.004563840417115, half_long_axis=1.2995895403025433, half_short_axis=0.641761939108477)
<ipython-input-3-25b98d70f68d>:31: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(ptx, pty, "ro", color="blue")
[5]:
angles = linspace(0, pi / 2, 10)
pty = sin(angles) * 20 + 10
ptx = cos(angles) * 20 + 10
ellipse = fit_ellipse(pty, ptx)
print(ellipse)
display(ptx, pty, ellipse)
Ellipse(center_1=10.000000000332909, center_2=10.000000000325038, angle=2.3689992424085746, half_long_axis=19.999999999804693, half_short_axis=19.999999999532037)
<ipython-input-3-25b98d70f68d>:31: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(ptx, pty, "ro", color="blue")
[6]:
angles = linspace(0, pi * 2, 6, endpoint=False)
pty = sin(angles) * 10 + 50
ptx = cos(angles) * 20 + 100
ellipse = fit_ellipse(pty, ptx)
print(ellipse)
display(ptx, pty, ellipse)
Ellipse(center_1=50.000000000000576, center_2=100.0000000000018, angle=3.141592653589068, half_long_axis=19.999999999994646, half_short_axis=10.000000000002697)
<ipython-input-3-25b98d70f68d>:31: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(ptx, pty, "ro", color="blue")
[7]:
# Center to zero
angles = linspace(0, 2*pi, 9, endpoint=False)
pty = sin(angles+0) * 10 + 0
ptx = cos(angles+0) * 20 + 0
ellipse = fit_ellipse(pty, ptx)
print(ellipse)
display(ptx, pty, ellipse)
Ellipse(center_1=-5.030642569181509e-12, center_2=-8.540723683836404e-12, angle=1.6532775148903056e-11, half_long_axis=19.999999999815742, half_short_axis=10.00000000009243)
<ipython-input-3-25b98d70f68d>:31: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(ptx, pty, "ro", color="blue")
[8]:
angles = linspace(0, 2 * pi, 9, endpoint=False)
pty = 50 + 10 * cos(angles) + 5 * sin(angles)
ptx = 100 + 5 * cos(angles) + 15 * sin(angles)
ellipse = fit_ellipse(pty, ptx)
print(ellipse)
display(ptx, pty, ellipse)
Ellipse(center_1=50.00000000000121, center_2=100.00000000000088, angle=0.5535743588955828, half_long_axis=18.09016994372895, half_short_axis=6.909830056258535)
<ipython-input-3-25b98d70f68d>:31: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(ptx, pty, "ro", color="blue")
[9]:
# Points from real peaking
from numpy import array
pty = array([0.06599215, 0.06105629, 0.06963708, 0.06900191, 0.06496001, 0.06352082, 0.05923421, 0.07080027, 0.07276284, 0.07170048])
ptx = array([0.05836343, 0.05866434, 0.05883284, 0.05872581, 0.05823667, 0.05839846, 0.0591999, 0.05907079, 0.05945377, 0.05909428])
try:
    ellipse = fit_ellipse(pty, ptx)
except Exception as e:
    ellipse = None
    print(e)
display(ptx, pty, ellipse)
<ipython-input-3-25b98d70f68d>:31: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(ptx, pty, "ro", color="blue")
[10]:
# Line
from numpy import arange
pty = arange(10)
ptx = arange(10)
try:
    ellipse = fit_ellipse(pty, ptx)
except Exception as e:
    ellipse = None
    print(e)
display(ptx, pty, ellipse)
Ellipse can't be fitted: singular matrix
<ipython-input-3-25b98d70f68d>:31: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(ptx, pty, "ro", color="blue")

Conclusion

Within pyFAI’s calibration process, the parameters of the ellipse are used in first instance as input guess for starting the fit procedure, which uses slsqp from scipy.optimize.