Skip to content

Commit 3fe1f48

Browse files
committed
add mesh registration and cache face_normals
1 parent 0183755 commit 3fe1f48

File tree

6 files changed

+339
-1
lines changed

6 files changed

+339
-1
lines changed

examples/scan_register.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""
2+
scan_register.py
3+
-------------
4+
5+
Create some simulated 3D scan data and register
6+
it to a "truth" mesh.
7+
"""
8+
9+
import trimesh
10+
import numpy as np
11+
12+
13+
def simulated_brick(face_count, extents, noise, max_iter=10):
14+
"""
15+
Produce a mesh that is a rectangular solid with noise
16+
with a random transform.
17+
18+
Parameters
19+
-------------
20+
face_count : int
21+
Approximate number of faces desired
22+
extents : (n,3) float
23+
Dimensions of brick
24+
noise : float
25+
Magnitude of vertex noise to apply
26+
"""
27+
28+
# create the mesh as a simple box
29+
mesh = trimesh.creation.box(extents=extents)
30+
31+
# add some systematic error pre- tesselation
32+
mesh.vertices[0] += mesh.vertex_normals[0] + (noise * 2)
33+
34+
# subdivide until we have more faces than we want
35+
for i in range(max_iter):
36+
if len(mesh.vertices) > face_count:
37+
break
38+
mesh = mesh.subdivide()
39+
40+
# apply tesselation and random noise
41+
mesh = mesh.permutate.noise(noise)
42+
43+
# randomly rotation with translation
44+
transform = trimesh.transformations.random_rotation_matrix()
45+
transform[:3, 3] = (np.random.random(3) - .5) * 1000
46+
47+
mesh.apply_transform(transform)
48+
49+
return mesh
50+
51+
52+
if __name__ == '__main__':
53+
# print log messages to terminal
54+
trimesh.util.attach_to_log()
55+
56+
# the size of our boxes
57+
extents = [6, 12, 2]
58+
59+
# create a simulated brick with noise and random transform
60+
scan = simulated_brick(face_count=5000,
61+
extents=extents,
62+
noise=.05)
63+
64+
# create a "true" mesh
65+
truth = trimesh.creation.box(extents=extents)
66+
67+
# (4, 4) float homogenous transform from truth to scan
68+
truth_to_scan, cost = truth.register(scan)
69+
70+
print("centroid distance pre-registration:",
71+
np.linalg.norm(truth.centroid - scan.centroid))
72+
73+
# apply the registration transform
74+
truth.apply_transform(truth_to_scan)
75+
print("centroid distance post-registration:",
76+
np.linalg.norm(truth.centroid - scan.centroid))
77+
78+
# find the distance from the truth mesh to each scan vertex
79+
distance = truth.nearest.on_surface(scan.vertices)[1]
80+
# 0.0 - 1.0 distance fraction
81+
fraction = distance / distance.max()
82+
83+
# color the mesh by distance from truth with
84+
# linear interpolation between two colors
85+
colors = ([255,0,0,255] * fraction.reshape((-1,1)) +
86+
[0,255,0,255] * (1 - fraction).reshape((-1,1)))
87+
# apply the vertex colors to the scan mesh
88+
scan.visual.vertex_colors = colors.astype(np.uint8)
89+
90+
# print some quick statistics about the mesh
91+
print('distance max:', distance.max())
92+
print('distance mean:', distance.mean())
93+
print('distance STD:', distance.std())
94+
95+
# export result with vertex colors for meshlab
96+
scan.export('scan_new.ply')
97+
98+
# show in a pyglet window
99+
scan.show()
100+
101+
102+
103+

tests/test_registration.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,49 @@ def test_icp_points(self):
5959
g.np.linalg.inv(matrixN))
6060
assert g.np.allclose(transformed, points_a[index])
6161

62+
def test_mesh(self):
63+
noise = .05
64+
extents = [6, 12, 3]
65+
66+
# create the mesh as a simple box
67+
mesh = g.trimesh.creation.box(extents=extents)
68+
69+
# subdivide until we have more faces than we want
70+
for i in range(3):
71+
mesh = mesh.subdivide()
72+
# apply tesselation and random noise
73+
mesh = mesh.permutate.noise(noise)
74+
# randomly rotation with translation
75+
transform = g.trimesh.transformations.random_rotation_matrix()
76+
transform[:3, 3] = (g.np.random.random(3) - .5) * 1000
77+
78+
mesh.apply_transform(transform)
79+
80+
scan = mesh
81+
# create a "true" mesh
82+
truth = g.trimesh.creation.box(extents=extents)
83+
84+
for a, b in [[truth, scan], [scan, truth]]:
85+
a_to_b, cost = a.register(b)
86+
87+
a_check = a.copy()
88+
a_check.apply_transform(a_to_b)
89+
90+
assert g.np.linalg.norm(a_check.centroid - b.centroid) < (noise * 2)
91+
92+
# find the distance from the truth mesh to each scan vertex
93+
distance = a_check.nearest.on_surface(b.vertices)[1]
94+
assert distance.max() < (noise * 2)
95+
96+
# try our registration with points
97+
points = g.trimesh.transform_points(
98+
scan.sample(100),
99+
matrix=g.trimesh.transformations.random_rotation_matrix())
100+
truth_to_points, cost = truth.register(points)
101+
truth.apply_transform(truth_to_points)
102+
distance = truth.nearest.on_surface(points)[1]
103+
assert distance.mean() < noise
104+
62105

63106
if __name__ == '__main__':
64107
g.trimesh.util.attach_to_log()

trimesh/base.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,12 +331,18 @@ def face_normals(self):
331331

332332
# if all triangles are valid shape is correct
333333
if valid.all():
334+
# put calculated face normals into cache manually
335+
self._cache['face_normals'] = normals
334336
return normals
335337

336338
# make a padded list of normals for correct shape
337339
padded = np.zeros((len(self.triangles), 3),
338340
dtype=np.float64)
339341
padded[valid] = normals
342+
343+
# put calculated face normals into cache manually
344+
self._cache['face_normals'] = padded
345+
340346
return padded
341347

342348
@face_normals.setter
@@ -1674,6 +1680,39 @@ def fill_holes(self):
16741680
"""
16751681
return repair.fill_holes(self)
16761682

