3

say we have a 2D grid that is projected on a 3D surface, resulting in a 3D numpy array, like the below image. What is the most efficient way to calculate a surface normal for each point of this grid?

enter image description here

Alejandro
  • 879
  • 11
  • 27
  • How do you define a surface normal? I can calculate normal based on three points, but also on N nearest nieghbours. And what do you want to do with the edge cases? And maybe more important, what did you try and what did not work for you? – 3dSpatialUser Jan 14 '22 at 14:10
  • @3DspatialUser good question, for me 3 points is enough. but it would be useful to have the flexibility of choosing n points if it won't cost too high computation – Alejandro Jan 14 '22 at 14:14
  • Still, which three points? The three closest points? And three including the point itself? I would suggest for you to look in to Principle Component Analyses https://stats.stackexchange.com/questions/2691/making-sense-of-principal-component-analysis-eigenvectors-eigenvalues in combination with nearest neighbors. – 3dSpatialUser Jan 14 '22 at 14:15
  • @3DspatialUser 3 including the point itself. complexity of PCA may be high no? I imagine if for each point we can find the 2 nearest points with a very low computation, we can do it quicker – Alejandro Jan 14 '22 at 14:28
  • 1
    For all points, find the first nearest neighbors (say 8) of all points using a kD tree and fit a least-squares plane. Consider discarding points if their distance seems abnormal. Notice that the orientation will be indeterminate. –  Jan 14 '22 at 15:02

2 Answers2

2

I can give you an example with simulated data:

I showed your way, with three points. With three points you can always calculate the cross product to get the perpendicular vector based on the two vectors created from three points. Order does not matter.

I took the liberty to also add the PCA approach using predefined sklearn functions. You can create your own PCA, good exercise to understand what happens under the hood but this works fine. The benefit of the approach is that it is easy to increase the number of neighbors and you are still able to calculate the normal vector. It is also possible to select the neighbors within a range instead of N nearest neighbors.

If you need more explanation about the working of the code please let me know.

from functools import partial
import numpy as np
from sklearn.neighbors import KDTree

from mpl_toolkits.mplot3d import axes3d
import matplotlib.pyplot as plt

from sklearn.decomposition import PCA


fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')

# Grab some test data.
X, Y, Z = axes3d.get_test_data(0.25)

X, Y, Z = map(lambda x: x.flatten(), [X, Y, Z])

plt.plot(X, Y, Z, '.')
plt.show(block=False)

data = np.array([X, Y, Z]).T

tree = KDTree(data, metric='minkowski') # minkowki is p2 (euclidean)

# Get indices and distances:
dist, ind = tree.query(data, k=3) #k=3 points including itself

def calc_cross(p1, p2, p3):
    v1 = p2 - p1
    v2 = p3 - p1
    v3 =  np.cross(v1, v2)
    return v3 / np.linalg.norm(v3)

def PCA_unit_vector(array, pca=PCA(n_components=3)):
    pca.fit(array)
    eigenvalues = pca.explained_variance_
    return pca.components_[ np.argmin(eigenvalues) ]

combinations = data[ind]

normals = list(map(lambda x: calc_cross(*x), combinations))

# lazy with map
normals2 = list(map(PCA_unit_vector, combinations))


## NEW ##

def calc_angle_with_xy(vectors):
    '''
    Assuming unit vectors!
    '''
    l = np.sum(vectors[:,:2]**2, axis=1) ** 0.5
    return np.arctan2(vectors[:, 2], l)

    

dist, ind = tree.query(data, k=5) #k=3 points including itself
combinations = data[ind]
# map with functools
pca = PCA(n_components=3)
normals3 = list(map(partial(PCA_unit_vector, pca=pca), combinations))

print( combinations[10] )
print(normals3[10])


n = np.array(normals3)
n[calc_angle_with_xy(n) < 0] *= -1

def set_axes_equal(ax):
    '''Make axes of 3D plot have equal scale so that spheres appear as spheres,
    cubes as cubes, etc..  This is one possible solution to Matplotlib's
    ax.set_aspect('equal') and ax.axis('equal') not working for 3D.

    Input
      ax: a matplotlib axis, e.g., as output from plt.gca().
    
    FROM: https://stackoverflow.com/questions/13685386/matplotlib-equal-unit-length-with-equal-aspect-ratio-z-axis-is-not-equal-to
    '''

    x_limits = ax.get_xlim3d()
    y_limits = ax.get_ylim3d()
    z_limits = ax.get_zlim3d()

    x_range = abs(x_limits[1] - x_limits[0])
    x_middle = np.mean(x_limits)
    y_range = abs(y_limits[1] - y_limits[0])
    y_middle = np.mean(y_limits)
    z_range = abs(z_limits[1] - z_limits[0])
    z_middle = np.mean(z_limits)

    # The plot bounding box is a sphere in the sense of the infinity
    # norm, hence I call half the max range the plot radius.
    plot_radius = 0.5*max([x_range, y_range, z_range])

    ax.set_xlim3d([x_middle - plot_radius, x_middle + plot_radius])
    ax.set_ylim3d([y_middle - plot_radius, y_middle + plot_radius])
    ax.set_zlim3d([z_middle - plot_radius, z_middle + plot_radius])


u, v, w = n.T

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
# ax.set_aspect('equal')

# Make the grid
ax.quiver(X, Y, Z, u, v, w, length=10, normalize=True)
set_axes_equal(ax)
plt.show()
3dSpatialUser
  • 2,034
  • 1
  • 9
  • 18
  • can you suggest a sanity check for it also, something that for example either draws an arrow that shows the normal vector or a simple example that gives a sense about the correctness of the result? – Alejandro Jan 19 '22 at 12:44
  • 1
    can you also suggest a way so that all normal vectors come out from one side of the surface? – Alejandro Jan 19 '22 at 15:09
  • I have edited my answer. For the direction of the vectors you can calculate the angle with the x,y plane and for example choose to only use 'z' positive (thus if 'z' is negative you can flip the vector). – 3dSpatialUser Jan 19 '22 at 15:42
  • I have added the part to give the normals some direction. One can make it simpled by checking if there is a z value smaller than zero, but this method maybe shows a more generic way to solve this, for example if you want all normals relative to a plane. If this solves your questions please mark this question as solved. – 3dSpatialUser Jan 19 '22 at 15:58
  • 1
    Thank you for your comprehensive and well explained answer:) it was very helpful including the set_axes_equal :) – Alejandro Jan 20 '22 at 14:36
  • 1
    I was confused because it looked like the answers were not correct, but then I thought about the axes and the skewed perspective. Therefore I included the link to the answer hat helped me. Also I increased the number of user points to 5, because some time the closest points were on 1 line since the example produces points distributed exactly in a grid. You can change it back to 3 points as the original answer if you like, but that was the reason to change the number of points which were used. – 3dSpatialUser Jan 20 '22 at 14:41
1

The surface normal for a point cloud is not well defined. One way to define them is from the surface normal of a reconstructed mesh using triangulation (which can introduce artefacts regarding you specific input). A relatively simple and fast solution is to use VTK to do that, and more specifically, vtkSurfaceReconstructionFilter and vtkPolyDataNormals . Regarding your needs, it might be useful to apply other filters.

Jérôme Richard
  • 41,678
  • 6
  • 29
  • 59