Added fast 2D range sensor for navigation/estimation.
This commit is contained in:
265
rotorpy/sensors/range_sensors.py
Normal file
265
rotorpy/sensors/range_sensors.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
import copy
|
||||
|
||||
from rotorpy.utils.occupancy_map import OccupancyMap
|
||||
|
||||
def edges_from_extents_2d(extents):
|
||||
"""
|
||||
Return the 4 edges of the rectangle given by the extents.
|
||||
Inputs:
|
||||
extents: the array (xmin, xmax, ymin, ymax) that describe the rectangle of interest.
|
||||
Outputs:
|
||||
edges: a list of vertices [[(a0.x, a0.y), (b0.x, b0.y)]
|
||||
[(a1.x, a1.y), (b1.x, b1.y)]
|
||||
.....
|
||||
]
|
||||
that describe the edges of the rectangle give by extents.
|
||||
|
||||
(xmin,ymax)--------(2)---------(xmax,ymax)
|
||||
| |
|
||||
| (1)
|
||||
(3) |
|
||||
| |
|
||||
(xmin,ymin)--------(0)---------(xmax,ymin)
|
||||
|
||||
|
||||
"""
|
||||
xmin = extents[0]
|
||||
xmax = extents[1]
|
||||
ymin = extents[2]
|
||||
ymax = extents[3]
|
||||
|
||||
edges = [(xmin, ymin, xmax, ymin),
|
||||
(xmax, ymin, xmax, ymax),
|
||||
(xmax, ymax, xmin, ymax),
|
||||
(xmin, ymax, xmin, ymin)]
|
||||
|
||||
return edges
|
||||
|
||||
class TwoDRangeSensor():
|
||||
"""
|
||||
The 2D Range sensor will provide distance measurements around the UAV on the XY plane (top-down).
|
||||
|
||||
The sensor is placed at the CoM of the UAV, and casts rays from the CoM outward toward objects. The
|
||||
distance between objects and the UAV, as measured by the i'th ray, is given in the i'th row of the
|
||||
sensor output. The distance measurement is clipped by Dmin and Dmax, bounds that are specified by
|
||||
the user (with defaults of course). The distance measurement can also be optionally corrupted by
|
||||
white noise using the specified noise density term. The angular resolution specified will determine
|
||||
the number of rays casted.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, world, sampling_rate,
|
||||
map_resolution = 1, # The resolution of the occupancy grid used for generating measurements, in meters.
|
||||
angular_resolution=10, # The angular resolution, measured in degrees.
|
||||
angular_fov=360, # Angular field of view, this determines the width of the region with which rays are cast, relative to the sensor heading, degrees.
|
||||
Dmin=0.025, # The minimum distance measurable by the range sensor, m.
|
||||
Dmax=100, # The maximum distance measurable by the range sensor, m.
|
||||
noise_density=0.0, # The noise density of the white noise added to the range sensor, [m / sqrt(Hz)].
|
||||
fixed_heading=True, # If true, the heading of the sensor will be world-fixed; if false, the rays will be cast relative to the robot's heading
|
||||
):
|
||||
|
||||
|
||||
self.world = world
|
||||
self.occ_map = OccupancyMap(world=self.world, resolution=(map_resolution, map_resolution, map_resolution), margin=0)
|
||||
|
||||
self.rate_scale = np.sqrt(sampling_rate/2)
|
||||
self.angular_resolution = angular_resolution
|
||||
self.angular_fov = angular_fov
|
||||
self.Dmin = Dmin
|
||||
self.Dmax = Dmax
|
||||
self.sensor_variance = noise_density
|
||||
self.fixed_heading = fixed_heading
|
||||
|
||||
# First, construct an array of ray directions that we will iterate over.
|
||||
self.ray_angles = np.arange(-self.angular_fov/2, self.angular_fov/2+self.angular_resolution, step=self.angular_resolution) # Centered at 0
|
||||
self.N_rays = len(self.ray_angles) # save the number of rays for later looping
|
||||
self.set_ray_vectors(heading=0)
|
||||
|
||||
# Next save relevant quantities for each physical block in the map
|
||||
self.world_edges = []
|
||||
world_slopes = []
|
||||
for block in self.world.world['blocks']:
|
||||
extents = block['extents'] # Get the extents of the block
|
||||
edges = edges_from_extents_2d(extents) # Extract the four edges of each block
|
||||
self.world_edges.append(edges)
|
||||
|
||||
return
|
||||
|
||||
def set_ray_vectors(self, heading=0):
|
||||
"""
|
||||
Set the ray vectors array based on the current heading.
|
||||
Inputs:
|
||||
heading: the current heading angle in degrees.
|
||||
"""
|
||||
|
||||
rotated_ray_angles = self.ray_angles + heading
|
||||
|
||||
x_rays = np.cos(rotated_ray_angles*np.pi/180).reshape(-1,1)
|
||||
y_rays = np.sin(rotated_ray_angles*np.pi/180).reshape(-1,1)
|
||||
|
||||
self.ray_vectors = np.hstack((x_rays, y_rays))
|
||||
|
||||
return
|
||||
|
||||
def cast_rays(self, x, y):
|
||||
"""
|
||||
Cast rays from (x,y) and return the distances of the point (x,y) to the nearest obstacles in the map.
|
||||
Raycasting is implemented using the algorithm described here: https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection
|
||||
|
||||
Inputs:
|
||||
x: the x position of the point of interest
|
||||
y: the y position of the point of interest
|
||||
Outputs:
|
||||
d: an array of distances that is of shape (self.N_rays,)
|
||||
"""
|
||||
d = np.zeros((self.N_rays,))
|
||||
|
||||
# For ray casting we'll have to use (x,y) as one of our points for all rays. Do this now to be more efficient
|
||||
x3 = x
|
||||
y3 = y
|
||||
|
||||
# Loop over each object
|
||||
distances = np.ones((self.N_rays,))*1e5 # Begin with
|
||||
for object_edges in self.world_edges:
|
||||
# Object edges is a list of tuples, each tuple describing one of the four edges
|
||||
for edge in object_edges:
|
||||
# First collect the coordinates for point 1 and 2 corresponding to this edge
|
||||
x1 = edge[0]
|
||||
y1 = edge[1]
|
||||
x2 = edge[2]
|
||||
y2 = edge[3]
|
||||
|
||||
# Using self.ray_vectors and the current position (x,y), create the ray's other point (x4,y4)
|
||||
# The rays are of length Dmax.
|
||||
x4 = x + self.Dmax*self.ray_vectors[:,0] # This gives us an array of x coordinates for each ray
|
||||
y4 = y + self.Dmax*self.ray_vectors[:,1] # This gives us an array of y coordinates for each ray
|
||||
|
||||
# Compute the denominator of the t-u test, which will be used to check for intersections
|
||||
den = (x1 - x2)*(y3 - y4) - (y1 - y2)*(x3 - x4)
|
||||
|
||||
# Assign nan to any instances where the denominator equals 0, because this indicates that the ray and boundary are parallel
|
||||
den[den == 0] = np.nan
|
||||
|
||||
# Compute t
|
||||
t = ((x1 - x3)*(y3 - y4) - (y1 - y3)*(x3 - x4))/den
|
||||
|
||||
# Compute u
|
||||
u = ((x1 - x3)*(y1 - y2) - (y1 - y3)*(x1 - x2))/den
|
||||
|
||||
# Now do the check to see if these lines intersect and compute the intersection point
|
||||
intersected = (t > 0)*(t < 1)*(u > 0)*1.
|
||||
intersected[intersected==0] = np.nan
|
||||
|
||||
# Compute the distance away from the point using u and (x,y)
|
||||
dx = u*(x4-x3)
|
||||
dy = u*(y4-y3)
|
||||
|
||||
# Now that we have (x_int, y_int), compute the distance away from (x,y) for those that are intersected
|
||||
object_distances = np.sqrt(dx**2 + dy**2)*intersected
|
||||
|
||||
# If any of these distances are less than the current distance saved in distances, rewrite that measurement
|
||||
distances[object_distances < distances] = object_distances[object_distances < distances]
|
||||
|
||||
return distances
|
||||
|
||||
def measurement(self, state, with_noise=True):
|
||||
"""
|
||||
Computes and returns the measurement at the current time step.
|
||||
Inputs:
|
||||
state, a dict describing the state with keys
|
||||
x, position, m, shape=(3,)
|
||||
v, linear velocity, m/s, shape=(3,)
|
||||
q, quaternion [i,j,k,w], shape=(4,)
|
||||
w, angular velocity (in LOCAL frame!), rad/s, shape=(3,)
|
||||
with_noise, a boolean to indicate if noise is added
|
||||
Outputs:
|
||||
ranges, an array that is
|
||||
"""
|
||||
|
||||
pos = state['x']
|
||||
|
||||
# First update the ray angles and recompute the ray vectors if the heading should follow the robot
|
||||
if not self.fixed_heading:
|
||||
orientation = Rotation.from_quat(state['q']).as_euler('zyx', degrees=True)
|
||||
heading = orientation[0] # Extract the yaw angle in degrees of the robot.
|
||||
self.set_ray_vectors(heading=heading) # This sets self.ray_vectors appropriately
|
||||
|
||||
# Cast the rays and compute the ranges for each ray
|
||||
self.ranges = np.clip(self.cast_rays(pos[0], pos[1]), self.Dmin, self.Dmax)
|
||||
if with_noise:
|
||||
self.ranges += self.rate_scale * np.random.normal(scale=np.abs(self.sensor_variance), size=self.N_rays)
|
||||
|
||||
return self.ranges
|
||||
|
||||
if __name__=="__main__":
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from rotorpy.utils.generate_maps import plot_map
|
||||
import matplotlib.colors as mcolors
|
||||
from matplotlib.patches import Rectangle
|
||||
import os
|
||||
|
||||
fig_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data_out')
|
||||
|
||||
random_color = np.random.uniform(low=0, high=1, size=(100, 3))
|
||||
|
||||
def plot_range(axes, world, pos, ranges, range_sensor, detected_edges=None):
|
||||
axes[0].plot(pos[0], pos[1], 'ro', zorder=10, label="Robot Position")
|
||||
if detected_edges is None:
|
||||
axes[0].plot((pos[0:2] + ranges[:,np.newaxis]*range_sensor.ray_vectors)[:,0], (pos[0:2] + ranges[:,np.newaxis]*range_sensor.ray_vectors)[:,1], 'ko', markersize=3, label="Ray Intersection Points")
|
||||
else:
|
||||
for (i,edge) in enumerate(detected_edges):
|
||||
axes[0].plot((pos[0:2] + ranges[edge,np.newaxis]*range_sensor.ray_vectors[edge])[:,0], (pos[0:2] + ranges[edge,np.newaxis]*range_sensor.ray_vectors[edge])[:,1], color=random_color[i], marker='o', markersize=3, linestyle='none', label="Ray Intersection Points")
|
||||
line_objects = []
|
||||
for i in range(range_sensor.N_rays):
|
||||
xvals = [pos[0], (pos[0:2] + ranges[:,np.newaxis]*range_sensor.ray_vectors)[i,0]]
|
||||
yvals = [pos[1], (pos[0:2] + ranges[:,np.newaxis]*range_sensor.ray_vectors)[i,1]]
|
||||
line = axes[0].plot(xvals, yvals, 'k-', linewidth=0.5, alpha=0.5)
|
||||
|
||||
if detected_edges is None:
|
||||
axes[1].plot(range_sensor.ray_angles, range_sensor.ranges, 'r.', linewidth=1.5)
|
||||
else:
|
||||
axes[1].plot(range_sensor.ray_angles, range_sensor.ranges, 'k.', linewidth=1.5, alpha=0.1)
|
||||
for (i,edge) in enumerate(detected_edges):
|
||||
axes[1].plot(range_sensor.ray_angles[edge], range_sensor.ranges[edge], color=random_color[i], linestyle='none', marker='.')
|
||||
axes[1].plot([-180, 180], [range_sensor.Dmin, range_sensor.Dmin], 'k--', linewidth=1)
|
||||
axes[1].plot([-180, 180], [range_sensor.Dmax, range_sensor.Dmax], 'k--', linewidth=1)
|
||||
|
||||
return
|
||||
|
||||
# Choose a map
|
||||
# Load the map from the world directory
|
||||
from rotorpy.world import World
|
||||
|
||||
map_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'worlds')
|
||||
maps = [m for m in os.listdir(map_dir) if '.json' in m]
|
||||
world = World.grid_forest(n_rows=10, n_cols=10, width=1, height=10, spacing=5)
|
||||
|
||||
# Sensor intrinsics
|
||||
angular_fov = 360
|
||||
angular_resolution = 1
|
||||
fixed_heading = True
|
||||
noise_density = 0.005
|
||||
|
||||
# Create sensor
|
||||
range_sensor = TwoDRangeSensor(world, sampling_rate=100, angular_fov=angular_fov, angular_resolution=angular_resolution, fixed_heading=fixed_heading, noise_density=noise_density)
|
||||
|
||||
(xmin, xmax, ymin, ymax, zmin, zmax) = world.world['bounds']['extents']
|
||||
|
||||
pos0 = np.array([0.5*(xmax+xmin), 0.5*(ymax+ymin), 0.5*(zmax+zmin)])
|
||||
state = {'x': pos0, 'q': np.array([0, 0, 0.3826834, 0.9238795])}
|
||||
ranges = range_sensor.measurement(state)
|
||||
|
||||
# Plotting
|
||||
(fig, axes) = plt.subplots(nrows=1, ncols=2, num="Ray Intersections Test")
|
||||
|
||||
axes[1].set_xlabel("Ray Angle (deg)")
|
||||
axes[1].set_ylabel("Range (m)")
|
||||
axes[1].set_xlim([-190, 190])
|
||||
|
||||
plot_map(axes[0], world.world)
|
||||
plot_range(axes, world, pos0, ranges, range_sensor)
|
||||
|
||||
plt.show()
|
||||
Reference in New Issue
Block a user