1683+
def register(self, other, **kwargs):
1684+
"""
1685+
Align a mesh with another mesh or a PointCloud using
1686+
the principal axes of inertia as a starting point which
1687+
is refined by iterative closest point.
1688+
1689+
Parameters
1690+
------------
1691+
mesh : trimesh.Trimesh object
1692+
Mesh to align with other
1693+
other : trimesh.Trimesh or (n, 3) float
1694+
Mesh or points in space
1695+
samples : int
1696+
Number of samples from mesh surface to align
1697+
icp_first : int
1698+
How many ICP iterations for the 9 possible
1699+
combinations of
1700+
icp_final : int
1701+
How many ICP itertations for the closest
1702+
candidate from the wider search
1703+
1704+
Returns
1705+
-----------
1706+
mesh_to_other : (4, 4) float
1707+
Transform to align mesh to the other object
1708+
cost : float
1709+
Average square distance per point
1710+
"""
1711+
mesh_to_other, cost = registration.mesh_other(mesh=self,
1712+
other=other,
1713+
**kwargs)
1714+
return mesh_to_other, cost
1715+
16771716
def compute_stable_poses(self,
16781717
center_mass=None,
16791718
sigma=0.0,

trimesh/points.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,19 @@ def merge_vertices(self):
320320
len(self.colors) == len(inverse)):
321321
self.colors = self.colors[unique]
322322

