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