Skip to content

Commit cb08c39

Browse files
authored
Add Normal vector estimation (AtsushiSakai#781)
* add normal vector calculation routine. * add normal vector calculation routine. * add normal vector estimation * fix unittests in not matplotlib frontend * fix lint ci * add ransac based normal distribution estimation * add normal_vector_estimation_main.rst * normal_vector_estimation_main.rst を更新 * update normal_vector_estimation_main.rst * update normal_vector_estimation_main.rst * normal_vector_estimation_main.rst * normal_vector_estimation_main.rst を更新 * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst * add normal_vector_estimation_main.rst
1 parent 1f96f4e commit cb08c39

File tree

12 files changed

+360
-11
lines changed

12 files changed

+360
-11
lines changed

ArmNavigation/rrt_star_seven_joint_arm_control/rrt_star_seven_joint_arm_control.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import math
66
import random
77
import numpy as np
8-
from mpl_toolkits import mplot3d
98
import matplotlib.pyplot as plt
109
import sys
1110
import pathlib
@@ -79,8 +78,9 @@ def __init__(self, start, goal, robot, obstacle_list, rand_area,
7978
self.obstacle_list = obstacle_list
8079
self.connect_circle_dist = connect_circle_dist
8180
self.goal_node = self.Node(goal)
82-
self.ax = plt.axes(projection='3d')
8381
self.node_list = []
82+
if show_animation:
83+
self.ax = plt.axes(projection='3d')
8484

8585
def planning(self, animation=False, search_until_max_iter=False):
8686
"""
@@ -176,7 +176,7 @@ def search_best_goal_node(self):
176176

177177
def find_near_nodes(self, new_node):
178178
nnode = len(self.node_list) + 1
179-
r = self.connect_circle_dist * math.sqrt((math.log(nnode) / nnode))
179+
r = self.connect_circle_dist * math.sqrt(math.log(nnode) / nnode)
180180
# if expand_dist exists, search vertices in
181181
# a range no more than expand_dist
182182
if hasattr(self, 'expand_dis'):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import numpy as np
2+
from matplotlib import pyplot as plt
3+
4+
from utils.plot import plot_3d_vector_arrow, plot_triangle, set_equal_3d_axis
5+
6+
show_animation = True
7+
8+
9+
def calc_normal_vector(p1, p2, p3):
10+
"""Calculate normal vector of triangle
11+
12+
Parameters
13+
----------
14+
p1 : np.array
15+
3D point
16+
p2 : np.array
17+
3D point
18+
p3 : np.array
19+
3D point
20+
21+
Returns
22+
-------
23+
normal_vector : np.array
24+
normal vector (3,)
25+
26+
"""
27+
# calculate two vectors of triangle
28+
v1 = p2 - p1
29+
v2 = p3 - p1
30+
31+
# calculate normal vector
32+
normal_vector = np.cross(v1, v2)
33+
34+
# normalize vector
35+
normal_vector = normal_vector / np.linalg.norm(normal_vector)
36+
37+
return normal_vector
38+
39+
40+
def sample_3d_points_from_a_plane(num_samples, normal):
41+
points_2d = np.random.normal(size=(num_samples, 2)) # 2D points on a plane
42+
d = 0
43+
for i in range(len(points_2d)):
44+
point_3d = np.append(points_2d[i], 0)
45+
d += normal @ point_3d
46+
d /= len(points_2d)
47+
48+
points_3d = np.zeros((len(points_2d), 3))
49+
for i in range(len(points_2d)):
50+
point_2d = np.append(points_2d[i], 0)
51+
projection_length = (d - normal @ point_2d) / np.linalg.norm(normal)
52+
points_3d[i] = point_2d + projection_length * normal
53+
54+
return points_3d
55+
56+
57+
def distance_to_plane(point, normal, origin):
58+
dot_product = np.dot(normal, point) - np.dot(normal, origin)
59+
if np.isclose(dot_product, 0):
60+
return 0.0
61+
else:
62+
distance = abs(dot_product) / np.linalg.norm(normal)
63+
return distance
64+
65+
66+
def ransac_normal_vector_estimation(points_3d, inlier_radio_th=0.7,
67+
inlier_dist=0.1, p=0.99):
68+
"""
69+
RANSAC based normal vector estimation
70+
71+
Parameters
72+
----------
73+
points_3d : np.array
74+
3D points (N, 3)
75+
inlier_radio_th : float
76+
Inlier ratio threshold. If inlier ratio is larger than this value,
77+
the iteration is stopped. Default is 0.7.
78+
inlier_dist : float
79+
Inlier distance threshold. If distance between points and estimated
80+
plane is smaller than this value, the point is inlier. Default is 0.1.
81+
p : float
82+
Probability that at least one of the sets of random samples does not
83+
include an outlier. If this probability is near 1, the iteration
84+
number is large. Default is 0.99.
85+
86+
Returns
87+
-------
88+
center_vector : np.array
89+
Center of estimated plane. (3,)
90+
normal_vector : np.array
91+
Normal vector of estimated plane. (3,)
92+
93+
"""
94+
center = np.mean(points_3d, axis=0)
95+
96+
max_iter = int(np.floor(np.log(1.0-p)/np.log(1.0-(1.0-inlier_radio_th)**3)))
97+
98+
for ite in range(max_iter):
99+
# Random sampling
100+
sampled_ids = np.random.choice(points_3d.shape[0], size=3,
101+
replace=False)
102+
sampled_points = points_3d[sampled_ids, :]
103+
p1 = sampled_points[0, :]
104+
p2 = sampled_points[1, :]
105+
p3 = sampled_points[2, :]
106+
normal_vector = calc_normal_vector(p1, p2, p3)
107+
108+
# calc inlier ratio
109+
n_inliner = 0
110+
for i in range(points_3d.shape[0]):
111+
p = points_3d[i, :]
112+
if distance_to_plane(p, normal_vector, center) <= inlier_dist:
113+
n_inliner += 1
114+
inlier_ratio = n_inliner / points_3d.shape[0]
115+
print(f"Iter:{ite}, {inlier_ratio=}")
116+
if inlier_ratio > inlier_radio_th:
117+
return center, normal_vector
118+
119+
return center, None
120+
121+
122+
def main1():
123+
p1 = np.array([0.0, 0.0, 1.0])
124+
p2 = np.array([1.0, 1.0, 0.0])
125+
p3 = np.array([0.0, 1.0, 0.0])
126+
127+
center = np.mean([p1, p2, p3], axis=0)
128+
normal_vector = calc_normal_vector(p1, p2, p3)
129+
print(f"{center=}")
130+
print(f"{normal_vector=}")
131+
132+
if show_animation:
133+
fig = plt.figure()
134+
ax = fig.add_subplot(projection='3d')
135+
set_equal_3d_axis(ax, [0.0, 2.5], [0.0, 2.5], [0.0, 3.0])
136+
plot_triangle(p1, p2, p3, ax)
137+
ax.plot(center[0], center[1], center[2], "ro")
138+
plot_3d_vector_arrow(ax, center, center + normal_vector)
139+
plt.show()
140+
141+
142+
def main2(rng=None):
143+
true_normal = np.array([0, 1, 1])
144+
true_normal = true_normal / np.linalg.norm(true_normal)
145+
num_samples = 100
146+
noise_scale = 0.1
147+
148+
points_3d = sample_3d_points_from_a_plane(num_samples, true_normal)
149+
# add random noise
150+
points_3d += np.random.normal(size=points_3d.shape, scale=noise_scale)
151+
152+
print(f"{points_3d.shape=}")
153+
154+
center, estimated_normal = ransac_normal_vector_estimation(
155+
points_3d, inlier_dist=noise_scale)
156+
157+
if estimated_normal is None:
158+
print("Failed to estimate normal vector")
159+
return
160+
161+
print(f"{true_normal=}")
162+
print(f"{estimated_normal=}")
163+
164+
if show_animation:
165+
fig = plt.figure()
166+
ax = fig.add_subplot(projection='3d')
167+
ax.plot(points_3d[:, 0], points_3d[:, 1], points_3d[:, 2], ".r")
168+
plot_3d_vector_arrow(ax, center, center + true_normal)
169+
plot_3d_vector_arrow(ax, center, center + estimated_normal)
170+
set_equal_3d_axis(ax, [-3.0, 3.0], [-3.0, 3.0], [-3.0, 3.0])
171+
plt.title("RANSAC based Normal vector estimation")
172+
plt.show()
173+
174+
175+
if __name__ == '__main__':
176+
# main1()
177+
main2()

PathPlanning/DynamicWindowApproach/dynamic_window_approach.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,7 @@ def main(gx=10.0, gy=10.0, robot_type=RobotType.circle):
300300
if show_animation:
301301
plt.plot(trajectory[:, 0], trajectory[:, 1], "-r")
302302
plt.pause(0.0001)
303-
304-
plt.show()
303+
plt.show()
305304

306305

307306
if __name__ == '__main__':

PathPlanning/RRTStar/rrt_star.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ def find_near_nodes(self, new_node):
186186
radius r
187187
"""
188188
nnode = len(self.node_list) + 1
189-
r = self.connect_circle_dist * math.sqrt((math.log(nnode) / nnode))
189+
r = self.connect_circle_dist * math.sqrt(math.log(nnode) / nnode)
190190
# if expand_dist exists, search vertices in a range no more than
191191
# expand_dist
192192
if hasattr(self, 'expand_dis'):
@@ -280,7 +280,7 @@ def main():
280280
rrt_star.draw_graph()
281281
plt.plot([x for (x, y) in path], [y for (x, y) in path], 'r--')
282282
plt.grid(True)
283-
plt.show()
283+
plt.show()
284284

285285

286286
if __name__ == '__main__':

PathPlanning/VisibilityRoadMap/visibility_road_map.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ def generate_visibility_nodes(self, start_x, start_y, goal_x, goal_y,
6262
for (vx, vy) in zip(cvx_list, cvy_list):
6363
nodes.append(DijkstraSearch.Node(vx, vy))
6464

65-
for node in nodes:
66-
plt.plot(node.x, node.y, "xr")
65+
if self.do_plot:
66+
for node in nodes:
67+
plt.plot(node.x, node.y, "xr")
6768

6869
return nodes
6970

docs/modules/mapping/mapping_main.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ Mapping
1313
k_means_object_clustering/k_means_object_clustering
1414
circle_fitting/circle_fitting
1515
rectangle_fitting/rectangle_fitting
16-
16+
normal_vector_estimation/normal_vector_estimation
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
Normal vector estimation
2+
-------------------------
3+
4+
5+
Normal vector calculation of a 3D triangle
6+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7+
8+
A 3D point is as a vector:
9+
10+
.. math:: p = [x, y, z]
11+
12+
When there are 3 points in 3D space, :math:`p_1, p_2, p_3`,
13+
14+
we can calculate a normal vector n of a 3D triangle which is consisted of the points.
15+
16+
.. math:: n = \frac{v1 \times v2}{|v1 \times v2|}
17+
18+
where
19+
20+
.. math:: v1 = p2 - p1
21+
22+
.. math:: v2 = p3 - p1
23+
24+
This is an example of normal vector calculation:
25+
26+
.. figure:: normal_vector_calc.png
27+
28+
API
29+
=====
30+
31+
.. autofunction:: Mapping.normal_vector_estimation.normal_vector_estimation.calc_normal_vector
32+
33+
Normal vector estimation with RANdam SAmpling Consensus(RANSAC)
34+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
35+
36+
Consider the problem of estimating the normal vector of a plane based on a
37+
set of N 3D points where a plane can be observed.
38+
39+
There is a way that uses all point cloud data to estimate a plane and
40+
a normal vector using the `least-squares method <https://stackoverflow.com/a/44315221/8387766>`_
41+
42+
However, this method is vulnerable to noise of the point cloud.
43+
44+
In this document, we will use a method that uses
45+
`RANdam SAmpling Consensus(RANSAC) <https://en.wikipedia.org/wiki/Random_sample_consensus>`_
46+
to estimate a plane and a normal vector.
47+
48+
RANSAC is a robust estimation methods for data set with outliers.
49+
50+
51+
52+
This RANSAC based normal vector estimation method is as follows:
53+
54+
#. Select 3 points randomly from the point cloud.
55+
56+
#. Calculate a normal vector of a plane which is consists of the sampled 3 points.
57+
58+
#. Calculate the distance between the calculated plane and the all point cloud.
59+
60+
#. If the distance is less than a threshold, the point is considered to be an inlier.
61+
62+
#. Repeat the above steps until the inlier ratio is greater than a threshold.
63+
64+
65+
66+
This is an example of RANSAC based normal vector estimation:
67+
68+
.. figure:: ransac_normal_vector_estimation.png
69+
70+
API
71+
=====
72+
73+
.. autofunction:: Mapping.normal_vector_estimation.normal_vector_estimation.ransac_normal_vector_estimation
74+
Loading

ruff.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
line-length = 88
22

33
select = ["F", "E", "W", "UP"]
4-
ignore = ["E501", "E741"]
4+
ignore = ["E501", "E741", "E402"]
55
exclude = [
66
]
77

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import conftest # Add root path to sys.path
2+
from Mapping.normal_vector_estimation import normal_vector_estimation as m
3+
import random
4+
5+
random.seed(12345)
6+
7+
8+
def test_1():
9+
m.show_animation = False
10+
m.main1()
11+
12+
13+
def test_2():
14+
m.show_animation = False
15+
m.main2()
16+
17+
18+
if __name__ == '__main__':
19+
conftest.run_this_test(__file__)

0 commit comments

Comments
 (0)