""" registration.py --------------- Functions for registering (aligning) point clouds with meshes. """ import numpy as np from . import util from . import bounds from . import transformations from .transformations import transform_points try: from scipy.spatial import cKDTree except BaseException as E: # wrapping just ImportError fails in some cases # will raise the error when someone tries to use KDtree from . import exceptions cKDTree = exceptions.closure(E) def mesh_other(mesh, other, samples=500, scale=False, icp_first=10, icp_final=50): """ Align a mesh with another mesh or a PointCloud using the principal axes of inertia as a starting point which is refined by iterative closest point. Parameters ------------ mesh : trimesh.Trimesh object Mesh to align with other other : trimesh.Trimesh or (n, 3) float Mesh or points in space samples : int Number of samples from mesh surface to align scale : bool Allow scaling in transform icp_first : int How many ICP iterations for the 9 possible combinations of sign flippage icp_final : int How many ICP iterations for the closest candidate from the wider search Returns ----------- mesh_to_other : (4, 4) float Transform to align mesh to the other object cost : float Average squared distance per point """ def key_points(m, count): """ Return a combination of mesh vertices and surface samples with vertices chosen by likelihood to be important to registation. """ if len(m.vertices) < (count / 2): return np.vstack(( m.vertices, m.sample(count - len(m.vertices)))) else: return m.sample(count) if not util.is_instance_named(mesh, 'Trimesh'): raise ValueError('mesh must be Trimesh object!') inverse = True search = mesh # if both are meshes use the smaller one for searching if util.is_instance_named(other, 'Trimesh'): if len(mesh.vertices) > len(other.vertices): # do the expensive tree construction on the # smaller mesh and query the others points search = other inverse = False points = key_points(m=mesh, count=samples) points_mesh = mesh else: points_mesh = other points = key_points(m=other, count=samples) if points_mesh.is_volume: points_PIT = points_mesh.principal_inertia_transform else: points_PIT = points_mesh.bounding_box_oriented.principal_inertia_transform elif util.is_shape(other, (-1, 3)): # case where other is just points points = other points_PIT = bounds.oriented_bounds(points)[0] else: raise ValueError('other must be mesh or (n, 3) points!') # get the transform that aligns the search mesh principal # axes of inertia with the XYZ axis at the origin if search.is_volume: search_PIT = search.principal_inertia_transform else: search_PIT = search.bounding_box_oriented.principal_inertia_transform # transform that moves the principal axes of inertia # of the search mesh to be aligned with the best- guess # principal axes of the points search_to_points = np.dot(np.linalg.inv(points_PIT), search_PIT) # permutations of cube rotations # the principal inertia transform has arbitrary sign # along the 3 major axis so try all combinations of # 180 degree rotations with a quick first ICP pass cubes = np.array([np.eye(4) * np.append(diag, 1) for diag in [[1, 1, 1], [1, 1, -1], [1, -1, 1], [-1, 1, 1], [-1, -1, 1], [-1, 1, -1], [1, -1, -1], [-1, -1, -1]]]) # loop through permutations and run iterative closest point costs = np.ones(len(cubes)) * np.inf transforms = [None] * len(cubes) centroid = search.centroid for i, flip in enumerate(cubes): # transform from points to search mesh # flipped around the centroid of search a_to_b = np.dot( transformations.transform_around(flip, centroid), np.linalg.inv(search_to_points)) # run first pass ICP matrix, junk, cost = icp(a=points, b=search, initial=a_to_b, max_iterations=int(icp_first), scale=scale) # save transform and costs from ICP transforms[i] = matrix costs[i] = cost # run a final ICP refinement step matrix, junk, cost = icp(a=points, b=search, initial=transforms[np.argmin(costs)], max_iterations=int(icp_final), scale=scale) # convert to per- point distance average cost /= len(points) # we picked the smaller mesh to construct the tree # on so we may have calculated a transform backwards # to save computation, so just invert matrix here if inverse: mesh_to_other = np.linalg.inv(matrix) else: mesh_to_other = matrix return mesh_to_other, cost def procrustes(a, b, reflection=True, translation=True, scale=True, return_cost=True): """ Perform Procrustes' analysis subject to constraints. Finds the transformation T mapping a to b which minimizes the square sum distances between Ta and b, also called the cost. Parameters ---------- a : (n,3) float List of points in space b : (n,3) float List of points in space reflection : bool If the transformation is allowed reflections translation : bool If the transformation is allowed translations scale : bool If the transformation is allowed scaling return_cost : bool Whether to return the cost and transformed a as well Returns ---------- matrix : (4,4) float The transformation matrix sending a to b transformed : (n,3) float The image of a under the transformation cost : float The cost of the transformation """ a = np.asanyarray(a, dtype=np.float64) b = np.asanyarray(b, dtype=np.float64) if not util.is_shape(a, (-1, 3)) or not util.is_shape(b, (-1, 3)): raise ValueError('points must be (n,3)!') if len(a) != len(b): raise ValueError('a and b must contain same number of points!') # Remove translation component if translation: acenter = a.mean(axis=0) bcenter = b.mean(axis=0) else: acenter = np.zeros(a.shape[1]) bcenter = np.zeros(b.shape[1]) # Remove scale component if scale: ascale = np.sqrt(((a - acenter)**2).sum() / len(a)) bscale = np.sqrt(((b - bcenter)**2).sum() / len(b)) else: ascale = 1 bscale = 1 # Use SVD to find optimal orthogonal matrix R # constrained to det(R) = 1 if necessary. u, s, vh = np.linalg.svd( np.dot(((b - bcenter) / bscale).T, ((a - acenter) / ascale))) if reflection: R = np.dot(u, vh) else: R = np.dot(np.dot(u, np.diag( [1, 1, np.linalg.det(np.dot(u, vh))])), vh) # Compute our 4D transformation matrix encoding # a -> (R @ (a - acenter)/ascale) * bscale + bcenter # = (bscale/ascale)R @ a + (bcenter - (bscale/ascale)R @ acenter) translation = bcenter - (bscale / ascale) * np.dot(R, acenter) matrix = np.hstack((bscale / ascale * R, translation.reshape(-1, 1))) matrix = np.vstack( (matrix, np.array([0.] * (a.shape[1]) + [1.]).reshape(1, -1))) if return_cost: transformed = transform_points(a, matrix) cost = ((b - transformed)**2).mean() return matrix, transformed, cost else: return matrix def icp(a, b, initial=np.identity(4), threshold=1e-5, max_iterations=20, **kwargs): """ Apply the iterative closest point algorithm to align a point cloud with another point cloud or mesh. Will only produce reasonable results if the initial transformation is roughly correct. Initial transformation can be found by applying Procrustes' analysis to a suitable set of landmark points (often picked manually). Parameters ---------- a : (n,3) float List of points in space. b : (m,3) float or Trimesh List of points in space or mesh. initial : (4,4) float Initial transformation. threshold : float Stop when change in cost is less than threshold max_iterations : int Maximum number of iterations kwargs : dict Args to pass to procrustes Returns ---------- matrix : (4,4) float The transformation matrix sending a to b transformed : (n,3) float The image of a under the transformation cost : float The cost of the transformation """ a = np.asanyarray(a, dtype=np.float64) if not util.is_shape(a, (-1, 3)): raise ValueError('points must be (n,3)!') is_mesh = util.is_instance_named(b, 'Trimesh') if not is_mesh: b = np.asanyarray(b, dtype=np.float64) if not util.is_shape(b, (-1, 3)): raise ValueError('points must be (n,3)!') btree = cKDTree(b) # transform a under initial_transformation a = transform_points(a, initial) total_matrix = initial # start with infinite cost old_cost = np.inf # avoid looping forever by capping iterations for n_iteration in range(max_iterations): # Closest point in b to each point in a if is_mesh: closest, distance, faces = b.nearest.on_surface(a) else: distances, ix = btree.query(a, 1) closest = b[ix] # align a with closest points matrix, transformed, cost = procrustes(a=a, b=closest, **kwargs) # update a with our new transformed points a = transformed total_matrix = np.dot(matrix, total_matrix) if old_cost - cost < threshold: break else: old_cost = cost return total_matrix, transformed, cost