├── .gitignore
├── docs
├── demo.gif
├── diagram.png
└── diagram.drawio
├── config
├── AreaToPath.cfg
└── LinePlanner.cfg
├── CMakeLists.txt
├── package.xml
├── LICENSE
├── launch
└── line_planner.launch
├── src
├── utils.py
├── markers.py
├── area_to_path_node.py
└── line_planner_node.py
└── ReadMe.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/*
--------------------------------------------------------------------------------
/docs/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoffKalast/line_planner/HEAD/docs/demo.gif
--------------------------------------------------------------------------------
/docs/diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoffKalast/line_planner/HEAD/docs/diagram.png
--------------------------------------------------------------------------------
/config/AreaToPath.cfg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | PACKAGE = "line_planner"
4 |
5 | from dynamic_reconfigure.parameter_generator_catkin import *
6 |
7 | gen = ParameterGenerator()
8 |
9 | gen.add("step_size", double_t, 0, "Step size for grid-based patterns", 2.0, 0.1, 100.0)
10 |
11 | exit(gen.generate(PACKAGE, "line_planner", "AreaToPath"))
--------------------------------------------------------------------------------
/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 3.0.2)
2 | project(line_planner)
3 |
4 | find_package(catkin REQUIRED COMPONENTS
5 | dynamic_reconfigure
6 | rospy
7 | move_base_msgs
8 | std_msgs
9 | message_generation
10 | )
11 |
12 | generate_dynamic_reconfigure_options(
13 | config/LinePlanner.cfg
14 | config/AreaToPath.cfg
15 | )
16 |
17 | catkin_package(
18 | CATKIN_DEPENDS dynamic_reconfigure rospy geometry_msgs move_base_msgs std_msgs
19 | )
20 |
21 | include_directories(
22 | ${catkin_INCLUDE_DIRS}
23 | )
24 |
25 | install(PROGRAMS
26 | src/line_planner_node.py
27 | src/area_to_path_node.py
28 | DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
29 | )
--------------------------------------------------------------------------------
/package.xml:
--------------------------------------------------------------------------------
1 |
2 | line_planner
3 | 0.0.1
4 | A simple planner that attempts to keep the robot close to a line between the starting and target goal.
5 | MoffKalast
6 | MIT
7 |
8 | rospy
9 | dynamic_reconfigure
10 |
11 | catkin
12 | actionlib_msgs
13 | geometry_msgs
14 | move_base_msgs
15 | std_msgs
16 |
17 | actionlib_msgs
18 | move_base_msgs
19 | std_msgs
20 |
21 | geometry_msgs
22 | actionlib_msgs
23 | move_base_msgs
24 | std_msgs
25 |
26 |
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 MoffKalast
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/config/LinePlanner.cfg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | PACKAGE = "line_planner"
4 |
5 | from dynamic_reconfigure.parameter_generator_catkin import *
6 |
7 | gen = ParameterGenerator()
8 |
9 | gen.add("publish_debug_markers", bool_t, 0, "Publish debug markers", True)
10 | gen.add("ignore_altitude", bool_t, 0, "Ignore Z values", False)
11 |
12 | gen.add("max_linear_speed", double_t, 0, "Maximum linear speed", 0.45, 0, 5)
13 | gen.add("max_turning_speed", double_t, 0, "Maximum turning speed", 0.9, 0, 5)
14 | gen.add("max_vertical_speed", double_t, 0, "Maximum vertical speed", 0.5, 0, 5)
15 |
16 | gen.add("max_line_divergence", double_t, 0, "Maximum line divergence", 1.0, 0, 50)
17 |
18 | gen.add("min_project_dist", double_t, 0, "Minimum projection distance", 0.15, 0.01, 20)
19 | gen.add("max_project_dist", double_t, 0, "Maximum projection distance", 1.2, 0, 100)
20 |
21 | gen.add("xy_distance_threshold", double_t, 0, "Horizontal distance threshold", 0.6, 0, 10)
22 | gen.add("z_distance_threshold", double_t, 0, "Vertical distance threshold", 0.6, 0, 10)
23 |
24 | gen.add("P", double_t, 0, "Proportional gain for PID controller", 3.0, 0, 100)
25 | gen.add("I", double_t, 0, "Integral gain for PID controller", 0.001, 0, 10)
26 | gen.add("D", double_t, 0, "Derivative gain for PID controller", 65.0, 0, 100)
27 |
28 | gen.add("side_offset_mult", double_t, 0, "Side projection multiplier", 0.5, 0, 1.0)
29 |
30 | gen.add("rate", int_t, 0, "Update rate", 30, 10, 60)
31 |
32 | exit(gen.generate(PACKAGE, "line_planner", "LinePlanner"))
--------------------------------------------------------------------------------
/launch/line_planner.launch:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/utils.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import math
4 | import numpy as np
5 |
6 | from geometry_msgs.msg import Pose, Point
7 |
8 | def project_position(start, end, current, mindist, maxdist, line_divergence, side_offset):
9 |
10 | def unit_vector(vector):
11 | return vector / np.linalg.norm(vector + 1e-10) # A small constant to prevent division by zero
12 |
13 | def pose_to_np(p):
14 | return np.array([p.position.x, p.position.y])
15 |
16 | def np_to_point(np_array):
17 | return Point(np_array[0], np_array[1], 0)
18 |
19 | start_pos = pose_to_np(start)
20 | end_pos = pose_to_np(end)
21 | current_pos = pose_to_np(current)
22 |
23 | # Calculate the vector representing the line segment
24 | line = end_pos - start_pos
25 | unit_line = unit_vector(line)
26 |
27 | # Calculate the projection of the current point onto the line
28 | current_to_start = current_pos - start_pos
29 | projection_length = np.dot(current_to_start, unit_line)
30 | projection = start_pos + unit_line * projection_length
31 |
32 | # Clamp the projection to the start point if necessary
33 | if np.dot(projection - start_pos, start_pos - end_pos) > 0:
34 | projection = start_pos
35 |
36 | # Calculate the distance and new position based on input parameters
37 | deltadist = current_pos - projection
38 | value = 0 if line_divergence == 0 else (line_divergence - np.sqrt(deltadist.dot(deltadist))) / line_divergence
39 | distance = mindist + (maxdist - mindist) * clamp(value, 0.0, 1.0)
40 | new_pos = projection + unit_vector(end_pos - start_pos) * distance
41 |
42 | # Clamp the new position to the end point if necessary
43 | if np.dot(new_pos - end_pos, end_pos - start_pos) > 0:
44 | new_pos = end_pos
45 |
46 | # Add the side vector for more aggressive tracking
47 | additional_vector = -side_offset * (current_pos - projection)
48 |
49 | return np_to_point(new_pos + additional_vector)
50 |
51 | def clamp(num, min, max):
52 | return min if num < min else max if num > max else num
53 |
54 | def transform_to_pose(t):
55 | pose = Pose()
56 | pose.position.x = t.transform.translation.x
57 | pose.position.y = t.transform.translation.y
58 | pose.position.z = t.transform.translation.z
59 | pose.orientation.x = t.transform.rotation.x
60 | pose.orientation.y = t.transform.rotation.y
61 | pose.orientation.z = t.transform.rotation.z
62 | pose.orientation.w = t.transform.rotation.w
63 |
64 | return pose
65 |
66 | def get_dir(from_vec, to_vec):
67 | target_direction = [
68 | to_vec.x - from_vec.x,
69 | to_vec.y - from_vec.y,
70 | ]
71 | target_distance = math.sqrt(target_direction[0] ** 2 + target_direction[1] ** 2)
72 |
73 | return [target_direction[0] / target_distance, target_direction[1] / target_distance]
74 |
75 | class PID:
76 | def __init__(self, kp, ki, kd, target=0, windup_guard=20.0):
77 | self.kp = kp
78 | self.ki = ki
79 | self.kd = kd
80 | self.target = target
81 |
82 | self.windup_guard = windup_guard
83 |
84 | self.prev_error = 0.0
85 | self.integral = 0.0
86 | self.derivative = 0.0
87 |
88 | def reset(self):
89 | self.prev_error = 0.0
90 | self.integral = 0.0
91 | self.derivative = 0.0
92 |
93 | def compute(self, current_value):
94 | error = self.target - current_value
95 |
96 | self.integral += error
97 | self.integral = max(min(self.integral, self.windup_guard), -self.windup_guard)
98 |
99 | self.derivative = error - self.prev_error
100 |
101 | output = self.kp * error + self.ki * self.integral + self.kd * self.derivative
102 |
103 | self.prev_error = error
104 |
105 | return output
--------------------------------------------------------------------------------
/src/markers.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import rospy
4 | import math
5 | from visualization_msgs.msg import Marker, MarkerArray
6 | from std_msgs.msg import ColorRGBA
7 | from geometry_msgs.msg import Point, Pose
8 |
9 | class DebugMarkers:
10 |
11 | def __init__(self, planning_frame):
12 | self.planning_frame = planning_frame
13 | self.marker_pub = rospy.Publisher("line_planner/markers", MarkerArray, queue_size=1)
14 |
15 | self.queued_marker_array = None
16 | self.publish_timer = rospy.Timer(rospy.Duration(0.2), self._timer_callback) # 200 ms throttle
17 |
18 | def _timer_callback(self, event):
19 | if self.queued_marker_array is not None:
20 | self.marker_pub.publish(self.queued_marker_array)
21 | self.queued_marker_array = None
22 |
23 | def delete_debug_markers(self):
24 | marker = Marker()
25 | marker.action = Marker.DELETEALL
26 | markerArray = MarkerArray()
27 | markerArray.markers.append(marker)
28 | self.queued_marker_array = markerArray
29 |
30 | def get_side_vec(self, start_goal, end_goal, max_divergence):
31 | start = start_goal.position
32 | end = end_goal.position
33 |
34 | delta_x = end.x - start.x
35 | delta_y = end.y - start.y
36 |
37 | # Normalize the direction vector
38 | magnitude = math.hypot(delta_x, delta_y) + 0.0001
39 | delta_x = (delta_x / magnitude) * max_divergence
40 | delta_y = (delta_y / magnitude) * max_divergence
41 |
42 | # Calculate perpendicular vectors
43 | start_left = Point(start.x + delta_y, start.y - delta_x, start.z)
44 | start_right = Point(start.x - delta_y, start.y + delta_x, start.z)
45 |
46 | end_left = Point(end.x + delta_y, end.y - delta_x, end.z)
47 | end_right = Point(end.x - delta_y, end.y + delta_x, end.z)
48 |
49 | return start_left, start_right, end_left, end_right
50 |
51 | def draw_debug_markers(self, target_position, start_goal, end_goal, min_goal_dist, max_divergence):
52 |
53 | def sphere_marker(position, marker_id, r, g, b, size):
54 | marker = Marker()
55 | marker.header.frame_id = self.planning_frame
56 | marker.type = Marker.SPHERE
57 | marker.pose.position = position
58 | marker.pose.orientation.w = 1.0
59 | marker.scale.x = size
60 | marker.scale.y = size
61 | marker.scale.z = size
62 | marker.color = ColorRGBA(r, g, b, 0.5)
63 | marker.id = marker_id
64 | return marker
65 |
66 | def line_marker(p_from, p_to, marker_id, r, g, b):
67 | marker = Marker()
68 | marker.header.frame_id = self.planning_frame
69 | marker.type = Marker.LINE_STRIP
70 | marker.pose.orientation.w = 1.0
71 | marker.points = [p_from, p_to]
72 | marker.colors = [ColorRGBA(r, g, b, 1.0), ColorRGBA(r, g, b, 1.0)]
73 | marker.scale.x = 0.03
74 | marker.id = marker_id
75 | return marker
76 |
77 | start_left, start_right, end_left, end_right = self.get_side_vec(start_goal, end_goal, max_divergence)
78 |
79 | markerArray = MarkerArray()
80 | markerArray.markers.append(line_marker(start_left, end_left, 0, 0.1, 0.6, 0.1))
81 | markerArray.markers.append(line_marker(start_right, end_right, 1, 0.6, 0.2, 0.2))
82 | markerArray.markers.append(line_marker(start_goal.position, end_goal.position, 2, 0.870, 0.870, 0.870))
83 | markerArray.markers.append(sphere_marker(start_goal.position, 3, 1.0, 0.0, 0.0, 0.2))
84 | markerArray.markers.append(sphere_marker(end_goal.position, 4, 0.0, 0.0, 1.0, min_goal_dist * 2))
85 | markerArray.markers.append(sphere_marker(target_position, 5, 0.0, 1.0, 0.0, 0.2))
86 | self.queued_marker_array = markerArray
87 |
--------------------------------------------------------------------------------
/ReadMe.md:
--------------------------------------------------------------------------------
1 | # Simple Projecting Line Planner
2 |
3 | [](https://opensource.org/licenses/MIT) [](https://build.ros.org/job/Ndev__line_planner__ubuntu_focal_amd64/3/)
4 |
5 | A local planner that takes two goals (last and next), and follows a projected goal that keeps it close to the line segment between the two goals. Designed to resist strong side forces, such as from wind or water current.
6 |
7 | 
8 |
9 | ## Params
10 |
11 | ```xml
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | ```
48 |
49 | Here's a diagram showing the possible states of the planner, and which distances each parameter affects:
50 |
51 | 
52 |
53 |
54 | ## Subscribed Topics
55 |
56 | - `/move_base_simple/goal` (PoseStamped), takes the current position as the starting point and moves towards the goal
57 |
58 | - `/move_base_simple/clear` (Empty), stops all movement immediately
59 |
60 | - `/move_base_simple/waypoints` (Path), takes each two consecutive points and navigates along the line between them
61 |
62 | ## Published Topics
63 |
64 | - `/cmd_vel` (Twist), publishes velocity for vehicle motion
65 |
66 | - `line_planner/active` (Bool), publishes navigation status
67 |
68 | - `line_planner/plan` (Path), publishes a nav plan, also the entire route if given
69 |
70 | - `line_planner/markers` (MarkerArray), publishes debug markers shown above
71 |
72 | - `line_planner/vertical_target` (Float32), publishes the current altitude target
73 |
74 | ## Dynamic Reconfigure Params
75 |
76 | - `publish_debug_markers` (bool_t), if set to True, the node will publish markers for debugging purposes.
77 |
78 | - `ignore_altitude` (bool_t), if true, Z values will be disregarded.
79 |
80 | - `max_linear_speed` (double_t), the maximum linear speed of the robot.
81 |
82 | - `max_turning_speed` (double_t), the maximum speed at which the robot can turn.
83 |
84 | - `max_vertical_speed` (double_t), the maximum vertical speed of the robot.
85 |
86 | - `max_line_divergence` (double_t), the maximum distance that the robot can diverge from the line between the goals.
87 |
88 | - `min_project_dist` (double_t), the minimum projection distance for the goal.
89 |
90 | - `max_project_dist` (double_t), the maximum projection distance for the goal.
91 |
92 | - `goal_distance_threshold` (double_t), the distance at which a goal is considered reached.
93 |
94 | - `P` (double_t), the proportional gain for the PID controller that controls the heading of the robot.
95 |
96 | - `I` (double_t), the integral gain for the PID controller.
97 |
98 | - `D` (double_t), the derivative gain for the PID controller.
99 |
100 | - `side_offset_mult` (double_t), multiplier for the side projection of the robot's position.
101 |
102 | - `rate` (int_t), the rate at which the robot updates its position and velocity.
103 |
104 | Based on the code you've provided, here's an addition to the documentation that describes the functionality of your newly added node:
105 |
106 | ---
107 |
108 | ## Bounding Box to Path Helper
109 |
110 | This package also contains a small helper demo node that takes a rectangle defined as a PolygonStamped and turns it into various patterns which then get published as a Path that the line planer can execute. Right now it provides three different patterns: lawnmower, expanding square, and victor sierra.
111 |
112 | The lawnmower pattern creates a path in a back-and-forth, or "mowing the lawn" manner. The expanding square pattern creates a square outward spiral path. The victor sierra pattern is a coast guard sector search.
113 |
114 | it also accepts a home polygon, which it uses to add a final waypoint that points back home after following the path, so the robot doesn't get stuck out of wifi range.
115 |
116 | ## Params
117 |
118 | ```xml
119 |
120 |
121 |
122 | ```
123 |
124 | ## Subscribed Topics
125 |
126 | - `/area_to_path/lawnmower` (PolygonStamped), takes a 4 vertex square and creates a lawnmower path inside it.
127 |
128 | - `/area_to_path/expanding_square` (PolygonStamped), takes a 4 vertex square and creates an expanding square (spiral) path inside it.
129 |
130 | - `/area_to_path/victor_sierra` (PolygonStamped), takes a 4 vertex square and creates a victor sierra (star) path inside it.
131 |
132 | - `/area_to_path/home` (PolygonStamped), takes a 4 vertex square and sets the center point as the home position.
133 |
134 | ## Published Topics
135 |
136 | - `/move_base_simple/waypoints` (Path), publishes the created path.
137 |
--------------------------------------------------------------------------------
/src/area_to_path_node.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import math
4 | import rospy
5 | import itertools
6 |
7 | from nav_msgs.msg import Path
8 | from geometry_msgs.msg import PoseStamped, PolygonStamped, Point
9 |
10 | from dynamic_reconfigure.server import Server as DynamicReconfigureServer
11 | from line_planner.cfg import AreaToPathConfig
12 |
13 | class BoundingBox3DListener:
14 | def __init__(self):
15 | rospy.init_node("area_to_path_node")
16 |
17 | self.path_pub = rospy.Publisher("/move_base_simple/waypoints", Path, queue_size=10)
18 |
19 | self.lawnmower_sub = rospy.Subscriber("/area_to_path/lawnmower", PolygonStamped, self.lawnmower_callback)
20 | self.square_sub = rospy.Subscriber("/area_to_path/expanding_square", PolygonStamped, self.square_callback)
21 | self.sierra_sub = rospy.Subscriber("/area_to_path/victor_sierra", PolygonStamped, self.sierra_callback)
22 | self.home_sub = rospy.Subscriber("/area_to_path/home", PolygonStamped, self.home_callback)
23 |
24 | self.STEP_SIZE = rospy.get_param("~step_size", 2.0) # The step size (X meters) in the path
25 |
26 | self.home_point = None
27 | self.home_header = None
28 |
29 | self.reconfigure_server = DynamicReconfigureServer(AreaToPathConfig, self.dynamic_reconfigure_callback)
30 |
31 | def dynamic_reconfigure_callback(self, config, level):
32 | self.STEP_SIZE = config.step_size
33 | return config
34 |
35 | def send_path(self, header, poses):
36 | if len(poses) == 0:
37 | rospy.logwarn("Send a larger polygon or reduce step size.")
38 | return
39 |
40 | path = Path()
41 | path.header = header
42 | path.poses = poses
43 | self.path_pub.publish(path)
44 |
45 | def home_callback(self, msg):
46 | p = msg.polygon.points
47 |
48 | if len(p) != 4:
49 | rospy.logwarn("Invalid Polygon, path creation requires a 4 vertex square.")
50 | return
51 |
52 | self.home_point = Point(
53 | x=(p[0].x + p[2].x) / 2,
54 | y=(p[0].y + p[2].y) / 2
55 | )
56 |
57 | self.home_header = msg.header
58 |
59 | rospy.loginfo("Home point set.")
60 |
61 | def norm(self, point):
62 | p = Point()
63 |
64 | psum = math.sqrt(point.x **2 + point.y **2 + point.z **2)
65 | if psum == 0:
66 | return p
67 |
68 | p.x = point.x / psum
69 | p.y = point.y / psum
70 | p.z = point.z / psum
71 |
72 | return p
73 |
74 | def get_pose(self, header, x, y):
75 | pose = PoseStamped()
76 | pose.header = header
77 | pose.pose.position.x = x
78 | pose.pose.position.y = y
79 | pose.pose.position.z = 0
80 | pose.pose.orientation.w = 1.0
81 | return pose
82 |
83 | def sierra_callback(self, msg):
84 | p = msg.polygon.points
85 |
86 | if len(p) != 4:
87 | rospy.logwarn("Invalid Polygon, path creation requires a 4 vertex square.")
88 | return
89 |
90 | poses = []
91 |
92 | # Calculate vectors between points
93 | vec_side_1 = Point(x=p[1].x - p[0].x, y=p[1].y - p[0].y)
94 | vec_side_2 = Point(x=p[3].x - p[0].x, y=p[3].y - p[0].y)
95 |
96 | # Compute the lengths of the two sides and the average (which will be our radius)
97 | length_side_1 = math.sqrt(vec_side_1.x**2 + vec_side_1.y**2)
98 | length_side_2 = math.sqrt(vec_side_2.x**2 + vec_side_2.y**2)
99 | radius = (length_side_1 + length_side_2) / 4
100 |
101 | # Calculate the center point of the search area
102 | center = Point(
103 | x=(p[0].x + p[2].x) / 2,
104 | y=(p[0].y + p[2].y) / 2
105 | )
106 |
107 | poses.append(self.get_pose(msg.header, center.x, center.y))
108 |
109 | angle = math.radians(0)
110 | poses.append(self.get_pose(msg.header,
111 | center.x + radius * math.cos(angle),
112 | center.y + radius * math.sin(angle)
113 | ))
114 |
115 | angle = math.radians(60)
116 | poses.append(self.get_pose(msg.header,
117 | center.x + radius * math.cos(angle),
118 | center.y + radius * math.sin(angle)
119 | ))
120 |
121 | angle = math.radians(240)
122 | poses.append(self.get_pose(msg.header,
123 | center.x + radius * math.cos(angle),
124 | center.y + radius * math.sin(angle)
125 | ))
126 |
127 | angle = math.radians(300)
128 | poses.append(self.get_pose(msg.header,
129 | center.x + radius * math.cos(angle),
130 | center.y + radius * math.sin(angle)
131 | ))
132 |
133 | angle = math.radians(120)
134 | poses.append(self.get_pose(msg.header,
135 | center.x + radius * math.cos(angle),
136 | center.y + radius * math.sin(angle)
137 | ))
138 |
139 | angle = math.radians(180)
140 | poses.append(self.get_pose(msg.header,
141 | center.x + radius * math.cos(angle),
142 | center.y + radius * math.sin(angle)
143 | ))
144 |
145 | poses.append(self.get_pose(msg.header, center.x, center.y))
146 |
147 | if self.home_point != None:
148 | poses.append(self.get_pose(self.home_header, self.home_point.x, self.home_point.y))
149 |
150 | self.send_path(msg.header, poses)
151 |
152 | def square_callback(self, msg):
153 | p = msg.polygon.points
154 |
155 | if len(p) != 4:
156 | rospy.logwarn("Invalid Polygon, path creation requires a 4 vertex square.")
157 | return
158 |
159 | poses = []
160 |
161 | # Calculate vectors between points
162 | vec_side_1 = Point(x=p[1].x - p[0].x, y=p[1].y - p[0].y)
163 | vec_side_2 = Point(x=p[3].x - p[0].x, y=p[3].y - p[0].y)
164 |
165 | # Compute the lengths of the two sides
166 | length_side_1 = math.sqrt(vec_side_1.x**2 + vec_side_1.y**2)
167 | length_side_2 = math.sqrt(vec_side_2.x**2 + vec_side_2.y**2)
168 |
169 | # Determine the center of the square
170 | center = Point(x=p[0].x + vec_side_1.x/2 + vec_side_2.x/2, y=p[0].y + vec_side_1.y/2 + vec_side_2.y/2)
171 |
172 | # The spiral is drawn as a series of line segments, starting from the center of the square
173 | pos = [center.x, center.y]
174 | dir = [0, -1] # Initial direction: up
175 |
176 | # Continue drawing the spiral until we reach the edge of the square
177 | for step in itertools.count(start=1, step=1):
178 | if self.STEP_SIZE * step > max(length_side_1, length_side_2): # If we would go beyond the edge of the square, end the spiral
179 | break
180 |
181 | for _ in range(2): # Each "layer" of the spiral consists of two steps
182 | for _ in range(step):
183 | if abs(pos[0] - center.x) <= length_side_1 / 2 and abs(pos[1] - center.y) <= length_side_2 / 2:
184 | pos[0] += dir[0] * self.STEP_SIZE
185 | pos[1] += dir[1] * self.STEP_SIZE
186 |
187 |
188 | poses.append(self.get_pose(msg.header, pos[0], pos[1]))
189 | dir = [-dir[1], dir[0]] # Rotate direction 90 degrees to the right
190 |
191 | if self.home_point != None:
192 | poses.append(self.get_pose(self.home_header, self.home_point.x, self.home_point.y))
193 |
194 | self.send_path(msg.header, poses)
195 |
196 |
197 | def lawnmower_callback(self, msg):
198 | p = msg.polygon.points
199 |
200 | if len(p) != 4:
201 | rospy.logwarn("Invalid Polygon, path creation requires a 4 vertex square.")
202 | return
203 |
204 | poses = []
205 |
206 | # Calculate vectors between points
207 | vec_side_1 = Point(x=p[1].x - p[0].x, y=p[1].y - p[0].y)
208 | vec_side_2 = Point(x=p[3].x - p[0].x, y=p[3].y - p[0].y)
209 |
210 | # Compute the lengths of the two sides
211 | length_side_1 = math.sqrt(vec_side_1.x**2 + vec_side_1.y**2)
212 | length_side_2 = math.sqrt(vec_side_2.x**2 + vec_side_2.y**2)
213 |
214 | # Decide which side to move along based on their lengths
215 | if length_side_1 > length_side_2:
216 | vec_step = self.norm(vec_side_2)
217 | vec_side = vec_side_1
218 | steps = math.ceil(length_side_2 / self.STEP_SIZE)
219 | else:
220 | vec_step = self.norm(vec_side_1)
221 | vec_side = vec_side_2
222 | steps = math.ceil(length_side_1 / self.STEP_SIZE)
223 |
224 | vec_step.x *= self.STEP_SIZE
225 | vec_step.y *= self.STEP_SIZE
226 |
227 | for i in range(steps):
228 | if i % 2 == 0: # For every other line, the direction should be reversed
229 | # Start of the line
230 | poses.append(self.get_pose(
231 | msg.header,
232 | p[0].x + i * vec_step.x,
233 | p[0].y + i * vec_step.y
234 | ))
235 |
236 | # End of the line
237 | poses.append(self.get_pose(
238 | msg.header,
239 | p[0].x + i * vec_step.x + vec_side.x,
240 | p[0].y + i * vec_step.y + vec_side.y,
241 | ))
242 | else:
243 | # Start of the line
244 | poses.append(self.get_pose(
245 | msg.header,
246 | p[0].x + i * vec_step.x + vec_side.x,
247 | p[0].y + i * vec_step.y + vec_side.y,
248 | ))
249 |
250 | # End of the line
251 | poses.append(self.get_pose(
252 | msg.header,
253 | p[0].x + i * vec_step.x,
254 | p[0].y + i * vec_step.y,
255 | ))
256 |
257 | if self.home_point != None:
258 | poses.append(self.get_pose(self.home_header, self.home_point.x, self.home_point.y))
259 |
260 | self.send_path(msg.header, poses)
261 |
262 |
263 | box = BoundingBox3DListener()
264 | rospy.spin()
265 |
266 |
267 |
--------------------------------------------------------------------------------
/src/line_planner_node.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from xmlrpc.client import Boolean
3 | import rospy
4 | import math
5 | import tf
6 | import tf2_ros
7 |
8 | from utils import *
9 | from markers import DebugMarkers
10 |
11 | from geometry_msgs.msg import Twist, Point
12 | from visualization_msgs.msg import Marker, MarkerArray
13 | from geometry_msgs.msg import PoseStamped
14 | from std_msgs.msg import Empty, ColorRGBA, Bool, Float32
15 |
16 | from tf.transformations import euler_from_quaternion
17 | from tf2_geometry_msgs import do_transform_pose
18 |
19 | from dynamic_reconfigure.server import Server as DynamicReconfigureServer
20 |
21 | from line_planner.cfg import LinePlannerConfig
22 | from nav_msgs.msg import Path
23 |
24 | ROBOT_FRAME = "base_link"
25 | PLANNING_FRAME = "map"
26 |
27 | class GoalServer:
28 |
29 | def __init__(self, tf2_buffer, update_plan):
30 | self.start_goal = None
31 | self.end_goal = None
32 | self.tf2_buffer = tf2_buffer
33 | self.update_plan = update_plan
34 |
35 | self.simple_goal_sub = rospy.Subscriber("/move_base_simple/waypoints", Path, self.route_callback)
36 | self.simple_goal_sub = rospy.Subscriber("/move_base_simple/goal", PoseStamped, self.goal_callback)
37 | self.clear_goals_sub = rospy.Subscriber("/move_base_simple/clear", Empty, self.reset)
38 |
39 | self.vertical_pub = rospy.Publisher("line_planner/vertical_target", Float32, queue_size=1)
40 |
41 | self.start_goal = None
42 | self.end_goal = None
43 | self.route = []
44 | self.route_index = 0
45 |
46 | def reset(self,msg):
47 | self.start_goal = None
48 | self.end_goal = None
49 | self.route = []
50 | self.route_index = 0
51 | self.update_plan()
52 |
53 | def goal_callback(self, goal):
54 | self.route = []
55 | self.route_index = 0
56 | self.process_goal(goal)
57 | self.update_plan()
58 |
59 | def route_callback(self, msg):
60 | rospy.loginfo("New route received.")
61 |
62 | if len(msg.poses) == 0:
63 | rospy.loginfo("Empty route, stopping.")
64 | self.reset(None)
65 | return
66 |
67 | self.route = msg.poses
68 | self.route_index = 0
69 | self.process_goal(self.route[0])
70 | self.update_plan()
71 |
72 | def get_goals(self):
73 | return self.start_goal, self.end_goal
74 |
75 | def goal_reached(self):
76 | if len(self.route) > 0:
77 | rospy.loginfo("Goal #%i reached.",self.route_index)
78 | if self.route_index < len(self.route)-1:
79 | self.route_index +=1
80 | self.process_goal(self.route[self.route_index])
81 | else:
82 | rospy.loginfo("-> Route finished.")
83 | self.start_goal = None
84 | self.end_goal = None
85 | else:
86 | rospy.loginfo("Simple goal reached.")
87 | self.start_goal = None
88 | self.end_goal = None
89 |
90 | self.update_plan()
91 |
92 |
93 | def set_goal_pair(self, endgoal):
94 | if len(self.route) == 0 or self.route_index == 0:
95 | try:
96 | self.start_goal = transform_to_pose(self.tf2_buffer.lookup_transform(PLANNING_FRAME, ROBOT_FRAME, rospy.Time(0)))
97 | self.end_goal = endgoal
98 | except (tf2_ros.LookupException, tf2_ros.ConnectivityException, tf2_ros.ExtrapolationException) as e:
99 | rospy.logwarn("TF2 exception: %s", e)
100 | else:
101 | self.start_goal = self.end_goal
102 | self.end_goal = endgoal
103 |
104 | vert_msg = Float32()
105 | vert_msg.data = self.end_goal.position.z
106 | self.vertical_pub.publish(vert_msg)
107 |
108 | self.update_plan()
109 |
110 | def process_goal(self, goal):
111 | if PLANNING_FRAME == goal.header.frame_id:
112 | rospy.loginfo("------------------")
113 | rospy.loginfo("Received goal in planning ("+PLANNING_FRAME+") frame.")
114 | rospy.loginfo("Position: X: %f, Y: %f, Z: %f", goal.pose.position.x, goal.pose.position.y, goal.pose.position.z)
115 | rospy.loginfo("Orientation: X: %f, Y: %f, Z: %f, W: %f", goal.pose.orientation.x, goal.pose.orientation.y, goal.pose.orientation.z, goal.pose.orientation.w)
116 | self.set_goal_pair(goal.pose)
117 | else:
118 | try:
119 | transform = self.tf2_buffer.lookup_transform(PLANNING_FRAME, goal.header.frame_id, rospy.Time(0))
120 | goal_transformed = do_transform_pose(goal, transform)
121 | self.set_goal_pair(goal_transformed.pose)
122 | rospy.loginfo("------------------")
123 | rospy.loginfo("Received goal in "+goal.header.frame_id+" frame, transformed to "+PLANNING_FRAME+".")
124 | rospy.loginfo("Position: X: %f, Y: %f, Z: %f", goal_transformed.pose.position.x, goal_transformed.pose.position.y, goal_transformed.pose.position.z)
125 | rospy.loginfo("Orientation: X: %f, Y: %f, Z: %f, W: %f", goal_transformed.pose.orientation.x, goal_transformed.pose.orientation.y, goal_transformed.pose.orientation.z, goal_transformed.pose.orientation.w)
126 |
127 | except (tf2_ros.LookupException, tf2_ros.ConnectivityException, tf2_ros.ExtrapolationException) as e:
128 | rospy.logwarn("TF2 exception: %s", e)
129 | return
130 |
131 | class LineFollowingController:
132 | def __init__(self):
133 | rospy.init_node("line_following_controller")
134 | global ROBOT_FRAME, PLANNING_FRAME
135 |
136 | ROBOT_FRAME = rospy.get_param('~robot_frame', 'base_link')
137 | PLANNING_FRAME = rospy.get_param('~planning_frame', 'map')
138 |
139 | self.MIN_GOAL_XY_DIST = rospy.get_param('~xy_distance_threshold', 0.5)
140 | self.MIN_GOAL_Z_DIST = rospy.get_param('~z_distance_threshold', 0.5)
141 |
142 | self.MAX_LINEAR_SPD = rospy.get_param('~max_linear_speed', 0.45)
143 | self.MAX_VERTICAL_SPD = rospy.get_param('~max_vertical_speed', 0.5)
144 | self.MAX_ANGULAR_SPD = rospy.get_param('~max_turning_speed', 0.9)
145 |
146 | self.LINE_DIVERGENCE = rospy.get_param('~max_line_divergence', 1.0)
147 | self.MIN_PROJECT_DIST = rospy.get_param('~min_project_dist', 0.15)
148 | self.MAX_PROJECT_DIST = rospy.get_param('~max_project_dist', 1.2)
149 |
150 | self.SIDE_OFFSET_MULT = rospy.get_param('~side_offset_mult', 0.5)
151 |
152 | self.IGNORE_ALTITUDE = rospy.get_param('~ignore_altitude', False)
153 |
154 | self.DEBUG_MARKERS = rospy.get_param('~publish_debug_markers', True)
155 | self.markers = DebugMarkers(PLANNING_FRAME)
156 |
157 | self.tf_listener = tf.TransformListener()
158 |
159 | self.tf2_buffer = tf2_ros.Buffer()
160 | self.tf2_listener = tf2_ros.TransformListener(self.tf2_buffer)
161 |
162 | self.cmd_vel_pub = rospy.Publisher("cmd_vel", Twist, queue_size=1)
163 |
164 | self.status_pub = rospy.Publisher("line_planner/active", Bool, queue_size=1, latch=True)
165 | self.plan_pub = rospy.Publisher("line_planner/plan", Path, queue_size=1, latch=True)
166 | self.marker_pub = rospy.Publisher("line_planner/markers", MarkerArray, queue_size=1)
167 |
168 | self.pid = PID(
169 | rospy.get_param('P', 3.0),
170 | rospy.get_param('I', 0.001),
171 | rospy.get_param('D', 65.0)
172 | )
173 |
174 | self.pid_vert = PID(
175 | rospy.get_param('P', 3.0),
176 | rospy.get_param('I', 0.001),
177 | rospy.get_param('D', 65.0)
178 | )
179 |
180 | self.goal_server = GoalServer(self.tf2_buffer, self.update_plan)
181 | self.active = False
182 |
183 | self.reconfigure_server = DynamicReconfigureServer(LinePlannerConfig, self.dynamic_reconfigure_callback)
184 |
185 | self.marker_publish_skip = 0
186 | self.status_pub.publish(False)
187 |
188 | rospy.loginfo("Line planner started.")
189 | rospy.loginfo("Robot frame: "+ROBOT_FRAME)
190 | rospy.loginfo("Planning frame: "+PLANNING_FRAME)
191 |
192 | def dynamic_reconfigure_callback(self, config, level):
193 | self.pid.kp = config.P
194 | self.pid.ki = config.I
195 | self.pid.kd = config.D
196 |
197 | self.pid_vert.kp = config.P
198 | self.pid_vert.ki = config.I
199 | self.pid_vert.kd = config.D
200 |
201 | self.MIN_GOAL_XY_DIST = config.xy_distance_threshold
202 | self.MIN_GOAL_Z_DIST = config.z_distance_threshold
203 |
204 | self.MAX_LINEAR_SPD = config.max_linear_speed
205 | self.MAX_ANGULAR_SPD = config.max_turning_speed
206 | self.MAX_VERTICAL_SPD = config.max_vertical_speed
207 |
208 | self.LINE_DIVERGENCE = config.max_line_divergence
209 | self.MIN_PROJECT_DIST = config.min_project_dist
210 | self.MAX_PROJECT_DIST = config.max_project_dist
211 |
212 | self.SIDE_OFFSET_MULT = config.side_offset_mult
213 |
214 | self.DEBUG_MARKERS = config.publish_debug_markers
215 | self.IGNORE_ALTITUDE = config.ignore_altitude
216 |
217 | return config
218 |
219 | def get_angle_error(self, current_pose, target_position):
220 | _, _, current_yaw = euler_from_quaternion([
221 | current_pose.orientation.x,
222 | current_pose.orientation.y,
223 | current_pose.orientation.z,
224 | current_pose.orientation.w,
225 | ])
226 |
227 | delta_x = target_position.x - current_pose.position.x
228 | delta_y = target_position.y - current_pose.position.y
229 | target_yaw = math.atan2(delta_y, delta_x)
230 |
231 | angle_error = target_yaw - current_yaw
232 | angle_error = -math.atan2(math.sin(angle_error), math.cos(angle_error))
233 |
234 | return angle_error
235 |
236 | def get_distance(self, pose, goal):
237 | deltax = goal.position.x - pose.position.x
238 | deltay = goal.position.y - pose.position.y
239 | return math.sqrt(deltax** 2 + deltay ** 2)
240 |
241 | def get_linear_velocity(self, distance, angle_error):
242 | ANGLE_40_RAD = math.radians(40)
243 | ANGLE_60_RAD = math.radians(60)
244 | ANGLE_120_RAD = math.radians(120)
245 | ANGLE_140_RAD = math.radians(140)
246 |
247 | abs_angle_error = math.fabs(angle_error)
248 | vel_multiplier = 0.0
249 |
250 | if abs_angle_error <= ANGLE_40_RAD:
251 | # Full speed ahead
252 | vel_multiplier = 1.0
253 | elif abs_angle_error <= ANGLE_60_RAD:
254 | # Linearly decrease speed from 100% at 30 deg to 0% at 60 deg.
255 | scale = (abs_angle_error - ANGLE_40_RAD) / (ANGLE_60_RAD - ANGLE_40_RAD)
256 | vel_multiplier = 1.0 - scale
257 | elif abs_angle_error <= ANGLE_120_RAD:
258 | # Turn in place between 60 and 120 deg
259 | vel_multiplier = 0.0
260 | elif abs_angle_error <= ANGLE_140_RAD:
261 | # Reverse from 0% at 120 deg to -100% at 180 deg for J turn behaviour.
262 | scale = (abs_angle_error - ANGLE_120_RAD) / (ANGLE_140_RAD - ANGLE_120_RAD)
263 | vel_multiplier = -scale
264 |
265 | linear_vel = self.MAX_LINEAR_SPD * vel_multiplier
266 |
267 | # Slow down while waiting for depth to match
268 | if distance < self.MIN_GOAL_XY_DIST:
269 | linear_vel *= 0.1
270 |
271 | return max(-self.MAX_LINEAR_SPD, min(linear_vel, self.MAX_LINEAR_SPD))
272 |
273 |
274 | def update(self):
275 | start_goal, end_goal = self.goal_server.get_goals()
276 |
277 | if start_goal == None or end_goal == None:
278 | if self.active:
279 | self.reset()
280 | return
281 |
282 | try:
283 | self.active = True
284 | self.status_pub.publish(True)
285 | pose = transform_to_pose(self.tf2_buffer.lookup_transform(PLANNING_FRAME, ROBOT_FRAME, rospy.Time(0)))
286 |
287 | target_position = project_position(
288 | start_goal,
289 | end_goal,
290 | pose,
291 | self.MIN_PROJECT_DIST,
292 | self.MAX_PROJECT_DIST,
293 | self.LINE_DIVERGENCE,
294 | self.SIDE_OFFSET_MULT
295 | )
296 |
297 | angle_error = self.get_angle_error(pose, target_position)
298 | angular_velocity = clamp(self.pid.compute(angle_error), -self.MAX_ANGULAR_SPD, self.MAX_ANGULAR_SPD)
299 | target_distance = self.get_distance(end_goal, pose)
300 | linear_velocity = self.get_linear_velocity(target_distance, angle_error)
301 |
302 | vertical_error = pose.position.z - end_goal.position.z
303 | vertical_velocity = clamp(self.pid_vert.compute(vertical_error), -self.MAX_VERTICAL_SPD, self.MAX_VERTICAL_SPD)
304 |
305 | if target_distance > self.MIN_GOAL_XY_DIST:
306 | pass
307 | elif math.fabs(vertical_error) > self.MIN_GOAL_Z_DIST and not self.IGNORE_ALTITUDE:
308 | angular_velocity = 0
309 | else:
310 | linear_velocity = 0
311 | angular_velocity = 0
312 | vertical_velocity = 0
313 | self.goal_server.goal_reached()
314 |
315 | self.send_twist(linear_velocity, angular_velocity, vertical_velocity)
316 |
317 | if self.DEBUG_MARKERS:
318 | self.markers.draw_debug_markers(target_position, start_goal, end_goal, self.MIN_GOAL_XY_DIST, self.LINE_DIVERGENCE)
319 |
320 | except (tf.LookupException, tf.ConnectivityException, tf.ExtrapolationException):
321 | rospy.logwarn("TF Exception")
322 |
323 | def update_plan(self):
324 |
325 | msg = Path()
326 | msg.header.frame_id = PLANNING_FRAME
327 |
328 | if len(self.goal_server.route) > 1:
329 | msg.poses = self.goal_server.route[self.goal_server.route_index:]
330 | else:
331 | start_goal, end_goal = self.goal_server.get_goals()
332 |
333 | if start_goal == None or end_goal == None:
334 | self.plan_pub.publish(Path())
335 | return
336 |
337 | start_stamped = PoseStamped()
338 | start_stamped.header.frame_id = PLANNING_FRAME
339 | start_stamped.pose = start_goal
340 | msg.poses.append(start_stamped)
341 |
342 | end_stamped = PoseStamped()
343 | end_stamped.header.frame_id = PLANNING_FRAME
344 | end_stamped.pose = end_goal
345 |
346 | msg.poses = [start_stamped, end_stamped]
347 |
348 | self.plan_pub.publish(msg)
349 |
350 | def send_twist(self, linear, angular, vert):
351 |
352 | #sanity check, just in case
353 | if math.isnan(linear):
354 | linear = 0
355 |
356 | if math.isnan(angular):
357 | angular = 0
358 |
359 | if self.IGNORE_ALTITUDE or math.isnan(vert):
360 | vert = 0
361 |
362 | twist = Twist()
363 | twist.linear.x = linear
364 | twist.linear.z = vert
365 | twist.angular.z = angular
366 | self.cmd_vel_pub.publish(twist)
367 |
368 | def reset(self):
369 | self.send_twist(0, 0, 0)
370 |
371 | if self.DEBUG_MARKERS:
372 | self.markers.delete_debug_markers()
373 |
374 | self.active = False
375 | self.status_pub.publish(False)
376 | self.pid.reset()
377 | self.pid_vert.reset()
378 |
379 | ctrl = LineFollowingController()
380 | rate = rospy.Rate(rospy.get_param('rate', 30))
381 | rospy.on_shutdown(ctrl.reset)
382 |
383 | while not rospy.is_shutdown():
384 | ctrl.update()
385 | rate.sleep()
386 |
--------------------------------------------------------------------------------
/docs/diagram.drawio:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
--------------------------------------------------------------------------------