Skip to content

Commit 99af1f3

Browse files
authored
Add theta star planner (#1293)
* Theta Star path compression * add tests and documentation for theta star algorithm * remove erraneous nested loop * improve documentation for theta star planner * add reference for theta star algorithm * use built-in function for norm * Add Bresenham line algorithm description to docs
1 parent 69d07fc commit 99af1f3

File tree

3 files changed

+385
-0
lines changed

3 files changed

+385
-0
lines changed
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
"""
2+
3+
Theta* grid planning
4+
5+
author: Musab Kasbati (@Musab1Blaser)
6+
7+
See Wikipedia article (https://cdn.aaai.org/AAAI/2007/AAAI07-187.pdf)
8+
9+
"""
10+
11+
import math
12+
13+
import matplotlib.pyplot as plt
14+
15+
show_animation = True
16+
use_theta_star = True
17+
18+
19+
class ThetaStarPlanner:
20+
21+
def __init__(self, ox, oy, resolution, rr):
22+
"""
23+
Initialize grid map for theta star planning
24+
25+
ox: x position list of Obstacles [m]
26+
oy: y position list of Obstacles [m]
27+
resolution: grid resolution [m]
28+
rr: robot radius[m]
29+
"""
30+
31+
self.resolution = resolution
32+
self.rr = rr
33+
self.min_x, self.min_y = 0, 0
34+
self.max_x, self.max_y = 0, 0
35+
self.obstacle_map = None
36+
self.x_width, self.y_width = 0, 0
37+
self.motion = self.get_motion_model()
38+
self.calc_obstacle_map(ox, oy)
39+
40+
class Node:
41+
def __init__(self, x, y, cost, parent_index):
42+
self.x = x # index of grid
43+
self.y = y # index of grid
44+
self.cost = cost
45+
self.parent_index = parent_index
46+
47+
def __str__(self):
48+
return str(self.x) + "," + str(self.y) + "," + str(
49+
self.cost) + "," + str(self.parent_index)
50+
51+
def planning(self, sx, sy, gx, gy):
52+
"""
53+
Theta star path search
54+
55+
input:
56+
s_x: start x position [m]
57+
s_y: start y position [m]
58+
gx: goal x position [m]
59+
gy: goal y position [m]
60+
61+
output:
62+
rx: x position list of the final path
63+
ry: y position list of the final path
64+
"""
65+
66+
start_node = self.Node(self.calc_xy_index(sx, self.min_x),
67+
self.calc_xy_index(sy, self.min_y), 0.0, -1)
68+
goal_node = self.Node(self.calc_xy_index(gx, self.min_x),
69+
self.calc_xy_index(gy, self.min_y), 0.0, -1)
70+
71+
open_set, closed_set = dict(), dict()
72+
open_set[self.calc_grid_index(start_node)] = start_node
73+
74+
while True:
75+
if len(open_set) == 0:
76+
print("Open set is empty..")
77+
break
78+
79+
c_id = min(
80+
open_set,
81+
key=lambda o: open_set[o].cost + self.calc_heuristic(goal_node,
82+
open_set[
83+
o]))
84+
current = open_set[c_id]
85+
86+
# show graph
87+
if show_animation: # pragma: no cover
88+
x = self.calc_grid_position(current.x, self.min_x)
89+
y = self.calc_grid_position(current.y, self.min_y)
90+
91+
# Draw an arrow toward the parent
92+
if current.parent_index != -1 and current.parent_index in closed_set:
93+
parent = closed_set[current.parent_index]
94+
px = self.calc_grid_position(parent.x, self.min_x)
95+
py = self.calc_grid_position(parent.y, self.min_y)
96+
97+
# Vector from current to parent
98+
dx = px - x
99+
dy = py - y
100+
101+
# scale down vector for visibility
102+
norm = math.hypot(dx, dy)
103+
dx /= norm
104+
dy /= norm
105+
106+
# Draw a small arrow (scale it down for visibility)
107+
plt.arrow(x, y, dx, dy,
108+
head_width=0.5, head_length=0.5,
109+
fc='c', ec='c', alpha=0.7)
110+
111+
# For stopping simulation with the esc key
112+
plt.gcf().canvas.mpl_connect(
113+
'key_release_event',
114+
lambda event: [exit(0) if event.key == 'escape' else None]
115+
)
116+
117+
if len(closed_set.keys()) % 10 == 0:
118+
plt.pause(0.001)
119+
120+
if current.x == goal_node.x and current.y == goal_node.y:
121+
print("Find goal")
122+
goal_node.parent_index = current.parent_index
123+
goal_node.cost = current.cost
124+
break
125+
126+
# Remove the item from the open set
127+
del open_set[c_id]
128+
129+
# Add it to the closed set
130+
closed_set[c_id] = current
131+
132+
# expand_grid search grid based on motion model
133+
for i, _ in enumerate(self.motion):
134+
node = self.Node(current.x + self.motion[i][0],
135+
current.y + self.motion[i][1],
136+
current.cost + self.motion[i][2], c_id) # cost may later be updated by theta star path compression
137+
n_id = self.calc_grid_index(node)
138+
139+
if not self.verify_node(node):
140+
continue
141+
142+
if n_id in closed_set:
143+
continue
144+
145+
# Theta* modification:
146+
if use_theta_star and current.parent_index != -1 and current.parent_index in closed_set:
147+
grandparent = closed_set[current.parent_index]
148+
if self.line_of_sight(grandparent, node):
149+
# If parent(current) has line of sight to neighbor
150+
node.cost = grandparent.cost + math.hypot(node.x - grandparent.x, node.y - grandparent.y)
151+
node.parent_index = current.parent_index # compress path directly to grandparent
152+
153+
if n_id not in open_set:
154+
open_set[n_id] = node
155+
else:
156+
if open_set[n_id].cost > node.cost:
157+
# This path is the best until now. record it
158+
open_set[n_id] = node
159+
160+
161+
rx, ry = self.calc_final_path(goal_node, closed_set)
162+
163+
return rx, ry
164+
165+
def calc_final_path(self, goal_node, closed_set):
166+
# generate final course
167+
rx, ry = [self.calc_grid_position(goal_node.x, self.min_x)], [
168+
self.calc_grid_position(goal_node.y, self.min_y)]
169+
parent_index = goal_node.parent_index
170+
while parent_index != -1:
171+
n = closed_set[parent_index]
172+
rx.append(self.calc_grid_position(n.x, self.min_x))
173+
ry.append(self.calc_grid_position(n.y, self.min_y))
174+
parent_index = n.parent_index
175+
176+
return rx, ry
177+
178+
@staticmethod
179+
def calc_heuristic(n1, n2):
180+
w = 1.0 # weight of heuristic
181+
d = w * math.hypot(n1.x - n2.x, n1.y - n2.y)
182+
return d
183+
184+
def calc_grid_position(self, index, min_position):
185+
"""
186+
calc grid position
187+
188+
:param index:
189+
:param min_position:
190+
:return:
191+
"""
192+
pos = index * self.resolution + min_position
193+
return pos
194+
195+
def calc_xy_index(self, position, min_pos):
196+
return round((position - min_pos) / self.resolution)
197+
198+
def calc_grid_index(self, node):
199+
return (node.y - self.min_y) * self.x_width + (node.x - self.min_x)
200+
201+
def line_of_sight(self, node1, node2):
202+
"""
203+
Check if there is a direct line of sight between two nodes.
204+
Uses Bresenham’s line algorithm for grid traversal.
205+
"""
206+
x0 = node1.x
207+
y0 = node1.y
208+
x1 = node2.x
209+
y1 = node2.y
210+
211+
dx = abs(x1 - x0)
212+
dy = abs(y1 - y0)
213+
sx = 1 if x0 < x1 else -1
214+
sy = 1 if y0 < y1 else -1
215+
216+
err = dx - dy
217+
218+
while True:
219+
if not self.verify_node(self.Node(x0, y0, 0, -1)):
220+
return False
221+
if x0 == x1 and y0 == y1:
222+
break
223+
e2 = 2 * err
224+
if e2 > -dy:
225+
err -= dy
226+
x0 += sx
227+
if e2 < dx:
228+
err += dx
229+
y0 += sy
230+
return True
231+
232+
233+
def verify_node(self, node):
234+
px = self.calc_grid_position(node.x, self.min_x)
235+
py = self.calc_grid_position(node.y, self.min_y)
236+
237+
if px < self.min_x:
238+
return False
239+
elif py < self.min_y:
240+
return False
241+
elif px >= self.max_x:
242+
return False
243+
elif py >= self.max_y:
244+
return False
245+
246+
# collision check
247+
if self.obstacle_map[node.x][node.y]:
248+
return False
249+
250+
return True
251+
252+
def calc_obstacle_map(self, ox, oy):
253+
254+
self.min_x = round(min(ox))
255+
self.min_y = round(min(oy))
256+
self.max_x = round(max(ox))
257+
self.max_y = round(max(oy))
258+
print("min_x:", self.min_x)
259+
print("min_y:", self.min_y)
260+
print("max_x:", self.max_x)
261+
print("max_y:", self.max_y)
262+
263+
self.x_width = round((self.max_x - self.min_x) / self.resolution)
264+
self.y_width = round((self.max_y - self.min_y) / self.resolution)
265+
print("x_width:", self.x_width)
266+
print("y_width:", self.y_width)
267+
268+
# obstacle map generation
269+
self.obstacle_map = [[False for _ in range(self.y_width)]
270+
for _ in range(self.x_width)]
271+
for ix in range(self.x_width):
272+
x = self.calc_grid_position(ix, self.min_x)
273+
for iy in range(self.y_width):
274+
y = self.calc_grid_position(iy, self.min_y)
275+
for iox, ioy in zip(ox, oy):
276+
d = math.hypot(iox - x, ioy - y)
277+
if d <= self.rr:
278+
self.obstacle_map[ix][iy] = True
279+
break
280+
281+
@staticmethod
282+
def get_motion_model():
283+
# dx, dy, cost
284+
motion = [[1, 0, 1],
285+
[0, 1, 1],
286+
[-1, 0, 1],
287+
[0, -1, 1],
288+
[-1, -1, math.sqrt(2)],
289+
[-1, 1, math.sqrt(2)],
290+
[1, -1, math.sqrt(2)],
291+
[1, 1, math.sqrt(2)]]
292+
293+
return motion
294+
295+
296+
def main():
297+
print(__file__ + " start!!")
298+
299+
# start and goal position
300+
sx = 10.0 # [m]
301+
sy = 10.0 # [m]
302+
gx = 50.0 # [m]
303+
gy = 50.0 # [m]
304+
grid_size = 2.0 # [m]
305+
robot_radius = 1.0 # [m]
306+
307+
# set obstacle positions
308+
ox, oy = [], []
309+
for i in range(-10, 60):
310+
ox.append(i)
311+
oy.append(-10.0)
312+
for i in range(-10, 60):
313+
ox.append(60.0)
314+
oy.append(i)
315+
for i in range(-10, 61):
316+
ox.append(i)
317+
oy.append(60.0)
318+
for i in range(-10, 61):
319+
ox.append(-10.0)
320+
oy.append(i)
321+
for i in range(-10, 40):
322+
ox.append(20.0)
323+
oy.append(i)
324+
for i in range(0, 40):
325+
ox.append(40.0)
326+
oy.append(60.0 - i)
327+
328+
if show_animation: # pragma: no cover
329+
plt.plot(ox, oy, ".k")
330+
plt.plot(sx, sy, "og")
331+
plt.plot(gx, gy, "xb")
332+
plt.grid(True)
333+
plt.axis("equal")
334+
335+
theta_star = ThetaStarPlanner(ox, oy, grid_size, robot_radius)
336+
rx, ry = theta_star.planning(sx, sy, gx, gy)
337+
338+
if show_animation: # pragma: no cover
339+
plt.plot(rx, ry, "-r")
340+
plt.pause(0.001)
341+
plt.show()
342+
343+
344+
if __name__ == '__main__':
345+
main()

docs/modules/5_path_planning/grid_base_search/grid_base_search_main.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,35 @@ Code Link
8282
.. autofunction:: PathPlanning.BidirectionalAStar.bidirectional_a_star.BidirectionalAStarPlanner
8383

8484

85+
.. _Theta*-algorithm:
86+
87+
Theta\* algorithm
88+
~~~~~~~~~~~~~~~~~
89+
90+
This is a 2D grid based shortest path planning with Theta star algorithm.
91+
92+
It offers an optimization over the A star algorithm to generate any-angle paths. Unlike A star, which always assigns the current node as the parent of the successor, Theta star checks for a line-of-sight (unblocked path) from an earlier node (typically the parent of the current node) to the successor, and directly assigns it as a parent if visible. This reduces cost by replacing staggered segments with straight lines.
93+
94+
Checking for line of sight involves verifying that no obstacles block the straight line between two nodes. On a grid, this means identifying all the discrete cells that the line passes through to determine if they are empty. Bresenham’s line algorithm is commonly used for this purpose. Starting from one endpoint, it incrementally steps along one axis, while considering the gradient to determine the position on the other axis. Because it relies only on integer addition and subtraction, it is both efficient and precise for grid-based visibility checks in Theta*.
95+
96+
As a result, Theta star produces shorter, smoother paths than A star, ideal for ground or aerial robots operating in continuous environments where smoother motion enables higher acceleration and reduced travel time.
97+
98+
.. image:: https://github.com/AtsushiSakai/PythonRoboticsGifs/raw/master/PathPlanning/ThetaStar/animation.gif
99+
100+
In the animation, each cyan arrow represents a node pointing to its parent.
101+
102+
Reference
103+
++++++++++++
104+
105+
- `Theta*: Any-Angle Path Planning on Grids <https://cdn.aaai.org/AAAI/2007/AAAI07-187.pdf>`__
106+
- `Bresenham's line algorithm <https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm>`__
107+
108+
Code Link
109+
+++++++++++++
110+
111+
.. autofunction:: PathPlanning.ThetaStar.theta_star.ThetaStarPlanner
112+
113+
85114
.. _D*-algorithm:
86115

87116
D\* algorithm

tests/test_theta_star.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import conftest
2+
from PathPlanning.ThetaStar import theta_star as m
3+
4+
5+
def test_1():
6+
m.show_animation = False
7+
m.main()
8+
9+
10+
if __name__ == '__main__':
11+
conftest.run_this_test(__file__)

0 commit comments

Comments
 (0)