Source code for irsim.lib.algorithm.rvo

"""
This file is the implementation of the Reciprocal Velocity Obstacle (RVO) algorithm for multi-robot collision avoidance.

Author: Ruihua Han

reference: https://github.com/MengGuo/RVO_Py_MAS
"""

from math import asin, atan2, cos, pi, sin, sqrt

import numpy as np

from irsim.util.util import dist_hypot, log_error


[docs] class reciprocal_vel_obs: """ A class to implement the Reciprocal Velocity Obstacle (RVO) algorithm for multi-robot collision avoidance. Args: state (list): The rvo state of the agent [x, y, vx, vy, radius, vx_des, vy_des]. obs_state_list (list) : List of states of static obstacles [[x, y, vx, vy, radius]]. vxmax (float): Maximum velocity in the x direction. vymax (float): Maximum velocity in the y direction. acce (float): Acceleration limit. factor (float): Penalty weighting factor for velocity selection. line_obs_list (list): List of line segments [[x1, y1, x2, y2], ...]. """ def __init__( self, state: list, obs_state_list=None, vxmax=1.5, vymax=1.5, acce=0.5, factor=1.0, line_obs_list=None, ): if obs_state_list is None: obs_state_list = [] if line_obs_list is None: line_obs_list = [] self.state = state self.obs_state_list = obs_state_list self.line_obs_list = line_obs_list self.vxmax = vxmax self.vymax = vymax self.acce = acce self.factor = factor
[docs] def update(self, state, obs_state_list, line_obs_list=None): """Update the agent, circular-obstacle, and line-obstacle states.""" self.state = state self.obs_state_list = obs_state_list self.line_obs_list = line_obs_list if line_obs_list is not None else []
[docs] def cal_vel(self, mode="rvo"): """ Calculate the velocity of the agent based on the Reciprocal Velocity Obstacle (RVO) algorithm. Args: mode (str): The vo configure to calculate the velocity. It can be "rvo", "hrvo", or "vo". - rvo: Reciprocal Velocity Obstacle (RVO) algorithm, for multi-robot collision avoidance. - hrvo: Hybrid Reciprocal Velocity Obstacle (HRVO) algorithm, for multi-robot collision avoidance. - vo: Velocity Obstacle (VO) algorithm, for obstacle-robot collision avoidance. Returns: list[float]: Selected velocity [vx, vy]. """ if mode == "rvo": rvo_list = self.config_rvo() elif mode == "hrvo": rvo_list = self.config_hrvo() elif mode == "vo": rvo_list = self.config_vo() else: # pragma: no cover - defensive guard; callers pass a valid mode log_error("wrong method mode, please input vo, rvo or hrvo") velocities, outside = self._candidate_velocities(rvo_list) return self.vel_select(velocities[outside], velocities[~outside])
@staticmethod def _cone_angles( x: float, y: float, r: float, mx: float, my: float, mr: float ) -> tuple[float, float, float]: """ Compute the edges of the velocity-obstacle cone towards one obstacle. The cone spans the directions from ``(x, y)`` that touch the obstacle disc at ``(mx, my)`` inflated by the combined radius. Obstacles closer than that combined radius are treated as tangent, giving a half cone of 90 degrees instead of an undefined one. Args: x (float): Ego x position. y (float): Ego y position. r (float): Ego radius. mx (float): Obstacle x position. my (float): Obstacle y position. mr (float): Obstacle radius. Returns: tuple: ``(line_left_ori, line_right_ori, half_angle)`` in radians. """ dis_mr = np.sqrt((my - y) ** 2 + (mx - x) ** 2) angle_mr = atan2(my - y, mx - x) if dis_mr < r + mr: dis_mr = r + mr ratio = min(max((r + mr) / dis_mr, -1.0), 1.0) half_angle = asin(ratio) return angle_mr + half_angle, angle_mr - half_angle, half_angle @staticmethod def _neighbour_state(obstacle): """Unpack a neighbour as ``(x, y, vx, vy, r)``. Accepts the moving form ``[x, y, vx, vy, r]`` and the static form ``[x, y, r]``, which is a neighbour with zero velocity. The layout is told from the length of ``obstacle``; the ego's own velocity plays no part in it. """ if len(obstacle) == 3: x, y, r = obstacle return x, y, 0.0, 0.0, r return obstacle[0], obstacle[1], obstacle[2], obstacle[3], obstacle[4]
[docs] def config_rvo(self): """Build reciprocal velocity-obstacle cones for all obstacles.""" rvo_list = [] for obstacle in self.obs_state_list: rvo = self.config_rvo_mode(obstacle) rvo_list.append(rvo) rvo_list.extend(self.config_vo_lines()) return rvo_list
[docs] def config_rvo_mode(self, obstacle): """Build one RVO cone for a circular obstacle. Args: obstacle: Neighbour state ``[x, y, vx, vy, radius]``, or the static form ``[x, y, radius]`` (zero velocity). Returns: list: ``[apex, left_vector, right_vector]`` cone description. """ x = self.state[0] y = self.state[1] vx = self.state[2] vy = self.state[3] r = self.state[4] # A static neighbour is one with zero velocity; the reciprocal apex # handles it without a special case. mx, my, mvx, mvy, mr = self._neighbour_state(obstacle) rvo_apex = [(vx + mvx) / 2, (vy + mvy) / 2] line_left_ori, line_right_ori, _ = self._cone_angles(x, y, r, mx, my, mr) line_left_vector = [cos(line_left_ori), sin(line_left_ori)] line_right_vector = [cos(line_right_ori), sin(line_right_ori)] return [rvo_apex, line_left_vector, line_right_vector]
[docs] def config_hrvo(self): """Build hybrid reciprocal velocity-obstacle cones for all obstacles.""" hrvo_list = [] for obstacle in self.obs_state_list: # for circular: [x, y, radius] hrvo = self.config_hrvo_mode(obstacle) hrvo_list.append(hrvo) hrvo_list.extend(self.config_vo_lines()) return hrvo_list
[docs] def config_hrvo_mode(self, obstacle): """Build one HRVO cone for a circular obstacle. Args: obstacle: Neighbour state ``[x, y, vx, vy, radius]``, or the static form ``[x, y, radius]`` (zero velocity). Returns: list: ``[apex, left_vector, right_vector]`` cone description. """ x = self.state[0] y = self.state[1] vx = self.state[2] vy = self.state[3] r = self.state[4] mx, my, mvx, mvy, mr = self._neighbour_state(obstacle) rvo_apex = [(vx + mvx) / 2, (vy + mvy) / 2] vo_apex = [mvx, mvy] line_left_ori, line_right_ori, half_angle = self._cone_angles( x, y, r, mx, my, mr ) line_left_vector = [cos(line_left_ori), sin(line_left_ori)] line_right_vector = [cos(line_right_ori), sin(line_right_ori)] cl_vector = [mx - x, my - y] cur_v = [vx - rvo_apex[0], vy - rvo_apex[1]] dis_rv = dist_hypot(rvo_apex[0], rvo_apex[1], vo_apex[0], vo_apex[1]) radians_rv = atan2(rvo_apex[1] - vo_apex[1], rvo_apex[0] - vo_apex[0]) diff = line_left_ori - radians_rv temp = pi - 2 * half_angle if temp == 0: temp = temp + 0.01 dis_diff = dis_rv * sin(diff) / sin(temp) if reciprocal_vel_obs.cross_product(cl_vector, cur_v) <= 0: hrvo_apex = [ rvo_apex[0] - dis_diff * cos(line_right_ori), rvo_apex[1] - dis_diff * sin(line_right_ori), ] else: hrvo_apex = [ vo_apex[0] + dis_diff * cos(line_right_ori), vo_apex[1] + dis_diff * sin(line_right_ori), ] return [hrvo_apex, line_left_vector, line_right_vector]
[docs] def config_vo(self): """Build standard velocity-obstacle cones for all obstacles.""" vo_list = [] for obstacle in self.obs_state_list: vo = self.config_vo_mode(obstacle) vo_list.append(vo) vo_list.extend(self.config_vo_lines()) return vo_list
[docs] def config_vo_mode(self, obstacle): """Build one VO cone for a circular obstacle. Args: obstacle: Neighbour state ``[x, y, vx, vy, radius]``, or the static form ``[x, y, radius]`` (zero velocity). Returns: list: ``[apex, left_vector, right_vector]`` cone description. """ x = self.state[0] y = self.state[1] r = self.state[4] mx, my, mvx, mvy, mr = self._neighbour_state(obstacle) vo_apex = [mvx, mvy] line_left_ori, line_right_ori, _ = self._cone_angles(x, y, r, mx, my, mr) line_left_vector = [cos(line_left_ori), sin(line_left_ori)] line_right_vector = [cos(line_right_ori), sin(line_right_ori)] return [vo_apex, line_left_vector, line_right_vector]
[docs] def config_vo_lines(self): """Compute VO cones for line segment obstacles. For each segment, compute the angular span as seen from the agent, expanded by asin(r / dist) on each side to account for the agent radius. The apex is [0, 0] since line obstacles are static. """ vo_list = [] x = self.state[0] y = self.state[1] r = self.state[4] for seg in self.line_obs_list: x1, y1, x2, y2 = seg # Closest point on segment to agent dx_seg = x2 - x1 dy_seg = y2 - y1 seg_len_sq = dx_seg * dx_seg + dy_seg * dy_seg if seg_len_sq < 1e-12: continue t = ((x - x1) * dx_seg + (y - y1) * dy_seg) / seg_len_sq t = max(0.0, min(1.0, t)) cx = x1 + t * dx_seg cy = y1 + t * dy_seg dist_closest = sqrt((x - cx) ** 2 + (y - cy) ** 2) if dist_closest < 1e-6: dist_closest = 1e-6 # Angles from agent to each endpoint angle1 = atan2(y1 - y, x1 - x) angle2 = atan2(y2 - y, x2 - x) # Expand by agent radius at each endpoint dist1 = sqrt((x1 - x) ** 2 + (y1 - y) ** 2) dist2 = sqrt((x2 - x) ** 2 + (y2 - y) ** 2) expand1 = asin(min(1.0, r / max(dist1, r))) if dist1 > 1e-6 else pi / 2 expand2 = asin(min(1.0, r / max(dist2, r))) if dist2 > 1e-6 else pi / 2 # Determine which endpoint is "left" and which is "right" # by checking the angular difference diff = atan2(sin(angle2 - angle1), cos(angle2 - angle1)) if diff >= 0: # angle2 is to the left of angle1 left_angle = angle2 + expand2 right_angle = angle1 - expand1 else: # angle1 is to the left of angle2 left_angle = angle1 + expand1 right_angle = angle2 - expand2 line_left_vector = [cos(left_angle), sin(left_angle)] line_right_vector = [cos(right_angle), sin(right_angle)] vo_list.append([[0, 0], line_left_vector, line_right_vector]) return vo_list
[docs] def vel_candidate(self, rvo_list): """Sample reachable velocities and split them by VO feasibility. Args: rvo_list: Velocity-obstacle cone descriptions ``[apex, left, right]``. Returns: tuple[list, list]: Feasible velocities outside all cones and infeasible velocities inside at least one cone. """ velocities, outside = self._candidate_velocities(rvo_list) return velocities[outside].tolist(), velocities[~outside].tolist()
def _axis_candidates(self, current, vmax): """Velocities reachable along one axis within ``acce`` of ``current``. The window is clipped to ``[-vmax, vmax]``. When the current velocity lies more than ``acce`` outside that range (an initial or externally set velocity above the limit), the window would be empty and the selection would fail, so the nearest admissible velocity is the one candidate instead. """ low = max(current - self.acce, -vmax) high = min(current + self.acce, vmax) if high <= low: return np.array([float(np.clip(current, -vmax, vmax))]) return np.arange(low, high, 0.05) def _candidate_velocities(self, rvo_list): """Reachable velocity grid and a mask of those outside every cone. The grid is evaluated against all cones at once with numpy, ordered as the nested ``vx``/``vy`` loops would produce it so that :meth:`vel_select` picks the same velocity. """ vxs = self._axis_candidates(self.state[2], self.vxmax) vys = self._axis_candidates(self.state[3], self.vymax) grid_x, grid_y = np.meshgrid(vxs, vys, indexing="ij") vx, vy = grid_x.ravel(), grid_y.ravel() outside = np.ones(vx.size, dtype=bool) for apex, left, right, *_ in rvo_list: rel_x, rel_y = vx - apex[0], vy - apex[1] inside = (left[0] * rel_y - rel_x * left[1] <= 0) & ( right[0] * rel_y - rel_x * right[1] >= 0 ) outside &= ~inside return np.column_stack((vx, vy)), outside
[docs] def vo_out(self, vx, vy, rvo_list): """Return whether a candidate velocity lies outside every VO cone.""" for rvo in rvo_list: rel_vx = vx - rvo[0][0] rel_vy = vy - rvo[0][1] rel_vector = [rel_vx, rel_vy] if reciprocal_vel_obs.between_vector(rvo[1], rvo[2], rel_vector): return False return True
[docs] def vel_select(self, vo_outside, vo_inside): """Select the best velocity from feasible candidates or penalized fallback. Both arguments may be lists of ``[vx, vy]`` or ``(N, 2)`` arrays; the first minimum is returned, as ``min`` would. """ vel_des = [self.state[5], self.state[6]] if len(vo_outside) != 0: velocities = np.asarray(vo_outside) distances = np.hypot( velocities[:, 0] - vel_des[0], velocities[:, 1] - vel_des[1] ) return list(velocities[int(np.argmin(distances))]) velocities = np.asarray(vo_inside) costs = self.penalties(velocities, vel_des, self.factor) return list(velocities[int(np.argmin(costs))])
[docs] def penalties(self, vels, vel_des, factor): """Vectorized :meth:`penalty` for an ``(N, 2)`` array of velocities. Same formulas and branches as the scalar version, evaluated for all candidates at once; the crowded-scene fallback otherwise calls ``penalty`` hundreds of times per robot per step. """ x, y, vx, vy, r = self.state[:5] tc_columns = [] for moving in self.obs_state_list: distance = dist_hypot(moving[0], moving[1], x, y) diff = max(distance**2 - (r + moving[4]) ** 2, 0) dis_vel = np.sqrt(diff) trans_x = 2 * vels[:, 0] - vx - moving[2] trans_y = 2 * vels[:, 1] - vy - moving[3] tc_columns.append(dis_vel / (np.sqrt(trans_x**2 + trans_y**2) + 1e-7)) for seg in self.line_obs_list: tc_columns.append(self._tc_line_segment_all(x, y, r, vels, seg)) dist_des = np.hypot(vels[:, 0] - vel_des[0], vels[:, 1] - vel_des[1]) if not tc_columns: return dist_des tc_min = np.min(tc_columns, axis=0) tc_min = np.where(tc_min == 0, 0.0001, tc_min) return factor * (1 / tc_min) + dist_des
[docs] def penalty(self, vel, vel_des, factor): """Scalar :meth:`penalties` for one velocity, kept for compatibility.""" return float(self.penalties(np.array([vel], dtype=float), vel_des, factor)[0])
@staticmethod def _tc_line_segment_all(x, y, r, vels, seg): """Vectorized :meth:`_tc_line_segment` for an ``(N, 2)`` array of velocities.""" x1, y1, x2, y2 = seg dx_seg, dy_seg = x2 - x1, y2 - y1 seg_len = sqrt(dx_seg * dx_seg + dy_seg * dy_seg) if seg_len < 1e-12: return np.full(len(vels), 1e6) nx, ny = -dy_seg / seg_len, dx_seg / seg_len if nx * (x - x1) + ny * (y - y1) < 0: nx, ny = -nx, -ny perp_dist = nx * (x - x1) + ny * (y - y1) vel_toward = -(nx * vels[:, 0] + ny * vels[:, 1]) approaching = vel_toward > 1e-7 tc = np.maximum((perp_dist - r) / np.where(approaching, vel_toward, 1.0), 0) col_x = x + vels[:, 0] * tc col_y = y + vels[:, 1] * tc t_proj = ((col_x - x1) * dx_seg + (col_y - y1) * dy_seg) / (seg_len * seg_len) margin = r / seg_len hits = approaching & (t_proj >= -margin) & (t_proj <= 1 + margin) return np.where(hits, tc, 1e6)
[docs] @staticmethod def between_vector(line_left_vector, line_right_vector, line_vector): """Return whether ``line_vector`` lies inside a cone boundary pair.""" return bool( reciprocal_vel_obs.cross_product(line_left_vector, line_vector) <= 0 and reciprocal_vel_obs.cross_product(line_right_vector, line_vector) >= 0 )
[docs] @staticmethod def cross_product(vector1, vector2): """Compute the 2D cross product ``vector1 x vector2``.""" return float(vector1[0] * vector2[1] - vector2[0] * vector1[1])