323+
def apply_transform(self, transform):
324+
"""
325+
Apply a homogenous transformation to the PointCloud
326+
object in- place.
327+
328+
Parameters
329+
--------------
330+
transform : (4, 4) float
331+
Homogenous transformation to apply to PointCloud
332+
"""
333+
self.vertices = transformations.transform_points(self.vertices,
334+
matrix=transform)
335+
323336
@property
324337
def bounds(self):
325338
"""

trimesh/registration.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,149 @@
1010
from scipy.spatial import cKDTree
1111

1212
from . import util
13+
from . import bounds
14+
from . import transformations
15+
1316
from .transformations import transform_points
1417

1518

19+
def key_points(mesh, count):
20+
"""
21+
Return a combination of mesh vertices and surface samples
22+
with vertices chosen by likelyhood to be important to registation
23+
"""
24+
stack = []
25+
if len(mesh.vertices) < (count / 2):
26+
return np.vstack((
27+
mesh.vertices,
28+
mesh.sample(count - len(mesh.vertices))))
29+
else:
30+
return mesh.sample(count)
31+
32+
33+
def mesh_other(mesh, other, samples=500, icp_first=10, icp_final=50):
34+
"""
35+
Align a mesh with another mesh or a PointCloud using
36+
the principal axes of inertia as a starting point which
37+
is refined by iterative closest point.
38+
39+
Parameters
40+
------------
41+
mesh : trimesh.Trimesh object
42+
Mesh to align with other
43+
other : trimesh.Trimesh or (n, 3) float
44+
Mesh or points in space
45+
samples : int
46+
Number of samples from mesh surface to align
47+
icp_first : int
48+
How many ICP iterations for the 9 possible
49+
combinations of
50+
icp_final : int
51+
How many ICP itertations for the closest
52+
candidate from the wider search
53+
54+
Returns
55+
-----------
56+
mesh_to_other : (4, 4) float
57+
Transform to align mesh to the other object
58+
cost : float
59+
Average squared distance per point
60+
"""
61+
62+
if not util.is_instance_named(mesh, 'Trimesh'):
63+
raise ValueError('mesh must be Trimesh object!')
64+
65+
inverse = True
66+
search = mesh
67+
# if both are meshes use the smaller one for searching
68+
if util.is_instance_named(other, 'Trimesh'):
69+
if len(mesh.vertices) > len(other.vertices):
70+
search = other
71+
inverse = False
72+
points = key_points(mesh=mesh,
73+
count=samples)
74+
points_mesh = mesh
75+
else:
76+
points_mesh = other
77+
points = key_points(mesh=other,
78+
count=samples)
79+
80+
if points_mesh.is_volume:
81+
points_PIT = points_mesh.principal_inertia_transform
82+
else:
83+
points_PIT = points_mesh.bounding_box_oriented.principal_inertia_transform
84+
85+
elif util.is_shape(other, (-1, 3)):
86+
# case where other is just points
87+
points = other
88+
points_PIT = bounds.oriented_bounds(points)[0]
89+
else:
90+
raise ValueError('other must be mesh or (n, 3) points!')
91+
92+
if search.is_volume:
93+
search_PIT = search.principal_inertia_transform
94+
else:
95+
search_PIT = search.bounding_box_oriented.principal_inertia_transform
96+
97+
# move from mesh a to mesh b
98+
search_to_points = np.dot(np.linalg.inv(points_PIT), search_PIT)
99+
100+
# permutations of cube rotations
101+
# the principal inertia transform has arbitrary sign
102+
# along the 3 major axis so try all combinations of
103+
# 180 degree rotations with a quick first ICP pass
104+
cubes = np.array([np.eye(4) * np.append(diag, 1)
105+
for diag in [[1, 1, 1],
106+
[1, 1, -1],
107+
[1, -1, 1],
108+
[-1, 1, 1],
109+
[-1, -1, 1],
110+
[-1, 1, -1],
111+
[1, -1, -1],
112+
[-1, -1, -1]]])
113+
114+
#from IPython import embed
115+
# embed()
116+
117+
# loop through permutations and run iterative closest point on each
118+
costs, transforms = [], []
119+
centroid = search.centroid
120+
for flip in cubes:
121+
a_to_b = np.dot(transformations.transform_around(flip, centroid),
122+
np.linalg.inv(search_to_points))
123+
124+
# import trimesh
125+
# vpt = trimesh.PointCloud(points)
126+
# vpt.apply_transform(a_to_b)
127+
# trimesh.Scene([search, vpt]).show()
128+
129+
# run first pass ICP
130+
matrix, junk, cost = icp(a=points,
131+
b=search,
132+
initial=a_to_b,
133+
max_iterations=int(icp_first),
134+
scale=False)
135+
transforms.append(matrix)
136+
costs.append(cost)
137+
138+
# run a final ICP refinement step
139+
matrix, junk, cost = icp(a=points,
140+
b=search,
141+
initial=transforms[np.argmin(costs)],
142+
max_iterations=int(icp_final),
143+
scale=False)
144+
145+
# convert square sum distance to squared average distance
146+
cost /= len(points)
147+
148+
if inverse:
149+
mesh_to_other = np.linalg.inv(matrix)
150+
else:
151+
mesh_to_other = matrix
152+
153+
return mesh_to_other, cost
154+
155+
16156
def procrustes(a,
17157
b,
18158
reflection=True,

trimesh/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '2.33.37'
1+
__version__ = '2.33.38'

0 commit comments

Comments
 (0)