Surface flatness and mathematical optimization

mathematics / engineering
The problem of estimating "how flat" a surface is comes up quite often in the industry. A machined surface can be subjected to a series of local height measurements with the use of a CNC height probe for example, giving rise to a point cloud from which the flatness information can be extracted. We are going to see how the principal component analysis method arises naturally when the flatness estimation problem is formulated as an optimization problem.

The problem

Input

We start with a point cloud of size N in 3D space, each height measurement corresponds to a point xi. The z component of xi denoted by xi2 is the actual height value, and the x and y components, denoted respectively by xi0 and xi1 are the horizontal position on the surface. Let X denote the collection of points xi. X can be represented as a regular data matrix, with each point in the cloud written as a row:

(1)X=[x0x1xN1]=[x00x01x02x10x11x12xN10xN11xN12]

We suppose that X is centered, meaning that the barycenter of the point cloud is at the origin. If it is not the case, we can always subtract the barycenter to each point of the cloud:

(2)XXx

An optimization problem

The RMS plane

A good estimation method for the surface flatness is to compute a best fit plane P (aka RMS plane) that optimally fits the point cloud X. Then, a maximum deviation is calculated above and below this plane, creating two additional upper and lower planes (named respectively PU and PL), both parallel to the RMS plane. The whole point cloud is comprised in between. The distance between the upper and lower planes is taken as a flatness measurement. This makes sense, right? The smaller the deviation from the best fit plane, the flatter the surface. So lower scores mean flatter.

Distance to an arbitrary plane

Let P be an arbitrary plane with unit normal n^=[n0n1n2]. Let n=[n^n3] denote the normal vector in homogeneous coordinates. The plane’s equation is then:

(3)P:n0x+n1y+n2z+n3=0

Then, for each xiX we can evaluate a signed distance Di to the plane P. Because the plane gets an orientation thanks to its normal, by convention, when Di>0 the point xi is said to be above the plane, and when Di<0 the point is below. To evaluate Di, it is handier to have the points in homogeneous coordinates too:

(4)xiyi=[xi1]

With this we can write the signed distance as:

(5)Di=nyi=n0xi0+n1xi1+n2xi2+n3

Note that when we set Di=0 we recover the plane’s equation 3. It all makes sense: if a point has zero distance to the plane, then it is on the plane, and as such, must be a solution to the plane equation.

Problem formulation

By hypothesis, our data is centered, implying that the best fit plane always contains the origin. This translates to n3 being null, which means that the subspace of interest spans the first three indices only, and we can forget about homogeneous coordinates (that I only mentioned for the sake of completeness). If you can’t make this assumption for whatever reason, you will need to fallback to an homogeneous representation. Then, the distance to the plane is simply:

(6)Di=n^xi

Optimizing the plane is equivalent to optimizing its normal. The idea is to find the normal n^ to the plane P that minimizes the sum of the squared distances for each point. Why the square? Because it behaves nicely under differentiation, as opposed to an absolute value for instance. We have the additional constraint that the normal must remain a unit vector. So the problem writes:

(7)Solve: argminn^i=0N1Di2subject to: n^=1

Solution

Solution using linear algebra

In order to take the normalization constraint into account, we are going to use a Lagrange multiplier. Now let’s write a Lagrangian for this problem:

(8)L=i=0N1(n^xi)2λ((n0)2+(n1)2+(n2)21)

Here, λ is the Lagrange multiplier, and what comes after it is the normalization constraint function, that is always equal to zero when the normal vector is normalized. The minus sign in front of λ is useful later on, but is merely a convention. Solving 7 is then equivalent to solving:

(9)Lnj=0,j2

Substituting 8 into this and dividing both sides by 2 we have the following system of equations:

(10)i=0N1(n^xi)xijλnj=0,j2

A covariance matrix C is hidden in the summation. Without loss of generality, we can multiply it by a 1N normalization factor for better stability:

(11)C=1NXX=1Ni=0N1[(xi0)2xi0xi1xi0xi2xi1xi0(xi1)2xi1xi2xi2xi0xi2xi1(xi2)2]

Then, 10 becomes:

(12)Cn^λn^=0(CλI)n^=0

So this is in fact an eigenvalue problem! The solutions to 12 are the eigenvectors of the covariance matrix, associated to the eigenvalues λ. To formalize this, we are looking for an eigendecomposition of C, i.e. finding W and Λ such that:

