Files
rotor_py_control/rotorpy/world.py
spencerfolk 4d7fca10e4 Init
2023-03-15 15:38:14 -04:00

306 lines
11 KiB
Python

import json
import numpy as np
from rotorpy.utils.shapes import Cuboid
from rotorpy.utils.numpy_encoding import NumpyJSONEncoder, to_ndarray
def interp_path(path, res):
if path.size == 3:
# There's only one datapoint. Return the point.
return path.reshape(1,-1)
else:
cumdist = np.cumsum(np.linalg.norm(np.diff(path, axis=0),axis=1))
if cumdist[-1] > 0:
t = np.insert(cumdist,0,0)
ts = np.arange(0, cumdist[-1], res)
pts = np.empty((ts.size, 3), dtype=np.float64)
for k in range(3):
pts[:,k] = np.interp(ts, t, path[:,k])
else:
pts = path[[0],:]
return pts
class World(object):
def __init__(self, world_data):
"""
Construct World object from data. Instead of using this constructor
directly, see also class methods 'World.from_file()' for building a
world from a saved .json file or 'World.grid_forest()' for building a
world object of a parameterized style.
Parameters:
world_data, dict containing keys 'bounds' and 'blocks'
bounds, dict containing key 'extents'
extents, list of [xmin, xmax, ymin, ymax, zmin, zmax]
blocks, list of dicts containing keys 'extents' and 'color'
extents, list of [xmin, xmax, ymin, ymax, zmin, zmax]
color, color specification
"""
self.world = world_data
@classmethod
def from_file(cls, filename):
"""
Read world definition from a .json text file and return World object.
Parameters:
filename
Returns:
world, World object
Example use:
my_world = World.from_file('my_filename.json')
"""
with open(filename) as file:
return cls(to_ndarray(json.load(file)))
def to_file(self, filename):
"""
Write world definition to a .json text file.
Parameters:
filename
Example use:
my_word.to_file('my_filename.json')
"""
with open(filename, 'w') as file: # TODO check for directory to exist
file.write(json.dumps(self.world, cls=NumpyJSONEncoder, indent=4))
def closest_points(self, points):
"""
For each point, return the closest occupied point in the world and the
distance to that point. This is appropriate for computing sphere-vs-world
collisions.
Input
points, (N,3)
Returns
closest_points, (N,3)
closest_distances, (N,)
"""
closest_points = np.empty_like(points)
closest_distances = np.full(points.shape[0], np.inf)
p = np.empty_like(points)
for block in self.world.get('blocks', []):
# Computation takes advantage of axes-aligned blocks. Note that
# scipy.spatial.Rectangle can compute this distance, but wouldn't
# return the point itself.
r = block['extents']
for i in range(3):
p[:, i] = np.clip(points[:, i], r[2*i], r[2*i+1])
d = np.linalg.norm(points-p, axis=1)
mask = d < closest_distances
closest_points[mask, :] = p[mask, :]
closest_distances[mask] = d[mask]
return (closest_points, closest_distances)
def min_dist_boundary(self, points):
"""
For each point, calculate the minimum distance to the boundary checking, x,y,z. A negative distance means the
point is outside the boundary
Input
points, (N,3)
Returns
closest_distances, (N,)
"""
# Bounds with upper limits negated [xmin, -xmax, ymin, -ymax, ...]
test_bounds = np.array(self.world['bounds']['extents'])
test_bounds[1::2] = -test_bounds[1::2]
# Repeated coordinates with second entry negated [x, -x, y, -y, ...]
test_points = np.repeat(points, 2, 1)
test_points[:,1::2] = -test_points[:,::2]
# Compute [x-xmin, xmax-x, y-ymin, ymax-y, z-zmin, zmax-z].
# Minimum distance is the minimum for each point to all walls.
distances = test_points - test_bounds
min_distances = np.amin(distances, 1)
return min_distances
def path_collisions(self, path, margin):
"""
Densely sample the path and check for collisions. Return a boolean mask
over the samples and the sample points themselves.
"""
pts = interp_path(path, res=0.001)
(closest_pts, closest_dist) = self.closest_points(pts)
collisions_blocks = closest_dist < margin
collisions_points = self.min_dist_boundary(pts) < 0
collisions = np.logical_or(collisions_points, collisions_blocks)
return pts[collisions]
def draw_empty_world(self, ax):
"""
Draw just the world without any obstacles yet. The boundary is represented with a black line.
Parameters:
ax, Axes3D object
"""
(xmin, xmax, ymin, ymax, zmin, zmax) = self.world['bounds']['extents']
# Set axes limits all equal to approximate 'axis equal' display.
x_width = xmax-xmin
y_width = ymax-ymin
z_width = zmax-zmin
width = np.max((x_width, y_width, z_width))
ax.set_xlim((xmin, xmin+width))
ax.set_ylim((ymin, ymin+width))
ax.set_zlim((zmin, zmin+width))
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
c = Cuboid(ax, xmax - xmin, ymax - ymin, zmax - zmin, alpha=0.01, linewidth=1, edgecolors='k')
c.transform(position=(xmin, ymin, zmin))
return list(c.artists)
def draw(self, ax):
"""
Draw world onto existing Axes3D axes and return artists corresponding to the
blocks.
Parameters:
ax, Axes3D object
Returns:
block_artists, list of Artists associated with blocks
Example use:
my_world.draw(ax)
"""
bounds_artists = self.draw_empty_world(ax)
block_artists = []
for b in self.world.get('blocks', []):
(xmin, xmax, ymin, ymax, zmin, zmax) = b['extents']
c = Cuboid(ax, xmax-xmin, ymax-ymin, zmax-zmin, alpha=0.6, linewidth=1, edgecolors='k', facecolors=b.get('color', None))
c.transform(position=(xmin, ymin, zmin))
block_artists.extend(c.artists)
return bounds_artists + block_artists
def draw_line(self, ax, points, color=None, linewidth=2):
path_length = np.sum(np.linalg.norm(np.diff(points, axis=0),axis=1))
pts = interp_path(points, res=path_length/1000)
# The scatter object is assigned a single z-order value. Split for better occlusion rendering.
for p in np.array_split(pts, 20):
ax.scatter(p[:,0], p[:,1], p[:,2], s=linewidth**2, c=color, edgecolors='none', depthshade=False)
def draw_points(self, ax, points, color=None, markersize=4):
# The scatter object is assigned a single z-order value. Split for better occlusion rendering.
for p in np.array_split(points, 20):
ax.scatter(p[:,0], p[:,1], p[:,2], s=markersize**2, c=color, edgecolors='none', depthshade=False)
# The follow class methods are convenience functions for building different
# kinds of parametric worlds.
@classmethod
def empty(cls, extents):
"""
Return World object for bounded empty space.
Parameters:
extents, tuple of (xmin, xmax, ymin, ymax, zmin, zmax)
Returns:
world, World object
Example use:
my_world = World.empty((xmin, xmax, ymin, ymax, zmin, zmax))
"""
bounds = {'extents': extents}
blocks = []
world_data = {'bounds': bounds, 'blocks': blocks}
return cls(world_data)
@classmethod
def grid_forest(cls, n_rows, n_cols, width, height, spacing):
"""
Return World object describing a grid forest world parameterized by
arguments. The boundary extents fit tightly to the included trees.
Parameters:
n_rows, rows of trees stacked in the y-direction
n_cols, columns of trees stacked in the x-direction
width, weight of square cross section trees
height, height of trees
spacing, spacing between centers of rows and columns
Returns:
world, World object
Example use:
my_world = World.grid_forest(n_rows=4, n_cols=3, width=0.5, height=3.0, spacing=2.0)
"""
# Bounds are outer boundary for world, which are an implicit obstacle.
x_max = (n_cols-1)*spacing + width
y_max = (n_rows-1)*spacing + width
bounds = {'extents': [0, x_max, 0, y_max, 0, height]}
# Blocks are obstacles in the environment.
x_root = spacing * np.arange(n_cols)
y_root = spacing * np.arange(n_rows)
blocks = []
for x in x_root:
for y in y_root:
blocks.append({'extents': [x, x+width, y, y+width, 0, height], 'color': [1, 0, 0]})
world_data = {'bounds': bounds, 'blocks': blocks}
return cls(world_data)
@classmethod
def random_forest(cls, world_dims, tree_width, tree_height, num_trees):
"""
Return World object describing a random forest world parameterized by
arguments.
Parameters:
world_dims, a tuple of (xmax, ymax, zmax). xmin,ymin, and zmin are set to 0.
tree_width, weight of square cross section trees
tree_height, height of trees
num_trees, number of trees
Returns:
world, World object
"""
# Bounds are outer boundary for world, which are an implicit obstacle.
bounds = {'extents': [0, world_dims[0], 0, world_dims[1], 0, world_dims[2]]}
# Blocks are obstacles in the environment.
xs = np.random.uniform(0, world_dims[0], num_trees)
ys = np.random.uniform(0, world_dims[1], num_trees)
pts = np.stack((xs, ys), axis=-1) # min corner location of trees
w, h = tree_width, tree_height
blocks = []
for pt in pts:
extents = list(np.round([pt[0], pt[0]+w, pt[1], pt[1]+w, 0, h], 2))
blocks.append({'extents': extents, 'color': [1, 0, 0]})
world_data = {'bounds': bounds, 'blocks': blocks}
return cls(world_data)
if __name__ == '__main__':
import argparse
from pathlib import Path
import matplotlib.pyplot as plt
from rotorpy.axes3ds import Axes3Ds
parser = argparse.ArgumentParser(description='Display a map file in a Matplotlib window.')
parser.add_argument('filename', help="Filename for map file json.")
p = parser.parse_args()
file = Path(p.filename)
world = World.from_file(file)
fig = plt.figure(f"{file.name}")
ax = Axes3Ds(fig)
world.draw(ax)
plt.show()