Skip to content

Commit 7865c4d

Browse files
committed
gps_to_path: Added path coverage computation.
1 parent 2122f28 commit 7865c4d

File tree

2 files changed

+185
-0
lines changed

2 files changed

+185
-0
lines changed

gps_to_path/launch/visualization.launch

+7
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@
8989
<remap from="waypoints" to="original_waypoints" />
9090
<remap from="path" to="original_waypoints_path" />
9191
</node>
92+
93+
<node name="path_coverage" pkg="gps_to_path" type="path_coverave" respawn="true">
94+
<param name="out_frame_id" value="utm_local" />
95+
<param name="num_dimensions" value="2" />
96+
<remap from="~path" to="/original_waypoints_path" />
97+
<remap from="~odom" to="/gps_odom" />
98+
</node>
9299

93100
<!-- Support for remaining battery visualization. -->
94101
<node name="$(anon tradr_battery_viz)" pkg="topic_tools" type="relay_field"

gps_to_path/nodes/path_coverage

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#!/usr/bin/env python
2+
3+
# SPDX-License-Identifier: BSD-3-Clause
4+
# SPDX-FileCopyrightText: Czech Technical University in Prague
5+
6+
"""
7+
Visualize coverage of a path by some odometry.
8+
9+
Publications:
10+
- `~coverage` (:class:`sensor_msgs.msg.PointCloud2`): Coverage pointcloud.
11+
- Channel 'coverage' contains the binary covered (1)/not covered (0) information.
12+
- Channel 'min_distance' is the minimum distance of the point on path from an odometry point (will be infinity for
13+
points on path that never were the closest to any odometry measurement).
14+
15+
Subscriptions:
16+
- `~path` (:class:`nav_msgs.msg.Path`): The path whose coverage should be visualized.
17+
- `~odom` (:class:`nav_msgs.msg.Odometry`): The odometry that fulfills the path points.
18+
19+
Parameters:
20+
- `~max_coverage_distance` (float, defaults to 1.0, meters): Maximum distance from path to be treated as covered.
21+
- `~path_points_distance` (float, defaults to 1.0, meters): Maximum distance between two points on the path (used for
22+
discretization of the path).
23+
- `~out_frame_id` (string, defaults to empty string): Frame ID of the output pointcloud. If empty, path frame ID is
24+
used.
25+
- `~num_dimensions` (int, 2 or 3, defaults to 3): Whether to compute the distance only in XY or in XYZ.
26+
"""
27+
28+
from threading import Lock
29+
30+
import rospy
31+
from geometry_msgs.msg import PoseStamped
32+
from ros_numpy import numpify, msgify
33+
from tf2_ros import TransformListener, Buffer
34+
import tf2_geometry_msgs
35+
36+
from nav_msgs.msg import Path, Odometry
37+
from sensor_msgs.msg import PointCloud2
38+
import numpy as np
39+
40+
from scipy.spatial import cKDTree as KDTree
41+
42+
import cras
43+
44+
45+
class PathCoverage(cras.Node):
46+
"""Visualize path coverage."""
47+
48+
def __init__(self):
49+
super(PathCoverage, self).__init__()
50+
51+
self.path_points = None
52+
self.path_points_kdtree = None
53+
self.min_distances = None
54+
55+
self.mutex = Lock()
56+
self.tf_buffer = Buffer()
57+
self.tf_listener = TransformListener(self.tf_buffer)
58+
59+
self.max_coverage_distance = cras.get_param('~max_coverage_distance', 1.0, "m")
60+
self.path_points_distance = cras.get_param('~path_points_distance', 1.0, "m")
61+
self.out_frame_id = cras.get_param('~out_frame_id', "")
62+
self.ndims = cras.get_param('~num_dimensions', 3)
63+
if self.ndims not in (2, 3):
64+
raise RuntimeError("num_dimensions has to be either 2 or 3")
65+
self.pub_coverage = rospy.Publisher('~coverage', PointCloud2, queue_size=1, latch=True)
66+
self.sub_path = rospy.Subscriber('~path', Path, self.path_cb, queue_size=1)
67+
self.sub_odom = rospy.Subscriber('~odom', Odometry, self.odom_cb, queue_size=100)
68+
69+
def reset(self):
70+
self.sub_path.unregister()
71+
with self.mutex:
72+
self.path_points = None
73+
self.path_points_kdtree = None
74+
self.min_distances = None
75+
76+
self.sub_path = rospy.Subscriber('~path', Path, self.path_cb, queue_size=1)
77+
super(PathCoverage, self).reset()
78+
79+
def path_cb(self, msg):
80+
"""
81+
:param Path msg:
82+
:return:
83+
"""
84+
self.check_time_jump()
85+
86+
if len(self.out_frame_id) == 0:
87+
self.out_frame_id = msg.header.frame_id
88+
89+
with self.mutex:
90+
new_points = self.path_msg_to_points(msg)
91+
if self.path_points is None:
92+
self.path_points = new_points
93+
self.min_distances = np.ones(new_points.shape[0]) * np.inf
94+
else:
95+
distances, _ = self.path_points_kdtree.query(
96+
new_points[:, :self.ndims], k=1, distance_upper_bound=self.path_points_distance)
97+
not_present_idxs = np.where(np.isinf(distances))
98+
added_points = new_points[not_present_idxs]
99+
if added_points.shape[0] > 0:
100+
self.path_points = np.vstack((self.path_points, added_points))
101+
self.min_distances = np.hstack((self.min_distances, np.ones(added_points.shape[0]) * np.inf))
102+
self.path_points_kdtree = KDTree(self.path_points[:, :self.ndims])
103+
104+
def odom_cb(self, msg):
105+
"""
106+
:param Odometry msg:
107+
:return:
108+
"""
109+
self.check_time_jump()
110+
if self.path_points is None:
111+
return
112+
113+
pos_stamped = PoseStamped()
114+
pos_stamped.header = msg.header
115+
pos_stamped.pose = msg.pose.pose
116+
pos_transformed = self.tf_buffer.transform(pos_stamped, self.out_frame_id)
117+
pos = numpify(pos_transformed.pose.position)[:self.ndims]
118+
with self.mutex:
119+
d, i = self.path_points_kdtree.query(pos, k=1, distance_upper_bound=self.max_coverage_distance)
120+
if np.isfinite(d):
121+
self.min_distances[i] = min(self.min_distances[i], d)
122+
pcl = np.zeros((self.path_points.shape[0],), dtype=[
123+
('x', np.float32),
124+
('y', np.float32),
125+
('z', np.float32),
126+
('coverage', np.uint8),
127+
('min_distance', np.float32),
128+
])
129+
pcl['x'] = self.path_points[:, 0]
130+
pcl['y'] = self.path_points[:, 1]
131+
pcl['z'] = self.path_points[:, 2]
132+
pcl['coverage'] = self.min_distances <= self.max_coverage_distance
133+
pcl['min_distance'] = self.min_distances
134+
coverage_pct = np.sum(pcl['coverage']) / float(self.min_distances.shape[0]) * 100.0
135+
pcl_msg = msgify(PointCloud2, pcl)
136+
pcl_msg.header.frame_id = self.out_frame_id
137+
pcl_msg.header.stamp = msg.header.stamp
138+
self.pub_coverage.publish(pcl_msg)
139+
140+
rospy.loginfo_throttle(10.0, "Path coverage %.1f%%" % (coverage_pct,))
141+
142+
def path_msg_to_points(self, msg):
143+
"""
144+
:param Path msg:
145+
:return:
146+
"""
147+
points = list()
148+
for pose in msg.poses:
149+
point = numpify(self.tf_buffer.transform(pose, self.out_frame_id).pose.position)
150+
if len(points) == 0 or np.linalg.norm(point - points[-1]) <= self.path_points_distance:
151+
points.append(point)
152+
else:
153+
dir = (point - points[-1])
154+
length = np.linalg.norm(dir)
155+
dir /= length
156+
dir *= self.path_points_distance
157+
# dir is now a vector of length path_points_distance
158+
for _ in range(1, int(np.floor(length / self.path_points_distance))):
159+
p = points[-1] + dir
160+
points.append(p)
161+
points.append(point)
162+
163+
return np.array(points)
164+
165+
@staticmethod
166+
def run():
167+
rospy.spin()
168+
169+
170+
def main():
171+
rospy.init_node('path_coverage')
172+
173+
node = PathCoverage()
174+
node.run()
175+
176+
177+
if __name__ == '__main__':
178+
main()

0 commit comments

Comments
 (0)