(13)C=WΛW

With W a 3×3 matrix whose columns are the eigenvectors, and Λ a 3×3 diagonal matrix whose values are the eigenvalues. This can always be done, because C is symmetric, so it is diagonalizable.

Because in our context λ must be a maximizer of L (the critical point we’re looking for is a saddle point), the optimal solution for us is the eigenvector n^ corresponding to the smallest eigenvalue λ (remember the negative sign in front of λ?). So all we need to do to find the normal of P is to compute C, its eigendecomposition, and select the column in W that has the same index as the lowest value in Λ.

A better approach

Let’s take a step back and contemplate what we’re doing here. The algorithm that consists in computing the eigendecomposition of a covariance matrix C associated to a (centered) data matrix X is known as a Principal Component Analysis or PCA for short. Its purpose is to find the directions of greatest variance of a dataset. These directions (known as the principal components) are the eigenvectors of C, and the bigger the variance across a principal component, the bigger the associated eigenvalue.

Because our dataset is comprised of points that are very close to the best fit plane P, the points will show the greatest variance in the axes of the local basis of P. Complementarily, they will show the lowest variance in the direction of the normal n^ to P. This justifies intuitively the previous section’s final paragraph.

Now, for practical reasons, we don’t usually mess with a covariance matrix because it will introduce numerical errors. It is more favorable from this standpoint to compute the compact Singular Value Decomposition (SVD) of X directly, which can be done efficiently on a computer:

(14)X=UΣV

with U an N×3 semi-unitary matrix, Σ a square diagonal 3×3 matrix containing the singular values σj, and V a 3×3 unitary matrix (because our points have dimension 3, and assuming N>3). Substituting 14 into 11 gives:

(15)C=1NXX=1NVΣUUΣV=VΣ2VN

because UU=I as U is semi-unitary, and Σ is square diagonal. As Σ=diag{σj}, and by comparison with 13 the singular values are just proportional to the square roots of our eigenvalues:

(16)σj=Nλj,j2

Also, the right-singular vectors of V are just the same as the eigenvectors of the eigendecomposition of C. So the process stays the same: we find the right-singular vector in V associated to the smallest singular value in Σ.

I had a computer vision teacher who liked to say, with the eyes of a crazy man, that an SVD is the proper way to rip open a matrix and see what’s inside! It often comes as second nature for people in these fields to perform an SVD by reflex on whatever data matrix they encounter, and this would have been the good move here.

The algorithm

So it turns out the algorithm is quite simple:

  • Compute the SVD of the data matrix: X=UΣV
  • Find the index of the smallest singular value in Σ: jmin=argminj{σj}
  • Set the best fit plane normal n^ to the corresponding singular vector: n^=Vjmin
  • Find the extremal distances to the best fit plane:
    • dmin=mini(n^xi)
    • dmax=maxi(n^xi)
  • Obtain the flatness estimation: F=dmaxdmin

Implementation

Generating point clouds for a test

If you don’t have a CNC height probe at home to play with, you can still generate some points procedurally. To obtain a point cloud that is randomly distributed around a plane P, knowing the normal n^ to P is good, but insufficient. We must choose a local basis B={u^,v^} of P such that u^v^n^. Because of this orthogonality constraint we have:

(17)u^n^=u0n0+u1n1+u2n2=0

By choosing u0=1 and u2=0, it follows that:

(18)u^=11+(n0n1)2[1n0n10]

And v^ is obtained by cross-product: v^=n^×u^.

Let (s,t) be the coordinates with respect to the basis B. Then the points of P are generated by:

(19)r(s,t)=r0+su^+tv^,with r0 an arbitrary translation

Now, let S and T be uniform random variables, and B a random variable of arbitrary distribution. Then the following discrete stochastic process will model our point cloud around the plane:

(20)xi=r0+Siu^+Tiv^+Bin^

The distribution of B controls the flatness, as B generates the random coefficients in front of the normal vector, which encodes by how much the point xi deviates from the plane. It is also possible to consider an additional non-linear term in the coefficient of n^ to model slight surface deformations. For example:

(21)xi=r0+Siu^+Tiv^+(Bi+αSiTi)n^

will exhibit an hyperbolic warp. This may come in handy when assessing the algorithm’s robustness.

Example Python implementation

First, let’s import Numpy and Matplotlib:

import matplotlib.pyplot as plt
import numpy as np

