|
| 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