Coverage for trimesh/registration.py: 93%
399 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1"""
2registration.py
3---------------
5Functions for registering (aligning) point clouds with meshes.
6"""
8import numpy as np
10from . import bounds, transformations, util
11from .geometry import weighted_vertex_normals
12from .points import PointCloud, plane_fit
13from .transformations import transform_points
14from .triangles import angles, cross, normals
15from .typed import ArrayLike, Integer
17try:
18 import scipy.sparse as sparse
19 from scipy.spatial import cKDTree
20except BaseException as E:
21 # wrapping just ImportError fails in some cases
22 # will raise the error when someone tries to use KDtree
23 from . import exceptions
25 cKDTree = exceptions.ExceptionWrapper(E)
26 sparse = exceptions.ExceptionWrapper(E)
29def mesh_other(
30 mesh,
31 other,
32 samples: Integer = 500,
33 scale: bool = False,
34 icp_first: Integer = 10,
35 icp_final: Integer = 50,
36 **kwargs,
37):
38 """
39 Align a mesh with another mesh or a PointCloud using
40 the principal axes of inertia as a starting point which
41 is refined by iterative closest point.
43 Parameters
44 ------------
45 mesh : trimesh.Trimesh object
46 Mesh to align with other
47 other : trimesh.Trimesh or (n, 3) float
48 Mesh or points in space
49 samples : int
50 Number of samples from mesh surface to align
51 scale : bool
52 Allow scaling in transform
53 icp_first : int
54 How many ICP iterations for the 9 possible
55 combinations of sign flippage
56 icp_final : int
57 How many ICP iterations for the closest
58 candidate from the wider search
59 kwargs : dict
60 Passed through to `icp`, which passes through to `procrustes`
62 Returns
63 -----------
64 mesh_to_other : (4, 4) float
65 Transform to align mesh to the other object
66 cost : float
67 Average squared distance per point
68 """
70 def key_points(m, count):
71 """
72 Return a combination of mesh vertices and surface samples
73 with vertices chosen by likelihood to be important
74 to registration.
75 """
76 if len(m.vertices) < (count / 2):
77 return np.vstack((m.vertices, m.sample(count - len(m.vertices))))
78 else:
79 return m.sample(count)
81 if not util.is_instance_named(mesh, "Trimesh"):
82 raise ValueError("mesh must be Trimesh object!")
84 inverse = True
85 search = mesh
86 # if both are meshes use the smaller one for searching
87 if util.is_instance_named(other, "Trimesh"):
88 if len(mesh.vertices) > len(other.vertices):
89 # do the expensive tree construction on the
90 # smaller mesh and query the others points
91 search = other
92 inverse = False
93 points = key_points(m=mesh, count=samples)
94 points_mesh = mesh
95 else:
96 points_mesh = other
97 points = key_points(m=other, count=samples)
99 if points_mesh.is_volume:
100 points_PIT = points_mesh.principal_inertia_transform
101 else:
102 points_PIT = points_mesh.bounding_box_oriented.principal_inertia_transform
104 elif util.is_shape(other, (-1, 3)):
105 # case where other is just points
106 points = other
107 points_PIT = bounds.oriented_bounds(points)[0]
108 else:
109 raise ValueError("other must be mesh or (n, 3) points!")
111 # get the transform that aligns the search mesh principal
112 # axes of inertia with the XYZ axis at the origin
113 if search.is_volume:
114 search_PIT = search.principal_inertia_transform
115 else:
116 search_PIT = search.bounding_box_oriented.principal_inertia_transform
118 # transform that moves the principal axes of inertia
119 # of the search mesh to be aligned with the best- guess
120 # principal axes of the points
121 search_to_points = np.dot(np.linalg.inv(points_PIT), search_PIT)
123 # permutations of cube rotations
124 # the principal inertia transform has arbitrary sign
125 # along the 3 major axis so try all combinations of
126 # 180 degree rotations with a quick first ICP pass
127 cubes = np.array(
128 [
129 np.eye(4) * np.append(diag, 1)
130 for diag in [
131 [1, 1, 1],
132 [1, 1, -1],
133 [1, -1, 1],
134 [-1, 1, 1],
135 [-1, -1, 1],
136 [-1, 1, -1],
137 [1, -1, -1],
138 [-1, -1, -1],
139 ]
140 ]
141 )
143 # loop through permutations and run iterative closest point
144 costs = np.ones(len(cubes)) * np.inf
145 transforms = [None] * len(cubes)
146 centroid = search.centroid
148 for i, flip in enumerate(cubes):
149 # transform from points to search mesh
150 # flipped around the centroid of search
151 a_to_b = np.dot(
152 transformations.transform_around(flip, centroid),
153 np.linalg.inv(search_to_points),
154 )
156 # run first pass ICP
157 matrix, _junk, cost = icp(
158 a=points,
159 b=search,
160 initial=a_to_b,
161 max_iterations=int(icp_first),
162 scale=scale,
163 **kwargs,
164 )
166 # save transform and costs from ICP
167 transforms[i] = matrix
168 costs[i] = cost
170 # run a final ICP refinement step
171 matrix, _junk, cost = icp(
172 a=points,
173 b=search,
174 initial=transforms[np.argmin(costs)],
175 max_iterations=int(icp_final),
176 scale=scale,
177 **kwargs,
178 )
180 # convert to per- point distance average
181 cost /= len(points)
183 # we picked the smaller mesh to construct the tree
184 # on so we may have calculated a transform backwards
185 # to save computation, so just invert matrix here
186 if inverse:
187 mesh_to_other = np.linalg.inv(matrix)
188 else:
189 mesh_to_other = matrix
191 return mesh_to_other, cost
194def procrustes(
195 a: ArrayLike,
196 b: ArrayLike,
197 weights: ArrayLike | None = None,
198 reflection: bool = True,
199 translation: bool = True,
200 scale: bool = True,
201 return_cost: bool = True,
202):
203 """
204 Perform Procrustes' analysis to quickly align two corresponding
205 point clouds subject to constraints. This is much cheaper than
206 any other registration method but only applies if the two inputs
207 correspond in order.
209 Finds the transformation T mapping a to b which minimizes the
210 square sum distances between Ta and b, also called the cost.
212 Optionally filter the points in a and b via a binary weights array.
213 Non-uniform weights are also supported, but won't yield the optimal rotation.
215 Parameters
216 ----------
217 a : (n,3) float
218 List of points in space
219 b : (n,3) float
220 List of points in space
221 weights : (n,) float
222 List of floats representing how much weight is assigned to each point.
223 Binary entries can be used to filter the arrays; normalization is not required.
224 Translation and scaling are adjusted according to the weighting.
225 Note, however, that this method does not yield the optimal rotation for
226 non-uniform weighting,
227 as this would require an iterative, nonlinear optimization approach.
228 reflection : bool
229 If the transformation is allowed reflections
230 translation : bool
231 If the transformation is allowed translation and rotation.
232 scale : bool
233 If the transformation is allowed scaling
234 return_cost : bool
235 Whether to return the cost and transformed a as well
237 Returns
238 ----------
239 matrix : (4,4) float
240 The transformation matrix sending a to b
241 transformed : (n,3) float
242 The image of a under the transformation
243 cost : float
244 The cost of the transformation
245 """
247 a_original = np.asanyarray(a, dtype=np.float64)
248 b_original = np.asanyarray(b, dtype=np.float64)
249 if not util.is_shape(a_original, (-1, 3)) or not util.is_shape(b_original, (-1, 3)):
250 raise ValueError("points must be (n,3)!")
251 if len(a_original) != len(b_original):
252 raise ValueError("a and b must contain same number of points!")
253 # weights are set to uniform if not provided.
254 if weights is None:
255 weights = np.ones(len(a_original))
256 w = np.maximum(np.asanyarray(weights, dtype=np.float64), 0)
257 if len(w) != len(a):
258 raise ValueError("weights must have same length as a and b!")
259 w_norm = (w / w.sum()).reshape((-1, 1))
261 # All zero entries are removed from further computations.
262 # If weights is a binary array, the optimal solution can still be found by
263 # simply removing the zero entries.
264 nonzero_weights = w_norm[:, 0] > 0.0
265 a_nonzero = a_original[nonzero_weights]
266 b_nonzero = b_original[nonzero_weights]
267 w_norm = w_norm[nonzero_weights]
269 # Remove translation component
270 if translation:
271 # centers are (weighted) averages of the individual points.
272 acenter = (a_nonzero * w_norm).sum(axis=0)
273 bcenter = (b_nonzero * w_norm).sum(axis=0)
274 else:
275 acenter = np.zeros(a_nonzero.shape[1])
276 bcenter = np.zeros(b_nonzero.shape[1])
278 # Remove scale component
279 if scale:
280 # scale is the square root of the (weighted) average of the
281 # squared difference between each point and the center.
282 ascale = np.sqrt((((a_nonzero - acenter) ** 2) * w_norm).sum())
283 bscale = np.sqrt((((b_nonzero - bcenter) ** 2) * w_norm).sum())
284 else:
285 ascale = 1
286 bscale = 1
288 # Use SVD to find optimal orthogonal matrix R
289 # constrained to det(R) = 1 if necessary.
291 target = np.dot(((b_nonzero - bcenter) / bscale).T, ((a_nonzero - acenter) / ascale))
293 u, _s, vh = np.linalg.svd(target)
295 if reflection:
296 R = np.dot(u, vh)
297 else:
298 # no reflection allowed, so determinant must be 1.0
299 R = np.dot(np.dot(u, np.diag([1, 1, np.linalg.det(np.dot(u, vh))])), vh)
301 # Compute our 4D transformation matrix encoding
302 # a -> (R @ (a - acenter)/ascale) * bscale + bcenter
303 # = (bscale/ascale)R @ a + (bcenter - (bscale/ascale)R @ acenter)
304 translation = bcenter - (bscale / ascale) * np.dot(R, acenter)
305 matrix = np.hstack((bscale / ascale * R, translation.reshape(-1, 1)))
306 matrix = np.vstack((matrix, np.array([0.0] * (a.shape[1]) + [1.0]).reshape(1, -1)))
308 if return_cost:
309 # Transform the original input array, including zero-weighted points
310 transformed = transform_points(a_original, matrix)
311 # The cost is the (weighted) sum of the euclidean distances between
312 # the transformed source points and the target points.
313 cost = (((b_nonzero - transformed[nonzero_weights]) ** 2) * w_norm).sum()
314 return matrix, transformed, cost
316 return matrix
319def icp(a, b, initial=None, threshold=1e-5, max_iterations=20, **kwargs):
320 """
321 Apply the iterative closest point algorithm to align a point cloud with
322 another point cloud or mesh. Will only produce reasonable results if the
323 initial transformation is roughly correct. Initial transformation can be
324 found by applying Procrustes' analysis to a suitable set of landmark
325 points (often picked manually).
327 Parameters
328 ----------
329 a : (n,3) float
330 List of points in space.
331 b : (m,3) float or Trimesh
332 List of points in space or mesh.
333 initial : (4,4) float
334 Initial transformation.
335 threshold : float
336 Stop when change in cost is less than threshold
337 max_iterations : int
338 Maximum number of iterations
339 kwargs : dict
340 Args to pass to procrustes
342 Returns
343 ----------
344 matrix : (4,4) float
345 The transformation matrix sending a to b
346 transformed : (n,3) float
347 The image of a under the transformation
348 cost : float
349 The cost of the transformation
350 """
352 a = np.asanyarray(a, dtype=np.float64)
353 if not util.is_shape(a, (-1, 3)):
354 raise ValueError("points must be (n,3)!")
356 if initial is None:
357 initial = np.eye(4)
359 is_mesh = util.is_instance_named(b, "Trimesh")
360 if not is_mesh:
361 b = np.asanyarray(b, dtype=np.float64)
362 if not util.is_shape(b, (-1, 3)):
363 raise ValueError("points must be (n,3)!")
364 btree = cKDTree(b)
366 # transform a under initial_transformation
367 a = transform_points(a, initial)
368 total_matrix = initial
370 # start with infinite cost
371 old_cost = np.inf
373 # avoid looping forever by capping iterations
374 for _ in range(max_iterations):
375 # Closest point in b to each point in a
376 if is_mesh:
377 closest, _distance, _faces = b.nearest.on_surface(a)
378 else:
379 _distances, ix = btree.query(a, 1)
380 closest = b[ix]
382 # align a with closest points
383 matrix, transformed, cost = procrustes(a=a, b=closest, **kwargs)
385 # update a with our new transformed points
386 a = transformed
387 total_matrix = np.dot(matrix, total_matrix)
389 if old_cost - cost < threshold:
390 break
391 else:
392 old_cost = cost
394 return total_matrix, transformed, cost
397def _normalize_by_source(source_mesh, target_geometry, target_positions):
398 # Utility function to put the source mesh in [-1, 1]^3 and transform
399 # target geometry accordingly
400 if not util.is_instance_named(target_geometry, "Trimesh") and not isinstance(
401 target_geometry, PointCloud
402 ):
403 vertices = np.asanyarray(target_geometry)
404 target_geometry = PointCloud(vertices)
405 centroid, scale = source_mesh.centroid, source_mesh.scale
406 source_mesh.vertices = (source_mesh.vertices - centroid[None, :]) / scale
407 # Dont forget to also transform the target positions
408 target_geometry.vertices = (target_geometry.vertices - centroid[None, :]) / scale
409 if target_positions is not None:
410 target_positions = (target_positions - centroid[None, :]) / scale
411 return target_geometry, target_positions, centroid, scale
414def _denormalize_by_source(
415 source_mesh, target_geometry, target_positions, result, centroid, scale
416):
417 # Utility function to transform source mesh from
418 # [-1, 1]^3 to its original transform
419 # and transform target geometry accordingly
420 source_mesh.vertices = scale * source_mesh.vertices + centroid[None, :]
421 target_geometry.vertices = scale * target_geometry.vertices + centroid[None, :]
422 if target_positions is not None:
423 target_positions = scale * target_positions + centroid[None, :]
424 if isinstance(result, list):
425 result = [scale * x + centroid[None, :] for x in result]
426 else:
427 result = scale * result + centroid[None, :]
428 return result
431def nricp_amberg(
432 source_mesh,
433 target_geometry,
434 source_landmarks=None,
435 target_positions=None,
436 steps=None,
437 eps=0.0001,
438 gamma=1,
439 distance_threshold=0.1,
440 return_records=False,
441 use_faces=True,
442 use_vertex_normals=True,
443 neighbors_count=8,
444):
445 """
446 Non Rigid Iterative Closest Points
448 Implementation of "Amberg et al. 2007: Optimal Step
449 Nonrigid ICP Algorithms for Surface Registration."
450 Allows to register non-rigidly a mesh on another or
451 on a point cloud. The core algorithm is explained
452 at the end of page 3 of the paper.
454 Comparison between nricp_amberg and nricp_sumner:
455 * nricp_amberg fits to the target mesh in less steps
456 * nricp_amberg can generate sharp edges
457 * only vertices and their neighbors are considered
458 * nricp_sumner tend to preserve more the original shape
459 * nricp_sumner parameters are easier to tune
460 * nricp_sumner solves for triangle positions whereas
461 nricp_amberg solves for vertex transforms
462 * nricp_sumner is less optimized when wn > 0
464 Parameters
465 ----------
466 source_mesh : Trimesh
467 Source mesh containing both vertices and faces.
468 target_geometry : Trimesh or PointCloud or (n, 3) float
469 Target geometry. It can contain no faces or be a PointCloud.
470 source_landmarks : (n,) int or ((n,) int, (n, 3) float)
471 n landmarks on the the source mesh.
472 Represented as vertex indices (n,) int.
473 It can also be represented as a tuple of triangle
474 indices and barycentric coordinates ((n,) int, (n, 3) float,).
475 target_positions : (n, 3) float
476 Target positions assigned to source landmarks
477 steps : Core parameters of the algorithm
478 Iterable of iterables (ws, wl, wn, max_iter,).
479 ws is smoothness term, wl weights landmark importance, wn normal importance
480 and max_iter is the maximum number of iterations per step.
481 eps : float
482 If the error decrease if inferior to this value, the current step ends.
483 gamma : float
484 Weight the translation part against the rotational/skew part.
485 Recommended value : 1.
486 distance_threshold : float
487 Distance threshold to account for a vertex match or not.
488 return_records : bool
489 If True, also returns all the intermediate results. It can help debugging
490 and tune the parameters to match a specific case.
491 use_faces : bool
492 If True and if target geometry has faces, use proximity.closest_point to find
493 matching points. Else use scipy's cKDTree object.
494 use_vertex_normals : bool
495 If True and if target geometry has faces, interpolate the normals of the target
496 geometry matching points.
497 Else use face normals or estimated normals if target geometry has no faces.
498 neighbors_count : int
499 number of neighbors used for normal estimation. Only used if target geometry has
500 no faces or if use_faces is False.
502 Returns
503 ----------
504 result : (n, 3) float or list[(n, 3) float]
505 The vertices positions of source_mesh such that it is registered non-rigidly
506 onto the target geometry.
507 If return_records is True, it returns the list of the vertex positions at each
508 iteration.
509 """
511 def _solve_system(M_kron_G, D, vertices_weight, nearest, ws, nE, nV, Dl, Ul, wl):
512 # Solve for Eq. 12
513 U = nearest * vertices_weight[:, None]
514 use_landmarks = Dl is not None and Ul is not None
515 A_stack = [ws * M_kron_G, D.multiply(vertices_weight[:, None])]
516 B_shape = (4 * nE + nV, 3)
517 if use_landmarks:
518 A_stack.append(wl * Dl)
519 B_shape = (4 * nE + nV + Ul.shape[0], 3)
520 A = sparse.csr_matrix(sparse.vstack(A_stack))
521 B = sparse.lil_matrix(B_shape, dtype=np.float32)
522 B[4 * nE : (4 * nE + nV), :] = U
523 if use_landmarks:
524 B[4 * nE + nV : (4 * nE + nV + Ul.shape[0]), :] = Ul * wl
525 X = sparse.linalg.spsolve(A.T * A, A.T * B).toarray()
526 return X
528 def _node_arc_incidence(mesh, do_weight):
529 # Computes node-arc incidence matrix of mesh (Eq.10)
530 nV = mesh.edges.max() + 1
531 nE = len(mesh.edges)
532 rows = np.repeat(np.arange(nE), 2)
533 cols = mesh.edges.flatten()
534 data = np.ones(2 * nE, np.float32)
535 data[1::2] = -1
536 if do_weight:
537 edge_lengths = np.linalg.norm(
538 mesh.vertices[mesh.edges[:, 0]] - mesh.vertices[mesh.edges[:, 1]], axis=-1
539 )
540 data *= np.repeat(1 / edge_lengths, 2)
541 return sparse.coo_matrix((data, (rows, cols)), shape=(nE, nV))
543 def _create_D(vertex_3d_data):
544 # Create Data matrix (Eq. 8)
545 nV = len(vertex_3d_data)
546 rows = np.repeat(np.arange(nV), 4)
547 cols = np.arange(4 * nV)
548 data = np.concatenate((vertex_3d_data, np.ones((nV, 1))), axis=-1).flatten()
549 return sparse.csr_matrix((data, (rows, cols)), shape=(nV, 4 * nV))
551 def _create_X(nV):
552 # Create Unknowns Matrix (Eq. 1)
553 X_ = np.concatenate((np.eye(3), np.array([[0, 0, 0]])), axis=0)
554 return np.tile(X_, (nV, 1))
556 def _create_Dl_Ul(D, source_mesh, source_landmarks, target_positions):
557 # Create landmark terms (Eq. 11)
558 Dl, Ul = None, None
560 if source_landmarks is None or target_positions is None:
561 # If no landmarks are provided, return None for both
562 return Dl, Ul
564 if isinstance(source_landmarks, tuple):
565 source_tids, source_barys = source_landmarks
566 source_tri_vids = source_mesh.faces[source_tids]
567 # u * x1, v * x2 and w * x3 combined
568 Dl = D[source_tri_vids.flatten(), :]
569 Dl.data *= source_barys.flatten().repeat(np.diff(Dl.indptr))
570 x0 = source_mesh.vertices[source_tri_vids[:, 0]]
571 x1 = source_mesh.vertices[source_tri_vids[:, 1]]
572 x2 = source_mesh.vertices[source_tri_vids[:, 2]]
573 Ul0 = (
574 target_positions
575 - x1 * source_barys[:, 1, None]
576 - x2 * source_barys[:, 2, None]
577 )
578 Ul1 = (
579 target_positions
580 - x0 * source_barys[:, 0, None]
581 - x2 * source_barys[:, 2, None]
582 )
583 Ul2 = (
584 target_positions
585 - x0 * source_barys[:, 0, None]
586 - x1 * source_barys[:, 1, None]
587 )
588 Ul = np.zeros((Ul0.shape[0] * 3, 3))
589 Ul[0::3] = Ul0 # y - v * x2 + w * x3
590 Ul[1::3] = Ul1 # y - u * x1 + w * x3
591 Ul[2::3] = Ul2 # y - u * x1 + v * x2
592 else:
593 Dl = D[source_landmarks, :]
594 Ul = target_positions
595 return Dl, Ul
597 target_geometry, target_positions, centroid, scale = _normalize_by_source(
598 source_mesh, target_geometry, target_positions
599 )
601 # Number of edges and vertices in source mesh
602 nE = len(source_mesh.edges)
603 nV = len(source_mesh.vertices)
605 # Initialize transformed vertices
606 transformed_vertices = source_mesh.vertices.copy()
607 # Node-arc incidence (M in Eq. 10)
608 M = _node_arc_incidence(source_mesh, True)
609 # G (Eq. 10)
610 G = np.diag([1, 1, 1, gamma])
611 # M kronecker G (Eq. 10)
612 M_kron_G = sparse.kron(M, G)
613 # D (Eq. 8)
614 D = _create_D(source_mesh.vertices)
615 # D but for normal computation from the transformations X
616 DN = _create_D(source_mesh.vertex_normals)
617 # Unknowns 4x3 transformations X (Eq. 1)
618 X = _create_X(nV)
619 # Landmark related terms (Eq. 11)
620 Dl, Ul = _create_Dl_Ul(D, source_mesh, source_landmarks, target_positions)
622 # Parameters of the algorithm (Eq. 6)
623 # order : Alpha, Beta, normal weighting, and max iteration for step
624 if steps is None:
625 steps = [
626 [0.01, 10, 0.5, 10],
627 [0.02, 5, 0.5, 10],
628 [0.03, 2.5, 0.5, 10],
629 [0.01, 0, 0.0, 10],
630 ]
631 if return_records:
632 records = [transformed_vertices]
634 # Main loop
635 for ws, wl, wn, max_iter in steps:
636 # If normals are estimated from points and if there are less
637 # than 3 points per query, avoid normal estimation
638 if not use_faces and neighbors_count < 3:
639 wn = 0
641 last_error = np.finfo(np.float32).max
642 error = np.finfo(np.float16).max
643 cpt_iter = 0
645 # Current step iterations loop
646 while last_error - error > eps and (max_iter is None or cpt_iter < max_iter):
647 qres = _from_mesh(
648 target_geometry,
649 transformed_vertices,
650 from_vertices_only=not use_faces,
651 return_normals=wn > 0,
652 return_interpolated_normals=wn > 0 and use_vertex_normals,
653 neighbors_count=neighbors_count,
654 )
656 # Data weighting
657 vertices_weight = np.ones(nV)
658 vertices_weight[qres["distances"] > distance_threshold] = 0
660 if wn > 0 and "normals" in qres:
661 target_normals = qres["normals"]
662 if use_vertex_normals and "interpolated_normals" in qres:
663 target_normals = qres["interpolated_normals"]
664 # Normal weighting = multiplying weights by cosines^wn
665 source_normals = DN * X
666 dot = util.diagonal_dot(source_normals, target_normals)
667 # Normal orientation is only known for meshes as target
668 dot = np.clip(dot, 0, 1) if use_faces else np.abs(dot)
669 vertices_weight = vertices_weight * dot**wn
671 # Actual system solve
672 X = _solve_system(
673 M_kron_G, D, vertices_weight, qres["nearest"], ws, nE, nV, Dl, Ul, wl
674 )
675 transformed_vertices = D * X
676 last_error = error
677 error_vec = np.linalg.norm(qres["nearest"] - transformed_vertices, axis=-1)
678 error = (error_vec * vertices_weight).mean()
679 if return_records:
680 records.append(transformed_vertices)
681 cpt_iter += 1
683 if return_records:
684 result = records
685 else:
686 result = transformed_vertices
688 result = _denormalize_by_source(
689 source_mesh, target_geometry, target_positions, result, centroid, scale
690 )
691 return result
694def _from_mesh(
695 mesh,
696 input_points,
697 from_vertices_only=False,
698 return_barycentric_coordinates=False,
699 return_normals=False,
700 return_interpolated_normals=False,
701 neighbors_count=10,
702 **kwargs,
703):
704 """
705 Find the the closest points and associated attributes from a Trimesh.
707 Parameters
708 -----------
709 mesh : Trimesh
710 Trimesh from which the query is performed
711 input_points : (m, 3) float
712 Input query points
713 from_vertices_only : bool
714 If True, consider only the vertices and not the faces
715 return_barycentric_coordinates : bool
716 If True, return the barycentric coordinates
717 return_normals : bool
718 If True, compute the normals at each closest point
719 return_interpolated_normals : bool
720 If True, return the interpolated normal at each closest point
721 neighbors_count : int
722 The number of closest neighbors to query
723 kwargs : dict
724 Dict to accept other key word arguments (not used)
725 Returns
726 ----------
727 qres : dict
728 Dictionary containing :
729 - nearest points (m, 3) with key 'nearest'
730 - distances to nearest point (m,) with key 'distances'
731 - support triangle indices of the nearest points (m,) with key 'tids'
732 - [optional] normals at nearest points (m,3) with key 'normals'
733 - [optional] barycentric coordinates in support triangles (m,3) with key
734 'barycentric_coordinates'
735 - [optional] interpolated normals (m,3) with key 'interpolated_normals'
736 """
737 input_points = np.asanyarray(input_points)
738 neighbors_count = min(neighbors_count, len(mesh.vertices))
740 if from_vertices_only or len(mesh.faces) == 0:
741 # Consider only the vertices
742 return _from_points(
743 mesh.vertices,
744 input_points,
745 mesh.kdtree,
746 return_normals=return_normals,
747 neighbors_count=neighbors_count,
748 )
749 # Else if we consider faces, use proximity.closest_point
750 qres = {}
751 from .proximity import closest_point
752 from .triangles import points_to_barycentric
754 qres["nearest"], qres["distances"], qres["tids"] = closest_point(mesh, input_points)
756 if return_normals:
757 qres["normals"] = mesh.face_normals[qres["tids"]]
758 if return_barycentric_coordinates or return_interpolated_normals:
759 qres["barycentric_coordinates"] = points_to_barycentric(
760 mesh.vertices[mesh.faces[qres["tids"]]], qres["nearest"]
761 )
763 if return_interpolated_normals:
764 # Interpolation from barycentric coordinates
765 qres["interpolated_normals"] = np.einsum(
766 "ij,ijk->ik",
767 qres["barycentric_coordinates"],
768 mesh.vertex_normals[mesh.faces[qres["tids"]]],
769 )
770 return qres
773def _from_points(
774 target_points,
775 input_points,
776 kdtree=None,
777 return_normals=False,
778 neighbors_count=10,
779 **kwargs,
780):
781 """
782 Find the the closest points and associated attributes
783 from a set of 3D points.
785 Parameters
786 -----------
787 target_points : (n, 3) float
788 Points from which the query is performed
789 input_points : (m, 3) float
790 Input query points
791 kdtree : scipy.cKDTree
792 KDTree used for query. Computed if not provided
793 return_normals : bool
794 If True, compute the normals at each nearest point
795 neighbors_count : int
796 The number of closest neighbors to query
797 kwargs : dict
798 Dict to accept other key word arguments (not used)
800 Returns
801 ----------
802 qres : dict
803 Dictionary containing :
804 - nearest points (m, 3) with key 'nearest'
805 - distances to nearest point (m,) with key 'distances'
806 - vertex indices of the nearest points (m,) with key 'vertex_indices'
807 - [optional] normals at nearest points (m,3) with key 'normals'
808 """
809 # Empty result
810 target_points = np.asanyarray(target_points)
811 input_points = np.asanyarray(input_points)
812 neighbors_count = min(neighbors_count, len(target_points))
813 qres = {}
814 if kdtree is None:
815 kdtree = cKDTree(target_points)
817 if return_normals:
818 assert neighbors_count >= 3
819 distances, indices = kdtree.query(input_points, k=neighbors_count)
820 nearest = target_points[indices, :]
821 qres["normals"] = plane_fit(nearest)[1]
822 qres["nearest"] = nearest[:, 0]
823 qres["distances"] = distances[:, 0]
824 qres["vertex_indices"] = indices[:, 0]
825 else:
826 qres["distances"], qres["vertex_indices"] = kdtree.query(input_points)
827 qres["nearest"] = target_points[qres["vertex_indices"], :]
829 return qres
832def nricp_sumner(
833 source_mesh,
834 target_geometry,
835 source_landmarks=None,
836 target_positions=None,
837 steps=None,
838 distance_threshold=0.1,
839 return_records=False,
840 use_faces=True,
841 use_vertex_normals=True,
842 neighbors_count=8,
843 face_pairs_type="vertex",
844):
845 """
846 Non Rigid Iterative Closest Points
848 Implementation of the correspondence computation part of
849 "Sumner and Popovic 2004: Deformation Transfer for Triangle Meshes"
850 Allows to register non-rigidly a mesh on another geometry.
852 Comparison between nricp_amberg and nricp_sumner:
853 * nricp_amberg fits to the target mesh in less steps
854 * nricp_amberg can generate sharp edges
855 * only vertices and their neighbors are considered
856 * nricp_sumner tend to preserve more the original shape
857 * nricp_sumner parameters are easier to tune
858 * nricp_sumner solves for triangle positions whereas
859 nricp_amberg solves for vertex transforms
860 * nricp_sumner is less optimized when wn > 0
862 Parameters
863 ----------
864 source_mesh : Trimesh
865 Source mesh containing both vertices and faces.
866 target_geometry : Trimesh or PointCloud or (n, 3) float
867 Target geometry. It can contain no faces or be a PointCloud.
868 source_landmarks : (n,) int or ((n,) int, (n, 3) float)
869 n landmarks on the the source mesh.
870 Represented as vertex indices (n,) int.
871 It can also be represented as a tuple of triangle indices and barycentric
872 coordinates ((n,) int, (n, 3) float,).
873 target_positions : (n, 3) float
874 Target positions assigned to source landmarks
875 steps : Core parameters of the algorithm
876 Iterable of iterables (wc, wi, ws, wl, wn).
877 wc is the correspondence term (strength of fitting), wi is the identity term
878 (recommended value : 0.001), ws is smoothness term, wl weights the landmark
879 importance and wn the normal importance.
880 distance_threshold : float
881 Distance threshold to account for a vertex match or not.
882 return_records : bool
883 If True, also returns all the intermediate results. It can help debugging
884 and tune the parameters to match a specific case.
885 use_faces : bool
886 If True and if target geometry has faces, use proximity.closest_point to find
887 matching points. Else use scipy's cKDTree object.
888 use_vertex_normals : bool
889 If True and if target geometry has faces, interpolate the normals of the target
890 geometry matching points.
891 Else use face normals or estimated normals if target geometry has no faces.
892 neighbors_count : int
893 number of neighbors used for normal estimation. Only used if target geometry has
894 no faces or if use_faces is False.
895 face_pairs_type : str 'vertex' or 'edge'
896 Method to determine face pairs used in the smoothness cost. 'vertex' yields
897 smoother results.
900 Returns
901 ----------
902 result : (n, 3) float or list[(n, 3) float]
903 The vertices positions of source_mesh such that it is registered non-rigidly
904 onto the target geometry.
905 If return_records is True, it returns the list of the vertex positions at each
906 iteration.
907 """
909 def _construct_transform_matrix(faces, Vinv, size):
910 # Utility function for constructing the per-frame transforms
911 _construct_transform_matrix._row = np.array([0, 1, 2] * 4)
912 nV = len(Vinv)
913 rows = np.tile(_construct_transform_matrix._row, nV) + 3 * np.repeat(
914 np.arange(nV), 12
915 )
916 cols = np.repeat(faces.flat, 3)
917 minus_inv_sum = -Vinv.sum(axis=1)
918 Vinv_flat = Vinv.reshape(nV, 9)
919 data = np.concatenate((minus_inv_sum, Vinv_flat), axis=-1).flatten()
920 return sparse.coo_matrix((data, (rows, cols)), shape=(3 * nV, size), dtype=float)
922 def _build_tetrahedrons(mesh):
923 # UUtility function for constructing the frames
924 v4_vec = mesh.face_normals
925 v1 = mesh.triangles[:, 0]
926 v2 = mesh.triangles[:, 1]
927 v3 = mesh.triangles[:, 2]
928 v4 = v1 + v4_vec
929 vertices = np.concatenate((mesh.vertices, v4))
930 nV, nT = len(mesh.vertices), len(mesh.faces)
931 v4_indices = np.arange(nV, nV + nT)[:, None]
932 tetrahedrons = np.concatenate((mesh.faces, v4_indices), axis=-1)
933 frames = np.concatenate(
934 ((v2 - v1)[..., None], (v3 - v1)[..., None], v4_vec[..., None]), axis=-1
935 )
936 return vertices, tetrahedrons, frames
938 def _construct_identity_cost(vtet, tet, Vinv):
939 # Utility function for constructing the identity cost
940 AEi = _construct_transform_matrix(
941 tet,
942 Vinv,
943 len(vtet),
944 ).tocsr()
945 Bi = np.tile(np.identity(3, dtype=float), (len(tet), 1))
946 return AEi, Bi
948 def _construct_smoothness_cost(vtet, tet, Vinv, face_pairs):
949 # Utility function for constructing the smoothness (stiffness) cost
950 AEs_r = _construct_transform_matrix(
951 tet[face_pairs[:, 0]], Vinv[face_pairs[:, 0]], len(vtet)
952 ).tocsr()
953 AEs_l = _construct_transform_matrix(
954 tet[face_pairs[:, 1]], Vinv[face_pairs[:, 1]], len(vtet)
955 ).tocsr()
956 AEs = (AEs_r - AEs_l).tocsc()
957 AEs.eliminate_zeros()
958 Bs = np.zeros((len(face_pairs) * 3, 3))
959 return AEs, Bs
961 def _construct_landmark_cost(vtet, source_mesh, source_landmarks):
962 # Utility function for constructing the landmark cost
963 if source_landmarks is None:
964 return None, np.ones(len(source_mesh.vertices), dtype=bool)
965 if isinstance(source_landmarks, tuple):
966 # If the input source landmarks are in barycentric form
967 source_landmarks_tids, source_landmarks_barys = source_landmarks
968 source_landmarks_vids = source_mesh.faces[source_landmarks_tids]
969 nL, nVT = len(source_landmarks_tids), len(vtet)
971 rows = np.repeat(np.arange(nL), 3)
972 cols = source_landmarks_vids.flat
973 data = source_landmarks_barys.flat
975 AEl = sparse.coo_matrix((data, (rows, cols)), shape=(nL, nVT))
976 marker_vids = source_landmarks_vids[
977 source_landmarks_barys > np.finfo(np.float16).eps
978 ]
979 non_markers_mask = np.ones(len(source_mesh.vertices), dtype=bool)
980 non_markers_mask[marker_vids] = False
981 else:
982 # Else if they are in vertex index form
983 nL, nVT = len(source_landmarks), len(vtet)
984 rows = np.arange(nL)
985 cols = source_landmarks.flat
986 data = np.ones(nL)
987 AEl = sparse.coo_matrix((data, (rows, cols)), shape=(nL, nVT))
988 non_markers_mask = np.ones(len(source_mesh.vertices), dtype=bool)
989 non_markers_mask[source_landmarks] = False
990 return AEl, non_markers_mask
992 def _construct_correspondence_cost(points, non_markers_mask, size):
993 # Utility function for constructing the correspondence cost
994 AEc = sparse.identity(size, dtype=float, format="csc")[: len(non_markers_mask)]
995 AEc = AEc[non_markers_mask]
996 Bc = points[non_markers_mask]
997 return AEc, Bc
999 def _compute_vertex_normals(vertices, faces):
1000 # Utility function for computing source vertex normals
1001 mesh_triangles = vertices[faces]
1002 mesh_triangles_cross = cross(mesh_triangles)
1003 mesh_face_normals = normals(
1004 triangles=mesh_triangles, crosses=mesh_triangles_cross
1005 )[0]
1006 mesh_face_angles = angles(mesh_triangles)
1007 mesh_normals = weighted_vertex_normals(
1008 vertex_count=nV,
1009 faces=faces,
1010 face_normals=mesh_face_normals,
1011 face_angles=mesh_face_angles,
1012 )
1013 return mesh_normals
1015 # First, normalize the source and target to [-1, 1]^3
1016 (target_geometry, target_positions, centroid, scale) = _normalize_by_source(
1017 source_mesh, target_geometry, target_positions
1018 )
1019 nV = len(source_mesh.vertices)
1020 use_landmarks = source_landmarks is not None and target_positions is not None
1022 if steps is None:
1023 steps = [
1024 # [wc, wi, ws, wl, wn],
1025 [1, 0.001, 1.0, 1000, 0],
1026 [1, 0.001, 1.0, 1000, 0],
1027 [10, 0.001, 1.0, 1000, 0],
1028 [100, 0.001, 1.0, 1000, 0],
1029 ]
1031 source_vtet, source_tet, V = _build_tetrahedrons(source_mesh)
1032 Vinv = np.linalg.inv(V)
1034 # List of (n, 2) faces index which share a vertex
1035 if face_pairs_type == "vertex":
1036 face_pairs = source_mesh.face_neighborhood
1037 else:
1038 face_pairs = source_mesh.face_adjacency
1040 # Construct the cost matrices
1041 # Identity cost (Eq. 12)
1042 AEi, Bi = _construct_identity_cost(source_vtet, source_tet, Vinv)
1043 # Smoothness cost (Eq. 11)
1044 AEs, Bs = _construct_smoothness_cost(source_vtet, source_tet, Vinv, face_pairs)
1045 # Landmark cost (Eq. 13)
1046 AEl, non_markers_mask = _construct_landmark_cost(
1047 source_vtet, source_mesh, source_landmarks
1048 )
1050 transformed_vertices = source_vtet.copy()
1051 if return_records:
1052 records = [transformed_vertices[:nV]]
1054 # Main loop
1055 for i, (wc, wi, ws, wl, wn) in enumerate(steps):
1056 Astack = [AEi * wi, AEs * ws]
1057 Bstack = [Bi * wi, Bs * ws]
1059 if use_landmarks:
1060 Astack.append(AEl * wl)
1061 Bstack.append(target_positions * wl)
1063 if (i > 0 or not use_landmarks) and wc > 0:
1064 # Query the nearest points
1065 qres = _from_mesh(
1066 target_geometry,
1067 transformed_vertices[:nV],
1068 from_vertices_only=not use_faces,
1069 return_normals=wn > 0,
1070 return_interpolated_normals=(use_vertex_normals and wn > 0),
1071 neighbors_count=neighbors_count,
1072 )
1074 # Correspondence cost (Eq. 13)
1075 AEc, Bc = _construct_correspondence_cost(
1076 qres["nearest"], non_markers_mask, len(source_vtet)
1077 )
1078 vertices_weight = np.ones(nV)
1079 vertices_weight[qres["distances"] > distance_threshold] = 0
1080 if wn > 0 or "normals" in qres:
1081 target_normals = qres["normals"]
1082 if use_vertex_normals and "interpolated_normals" in qres:
1083 target_normals = qres["interpolated_normals"]
1084 # Normal weighting : multiplying weights by cosines^wn
1085 source_normals = _compute_vertex_normals(
1086 transformed_vertices, source_mesh.faces
1087 )
1088 dot = util.diagonal_dot(source_normals, target_normals)
1089 # Normal orientation is only known for meshes as target
1090 dot = np.clip(dot, 0, 1) if use_faces else np.abs(dot)
1091 vertices_weight = vertices_weight * dot**wn
1093 # Account for vertices' weight
1094 AEc.data *= vertices_weight[non_markers_mask][AEc.indices]
1095 Bc *= vertices_weight[non_markers_mask, None]
1097 Astack.append(AEc * wc)
1098 Bstack.append(Bc * wc)
1100 # Now solve Eq. 14 ...
1101 A = sparse.vstack(Astack, format="csc")
1102 A.eliminate_zeros()
1103 b = np.concatenate(Bstack)
1105 LU = sparse.linalg.splu((A.T * A).tocsc())
1106 transformed_vertices = LU.solve(A.T * b)
1107 # done !
1109 if return_records:
1110 records.append(transformed_vertices[:nV])
1112 if return_records:
1113 result = records
1114 else:
1115 result = transformed_vertices[:nV]
1117 result = _denormalize_by_source(
1118 source_mesh, target_geometry, target_positions, result, centroid, scale
1119 )
1120 return result