Now, we add a function to generate a random orthogonal basis. The first two vectors returned are in the plane, and the last one is the plane’s normal:

def random_basis():
    # Unit plane normal
    n0 = np.random.rand(3)
    n0 = n0 / np.sqrt(np.sum(n0**2))
    # Plane basis
    u0 = np.array([1, -n0[0]/n0[1], 0], dtype='float')
    u0 = u0 / np.sqrt(np.sum(u0**2))
    v0 = np.cross(n0, u0)

    return (u0, v0, n0)

Given a basis, we can generate our point cloud. Let’s choose the s and t coordinates uniformly in [10,10]. The deviation along the normal axis is scaled by a scalar factor, and for now, a uniform distribution is used. Then we need to center the data by subtracting the cloud barycenter to all points:

def generate_data(num_points: int, noise_amp: float, basis):
    u, v, n = basis
    # Random coordinate in the plane
    s = np.random.uniform(-10, 10, num_points)
    t = np.random.uniform(-10, 10, num_points)
    # Random deviation along the normal axis
    b = noise_amp * np.random.uniform(-1, 1, num_points)
    # Generate points
    X = s[:, np.newaxis]*u + t[:, np.newaxis]*v + b[:, np.newaxis]*n
    # Center data
    m = np.mean(X, axis=0)
    X = X - m

    return X

Now, we write the function that takes the point cloud as input, and spits out the best fit normal vector n^:

def best_fit_normal(X):
    # Perform SVD
    U, Sigma, Vh = np.linalg.svd(X, full_matrices=True)
    # Find index of smallest singular value
    jmin = np.argmin(Sigma)

    # Return the right-singular vector at that index
    return Vh[jmin]

We also need a function to compute the extremal distances to the best fit plane:

def max_dist(X, normal):
    # Dot product of every point in X with the normal
    D = np.sum(normal*X, axis=1)

    return (np.min(D), np.max(D))

And this helper function will allow us to plot the planes more easily:

def plot_plane(ax, origin, normal, color, alpha):
    x = np.linspace(-13, 13, 2)
    y = np.linspace(-13, 13, 2)
    px, py = np.meshgrid(x, y)
    pz = - (px * normal[0] + py * normal[1]) / normal[2]
    ax.plot_surface(px + origin[0], py + origin[1],
                    pz + origin[2], color=color, alpha=alpha)

Let’s put it all together:

def main():
    basis = random_basis()
    X = generate_data(100, 2, basis)
    n_opt = best_fit_normal(X)
    dmin, dmax = max_dist(X, n_opt)
    F = dmax - dmin
    n_rms = np.linalg.norm(n_opt - basis[2])

    print(f'flatness score: {F}')
    print(f'norm RMS error: {n_rms}')

    # Plot the points, best fit plane and upper and lower planes
    fig = plt.figure()
    ax = fig.add_subplot(projection='3d')
    plot_plane(ax, [0, 0, 0], n_opt, "red", 0.5)
    plot_plane(ax, dmin*n_opt, n_opt, "orange", 0.5)
    plot_plane(ax, dmax*n_opt, n_opt, "orange", 0.5)
    ax.scatter(X[:, 0], X[:, 1], X[:, 2])
    plt.show()


if __name__ == '__main__':
    main()

Example output:

flatness score: 4.709666038694197
norm RMS error: 0.05532841814392836

The norm RMS error is the Euclidean distance between the normal vector n^0 that was used to generate the point cloud, and the normal vector n^ that was optimized by the algorithm. They should match pretty closely as long as the noise amplitude B isn’t too high.

The point cloud and the planes. The best fit plane is displayed in red, and the upper and lower planes in orange.

The same point cloud from another view point.

Here I’m deliberately choosing a big noise amplitude so the different planes can be seen, but in a real world scenario, your points should be much closer to P.

Limitations

This implementation is just a starting point. In a real world scenario you will have additional problems to solve. For example, there will be outliers, points in the cloud that are way out in the distance, because your height probe bumped into a bread crumb or any other more realistic reason. Maybe you could use an outlier detection pre-pass on X using a k-NN score, or an LOF? Perhaps you want to characterize the surface deformation, in which case fitting a plane is no more ideal…

Anyway, I hope you enjoyed this trip in the land of Linear Algebra!




The comment section requires the Utterances cookie in order to work properly. If you want to see people's comments or post a comment yourself, please enable the Utterances cookie here.