===== Point Cloud Tools: Algorithms and Math ===== **Note:** This tutorial is based on the plugin "PointCloud" that you can get either through the Plugin manager (within Groimp) or by downloading the jar [[https://gitlab.com/grogra/groimp-plugins/pointcloud|here]]. Because formulas can not be displayed here efficiently, we would recommend to read the belonging practical reports for more information about the math behind the point cloud features. In case of interest, please contact the GroIMP developer team. This overview contains the most important algorithms that have been used for the point cloud-to-object fitting features. Here, the algorithms are written in pseudocode. The java implementation of each algorithm can be found in the belonging java code (project IMP-3D, class ''de.grogra.imp3d.pointcloud.PointCloudFittingTools.java''). In the pseudocode examples, some further functions are used: The function ''GET_CENTER_POINT'' requires a list of 3D points and returns a new point with the average position of all given points. The function ''GET_POINT_POINT_DISTANCE'' requires two 3D points and returns the pythagorean distance between these two points. The function ''GET_LINE_POINT_DISTANCE'' requires a position vector of a 3D line, a direction vector of a 3D line, and a 3D point. It returns the minimum distance between the point and the line. The function ''GET_PLANE_POINT_DISTANCE'' requires a position vector of a 3D plane, a normal vector of a 3D plane, and a 3D point. It returns the minimum distance between the point and the plane. The function ''GET_SURFACE_POINT_DISTANCE'' requires a 3D object (a sphere, a cylinder, a frustum or a cone), and a 3D point. It returns the minimum distance between the point and the objects surface. The function ''SET_VECTOR_LENGTH'' requires a vector and a numeric value. It multiplies all components of the vector, so that the pythagorean length of the vector is as long as the given numeric value. The function ''LENGTH'' requires a vector and returns the pythagorean length of the vector. The function ''CREATE_POINT'' requires three values (x, y, and z value) and returns a point object with the given coordinates. The function ''CREATE_SPHERE'' requires a position vector and a radius. It returns a sphere object. The function ''CREATE_CYLINDER'' requires a position vector, a direction vector, a radius, and a length. It returns a cylinder object. The function ''CREATE_FRUSTUM'' requires a position vector, a direction vector, a base radius, a top radius, and a length. It returns a frustum object. The function ''CREATE_CONE'' requires a position vector, a direction vector, a base radius, and a length. It returns a cone object. The function ''SIZE'' requires an array and returns its number of elements. The function ''SUM'' requires an array and returns the sum of all values in that array. The function ''VOLUME'' requires a sphere, a cylinder, a frustum, or a cone and returns its volume. The function ''AREA'' requires a sphere, a cylinder, a frustum, or a cone and returns its surface area. The functions ''SQRT'', ''ABS'', ''SIN'' and ''COS'' require a numeric value and return the square root, the absolute value, the sinus value, and the cosinus value of the parameter. ==== Generating a Fibonacci sphere ==== A Fibonacci sphere is a list of points, distributed around a sphere surface as evenly as possible. This function is used to create such a sphere. The center position, the radius and the number of points must be provided as parameter. With more points, the sphere gets more precise and the result of the object fitting gets more accurate. On the other hand, a higher precision requires a longer calculation time. FUNCTION CREATE_FIBONACCI_SPHERE(center, radius, number) points := [number] phi := PI * (3 - SQRT(5)) index := 0 WHILE (index < number) y := radius - (index / (number - 1)) * 2 * radius temporaryRadius := SQRT(radius^2 - y^2) theta := phi * index x := COS(theta) * temporaryRadius z := SIN(theta) * temporaryRadius points[index] := CREATE_POINT(center_x + x, center_y + y, center_z + z) index := index + 1 END RETURN points END ==== Calculating a score to compare objects ==== The score function is one of the most important parts of the fitting algorithm. The object fitting works with creating lots of potential objects and comparing them with the original point cloud. Due to this, the quality of the result of the fitting algorithm is mostly based on this score function. To calculate a score for an object and a point cloud, the average distance of all points to the objects surface is calculated first. The distance is always handled as absolute value. Points with a large distance to the object lead to a worse score and points in the center of the object do also lead to a worse score. In practice, large point clouds can result in a nearly perfect score, if all points are near to the theoretical object surface, but most parts of the surface are not used. This phenomena can happen if a small point cloud has only points on a surface of a much larger object. To prevent this wrong result from being good, the volume and the surface area of the object are added to the score. This has the effect that objects with a small volume and a small surface area are preferred in contrast to huge ones with only some point cloud points in the middle of the surface. FUNCTION GET_SCORE(points[], object) score := 0 FOR (point : points) distance := GET_SURFACE_POINT_DISTANCE(object, point) score := score + ABS(distance) END RETURN score / SIZE(points) + VOLUME(object) + AREA(object) END ==== Fitting a cylinder with principal component analysis ==== The principal component analysis was the first attempt (in this practical course) to fit a cylinder to a point cloud. It expects a point cloud (or a list of points) and calculates the center position of the point cloud as well as the distance between the two most distant points. The longest distance is used as direction vector (''firstDirection'') and principal component of the analyzed point cloud. With this direction vector, the second direction (''secondDirection'') is calculated so that it is orthogonal to the first direction vector and targets the point with the most far distance to the first direction vector. With the cross product of the first direction vector and the second direction vector, the third direction vector (''thirdDirection'') is calculated. For all three direction vectors, a cylinder is calculated so that it directs into the respective direction. For each of the cylinders, a score is calculated. The cylinder with the least score is returned. FUNCTION FIT_CYLINDER_WITH_PRINCIPAL_COMPONENT_ANALYSIS(points[]) center := GET_CENTER_POINT(points) mostFarPoint := NULL mostFarDistance := 0 FOR (point : points) distance := GET_POINT_POINT_DISTANCE(point, center) IF (distance > mostFarDistance) mostFarPoint := point mostFarDistance := distance END END firstDirection := mostFarPoint - center orthogonalMostFarPoint := NULL orthogonalMostFarDistance := 0 FOR (point : points) distance := GET_LINE_POINT_DISTANCE(center, firstDirection, point) IF (distance > orthogonalMostFarDistance) orthogonalMostFarPoint := point orthogonalMostFarDistance := distance END END diagonalVector := orthogonalMostFarPoint - center diagonalDistance := LENGTH(diagonalVector) distanceOnLine := SQRT(diagonalDistance^2 - orthogonalMostFarDistance^2) negative := firstDirection * diagonalVector SET_VECTOR_LENGTH(newVector, (negative < 0 ? -1 : 1) * distanceOnLine) footPoint := center + newVector secondDirection := orthogonalMostFarPoint - footPoint thirdDirection := firstDirection X secondDirection // X is the cross product thirdMostFarDistance := 0 FOR (point : points) distance := GET_PLANE_POINT_DISTANCE(center, thirdDirection, point) IF (distance > thirdMostFarDistance) thirdMostFarDistance := distance END END SET_VECTOR_LENGTH(thirdDirection, thirdMostFarDistance) xLength := LENGTH(firstDirection) yLength := LENGTH(secondDirection) zLength := LENGTH(thirdDirection) xPosition := center - firstDirection yPosition := center - secondDirection zPosition := center - thirdDirection xAverage := (yLength + zLength) / 2 yAverage := (xLength + zLength) / 2 zAverage := (xLength + yLength) / 2 xCylinder := CREATE_CYLINDER(xPosition, firstDirection, xAverage, 2 * xLength) yCylinder := CREATE_CYLINDER(yPosition, secondDirection, yAverage, 2 * yLength) zCylinder := CREATE_CYLINDER(zPosition, thirdDirection, zAverage, 2 * zLength) xScore := GET_SCORE(points, xCylinder) yScore := GET_SCORE(points, yCylinder) zScore := GET_SCORE(points, zCylinder) IF (xScore < yScore AND xScore < zScore) RETURN xCylinder ELSE IF (yScore < zScore) RETURN yCylinder ELSE RETURN zCylinder END END ==== Fitting a cylinder with Fibonacci sphere analysis ==== The cylinder fitting algorithm with Fibonacci sphere analysis is the final solution for the object fitting feature in GroIMP. It is split into two algorithms. The main algorithm creates a Fibonacci sphere around the point cloud. The Fibonacci sphere has a specific number of points (= precision). The algorithm creates a cylinder for each point of the Fibonacci sphere and calculates the score for each cylinder. Finally, the scores are compared and the cylinder with the best score is returned. The cylinder fitting function is later used for the frustum fitting and the cone fitting. This function provides the most basic information about the point cloud. It returns the position vector and the direction vector. Later, for the frustums, two different radii can be calculated, based on the cylinder returned by this function. The cone fitting is based on frustum fitting, but with a custom base radius and an adapted length. By reusing this algorithm for the other kinds of objects, lots of redundant implementation can be avoided. FUNCTION FIT_CYLINDER_WITH_FIBONACCI_SPHERE_ANALYSIS(points[], precision) center := GET_CENTER_POINT(points) mostFarPoint := NULL mostFarDistance := 0 FOR (point : points) distance := GET_POINT_POINT_DISTANCE(point, center) IF (distance > mostFarDistance) mostFarPoint := point mostFarDistance := distance END END sphere := CREATE_FIBONACCI_SPHERE(center, mostFarDistance, precision score := INFINITY bestCylinder := NULL FOR (point : sphere) direction := point - center cylinder := FIT_CYLINDER_TO_POINT_WITH_GIVEN_DIRECTION(points, direction) cylinderScore := GET_SCORE(cylinder) IF (cylinderScore < score) score := cylinderScore bestCylinder := cylinder END END RETURN bestCylinder END The main function of the algorithm to fit a cylinder to a point cloud, using the Fibonacci sphere, is also using this function to create a cylinder for a given point cloud and a given direction vector. First, this function requests the center point of the point cloud and uses it for the calculation of the position vector of the returned cylinder. After that, the radius and the length for the cylinder can be calculated with the given direction vector. The returned cylinder is not the finally returned cylinder of the fitting algorithm. It is only used for the test case that tests how good the cylinder is. FUNCTION FIT_CYLINDER_TO_POINT_WITH_GIVEN_DIRECTION(points[], direction) center := GET_CENTER_POINT(points) length := 0 radius := 0 FOR (point : points) distanceToPlane := GET_LINE_POINT_DISTANCE(center, direction, point) distanceToNormal := GET_PLANE_POINT_DISTANCE(center, direction, point) IF (distanceToPlane < length) length := distanceToPlane END IF (distanceToNormal < radius) radius := distanceToNormal END END lengthVector := direction SET_VECTOR_LENGTH(lengthVector, length) position := center - lengthVector RETURN CREATE_CYLINDER(position, direction, radius, 2 * length) END ==== Calculating a linear regression for frustums and cones ==== The linear regression function is used to calculate the angle of the side surface of a frustum, using a given cylinder. The idea of this algorithm is to use the positions of the points in the point cloud as data set and the already fitted cylinder as coordinate system. The direction vector of the cylinder represents the x-axis, while the base surface (in all directions around the direction vector) represents the y-axis. This has the advantage that the distance of each point in the point cloud to the direction vector of the cylinder can be calculated and used as y-value. It is then independent from the angle around the direction vector of the cylinder. The distance to the base surface is then used as x-value. This function returns an array with two values. The first value is the slope of the linear regression, the second one is the intercept. FUNCTION LINEAR_REGRESSION(cylinder, points[]) position := cylinder.position direction := cylinder.direction positions := [SIZE(points)] values := [SIZE(points)] index := 0 FOR (point : points) positions[index] := GET_PLANE_POINT_DISTANCE(position, direction, point) values[index] := GET_LINE_POINT_DISTANCE(position, direction, point) index := index + 1 END RETURN LINEAR_REGRESSION(positions, values) END Internally, the linear regression is done with raw x- and y-values. They are extracted from the given cylinder and the given point cloud and provided to this function. For the linear regression, the center x-y-center position of the data set is calculated. Then, the average slope is calculated by analyzing the local slopes between each data set. Finally, the intercept can be calculated and the regression parameters can be returned. The returned value is an array that contains the slope in the first field and the intercept in the second field. FUNCTION LINEAR_REGRESSION(positions[], values[]) sumX := SUM(positions) sumY := SUM(values) averageX := sumX / SIZE(positions) averageY := sumY / SIZE(values) sumX := 0 sumY := 0 index := 0 number := SIZE(positions) WHILE (index < number) difference := positions[index] - averageX sumX := sumX + difference * (positions[index] - averageX) sumY := sumY + difference * (values[index] - averageY) index := index + 1 END slope := sumY / sumX intercept := averageY - slope * averageX RETURN {slope, intercept} END ==== Fitting spheres to point clouds ==== A sphere with a maximum radius is fitted to a point cloud in two steps. The first step is to calculate the center position of the point cloud. In the second step, the point with the maximum distance to the center position is choosed and stored. The returned sphere is then located at the center position and has a radius, specified by the maximum distance. FUNCTION FIT_SPHERE_MAXIMUM(points[]) center := GET_CENTER_POINT(points) radius := 0 FOR (point : points) distance := GET_POINT_POINT_DISTANCE(center, point) IF (distance > radius) radius := distance END END RETURN CREATE_SPHERE(center, radius) END A sphere with an average radius is also fitted to a point cloud in two steps. The first step is to calculate the center position of the point cloud. In the second step, the average distance of all points to the center position is calculated and stored. The returned sphere is then located at the center position and has a radius, specified by the average distance. FUNCTION FIT_SPHERE_AVERAGE(points[]) center := GET_CENTER_POINT(points) sum := 0 FOR (point : points) sum := sum + GET_POINT_POINT_DISTANCE(center, point) END radius := sum / SIZE(points) RETURN CREATE_SPHERE(center, radius) END ==== Fitting cylinders to point clouds ==== The cylinder fitting with maximum radius is already done by the algorithm that fits a cylinder by using the Fibonacci sphere analysis. This function is only a mapping function (for completeness in this overview). FUNCTION FIT_CYLINDER_MAXIMUM(points[], precision) RETURN FIT_CYLINDER_WITH_FIBONACCI_SPHERE_ANALYSIS(points, precision) END The cylinder fitting with average radius is a bit more interesting. It first generates a cylinder with maximum radius. After that, it uses the direction vector of the cylinder as x-axis and the distance of each point of the point cloud to the direction vector as y-value and calculates the average radius. Finally, the radius of the found cylinder is changed to the average radius and the cylinder is returned. FUNCTION FIT_CYLINDER_AVERAGE(points[], precision) cylinder := FIT_CYLINDER_MAXIMUM(points, precision) position := cylinder.position direction := cylinder.direction radiusSum := 0 FOR (point : points) radiusSum = radiusSum + GET_LINE_POINT_DISTANCE(position, direction, point) END radius := radiusSum / SIZE(points) RETURN CREATE_CYLINDER(position, direction, radius, cylinder.length) END ==== Fitting frustums to point clouds ==== To fit a frustum with average radius is a bit more complex. The first step is to generate a cylinder. The position vector, the direction vector, and the length are then extracted from the cylinder and reused for the returned frustum. To get the base radius and the top radius, a linear regression is executed with the points of the point cloud, oriented on the direction vector of the cylinder. To get the radii, the y-values of the linear regressions are calculated for the x-values 0 (base surface) and length (top surface). The resulting y-values are then used as base radius and top radius of the returned frustum. In a second step, the frustum is flipped if the base radius is smaller than the top radius. This ensures that frustums do always have a logically deterministic direction. FUNCTION FIT_FRUSTUM_AVERAGE(points[], precision) cylinder := FIT_CYLINDER_AVERAGE(points, precision) regression := LINEAR_REGRESSION(cylinder, points) slope := regression[0] intercept := regression[1] position := cylinder.position direction := cylinder.direction length := cylinder.length baseRadius := slope * 0 + intercept topRadius := slope * length + intercept IF (baseRadius < topRadius) vector := direction SET_VECTOR_LENGTH(vector, length) position := position + vector direction := -1 * direction temporary := topRadius topRadius := baseRadius baseRadius := temporary END RETURN CREATE_FRUSTUM(position, direction, baseRadius, topRadius, length) END The frustum fitting with maximum radius is based on the respective fitting algorithm with average radius. This algorithm searches for the point in the point cloud with the maximum distance to the local position that would have been calculated with linear regression. By doing this, an additional value for the radius can be calculated and added to the existing radius. It is then added to the base radius and to the top radius to get a frustum with the same angle as before, but with the surface on the most-outside point. FUNCTION FIT_FRUSTUM_MAXIMUM(points[], precision) frustum := FIT_FRUSTUM_AVERAGE(points, precision) position := cylinder.position direction := cylinder.direction maximumDistance := 0 FOR (point : points) distanceToPrincipalAxis := GET_LINE_POINT_DISTANCE(position, direction, point) distanceToBasePlane := GET_PLANE_POINT_DISTANCE(position, direction, point) distance := distanceToPrincipalAxis - (slope * distanceToBasePlane + intercept) IF (distance > maximumDistance) maximumDistance := distance END END frustum.baseRadius := frustum.baseRadius + maximumDistance frustum.topRadius := frustum.topRadius + maximumDistance RETURN frustum END ==== Fitting cones to point clouds ==== The calculation of a cone with average radius is based on the calculation of a frustum with average radius. This function generates a frustum and scales the length so that the angle of the frustum side surface is kept. FUNCTION FIT_CONE_AVERAGE(points[], precision) frustum := FIT_FRUSTUM_AVERAGE(points, precision) ratio := frustum.baseRadius / frustum.topRadius additionalLength := frustum.length / (ratio - 1) coneLength := additionalLength + frustum.length radius := frustum.baseRadius RETURN CREATE_CONE(frustum.position, frustum.direction, radius, coneLength) END The calculation of a cone with maximum radius is also based on the calculation of a frustum with maximum radius. This function also generates a frustum and scales the length so that the angle of the frustum side surface is kept. FUNCTION FIT_CONE_MAXIMUM(points[], precision) frustum := FIT_FRUSTUM_MAXIMUM(points, precision) ratio := frustum.baseRadius / frustum.topRadius additionalLength := frustum.length / (ratio - 1) coneLength := additionalLength + frustum.length radius := frustum.baseRadius RETURN CREATE_CONE(frustum.position, frustum.direction, radius, coneLength) END ==== Fitting automatic objects to point clouds ==== The automatic fitting function is the most powerful function in the set of point cloud fitting algorithms. It generates one object of each type (a sphere, a cylinder, a frustum, and a cone) and compares them by their score. The object with the best score is returned. This function also considers the average/maximum mode. Two special cases have to be considered for frustums. In some cylindric or conical test point clouds, only frustums were returned during the debugging phase. This effect appears because no objects are perfect in nature. Cylinders are not perfect. Their radius on the top end and on the bottom end are slightly different. This brings the algorithm to always return a frustum for natural cylinders. To avoid this, a frustum is only returned, if the radii differ by at least 5%. For smaller differences, a cylinder is returned. Cones are also not perfect. The algorithm will only return a cone, if the tip is represented by a point. Due to floating point unprecision, this is nearly impossible for natural cones. To avoid this effect, a cone is returned if the top radius of the frustum has 5% of the base radius or less. If the top radius is larger, a frustum is returned. FUNCTION FIT_AUTOMATIC_OBJECT(points[], precision, average) IF (average) sphereObject := FIT_SPHERE_AVERAGE(points) cylinderObject := FIT_CYLINDER_AVERAGE(points, precision) frustumObject := FIT_FRUSTUM_AVERAGE(points, precision) coneObject := FIT_CONE_AVERAGE(points, precision) ELSE sphereObject := FIT_SPHERE_MAXIMUM(points) cylinderObject := FIT_CYLINDER_MAXIMUM(points, precision) frustumObject := FIT_FRUSTUM_MAXIMUM(points, precision) coneObject := FIT_CONE_MAXIMUM(points, precision) END sphere := GET_SCORE(sphereObject, points) cylinder := GET_SCORE(cylinderObject, points) frustum := GET_SCORE(frustumObject, points) cone := GET_SCORE(coneObject, points) IF (sphere < cylinder && sphere < frustum && sphere < cone) RETURN sphereObject ELSE IF (cylinder < frustum && cylinder < cone) RETURN cylinderObject ELSE IF (frustum < cone) IF (frustum.topRadius < frustum.baseRadius * 0.1) RETURN coneObject ELSE IF (frustum.topRadius > frustum.baseRadius * 0.9) RETURN cylinderObject ELSE RETURN frustumObject END ELSE RETURN coneObject END END