safety_gym/envs/world.py (302 lines of code) (raw):

#!/usr/bin/env python import os import xmltodict import numpy as np from copy import deepcopy from collections import OrderedDict from mujoco_py import const, load_model_from_path, load_model_from_xml, MjSim, MjViewer, MjRenderContextOffscreen import safety_gym import sys ''' Tools that allow the Safety Gym Engine to interface to MuJoCo. The World class owns the underlying mujoco scene and the XML, and is responsible for regenerating the simulator. The way to use this is to configure a World() based on your needs (number of objects, etc) and then call `world.reset()`. *NOTE:* The simulator should be accessed as `world.sim` and not just saved separately, because it may change between resets. Configuration is idiomatically done through Engine configuration, so any changes to this configuration should also be reflected in changes to the Engine. TODO: - unit test scaffold ''' # Default location to look for /xmls folder: BASE_DIR = os.path.dirname(safety_gym.__file__) def convert(v): ''' Convert a value into a string for mujoco XML ''' if isinstance(v, (int, float, str)): return str(v) # Numpy arrays and lists return ' '.join(str(i) for i in np.asarray(v)) def rot2quat(theta): ''' Get a quaternion rotated only about the Z axis ''' return np.array([np.cos(theta / 2), 0, 0, np.sin(theta / 2)], dtype='float64') class World: # Default configuration (this should not be nested since it gets copied) # *NOTE:* Changes to this configuration should also be reflected in `Engine` configuration DEFAULT = { 'robot_base': 'xmls/car.xml', # Which robot XML to use as the base 'robot_xy': np.zeros(2), # Robot XY location 'robot_rot': 0, # Robot rotation about Z axis 'floor_size': [3.5, 3.5, .1], # Used for displaying the floor # Objects -- this is processed and added by the Engine class 'objects': {}, # map from name -> object dict # Geoms -- similar to objects, but they are immovable and fixed in the scene. 'geoms': {}, # map from name -> geom dict # Mocaps -- mocap objects which are used to control other objects 'mocaps': {}, # Determine whether we create render contexts 'observe_vision': False, } def __init__(self, config={}, render_context=None): ''' config - JSON string or dict of configuration. See self.parse() ''' self.parse(config) # Parse configuration self.first_reset = True self.viewer = None self.render_context = render_context self.update_viewer_sim = False self.robot = Robot(self.robot_base) def parse(self, config): ''' Parse a config dict - see self.DEFAULT for description ''' self.config = deepcopy(self.DEFAULT) self.config.update(deepcopy(config)) for key, value in self.config.items(): assert key in self.DEFAULT, f'Bad key {key}' setattr(self, key, value) @property def data(self): ''' Helper to get the simulation data instance ''' return self.sim.data # TODO: remove this when mujoco-py fix is merged and a new version is pushed # https://github.com/openai/mujoco-py/pull/354 # Then all uses of `self.world.get_sensor()` should change to `self.data.get_sensor`. def get_sensor(self, name): id = self.model.sensor_name2id(name) adr = self.model.sensor_adr[id] dim = self.model.sensor_dim[id] return self.data.sensordata[adr:adr + dim].copy() def build(self): ''' Build a world, including generating XML and moving objects ''' # Read in the base XML (contains robot, camera, floor, etc) self.robot_base_path = os.path.join(BASE_DIR, self.robot_base) with open(self.robot_base_path) as f: self.robot_base_xml = f.read() self.xml = xmltodict.parse(self.robot_base_xml) # Nested OrderedDict objects # Convenience accessor for xml dictionary worldbody = self.xml['mujoco']['worldbody'] # Move robot position to starting position worldbody['body']['@pos'] = convert(np.r_[self.robot_xy, self.robot.z_height]) worldbody['body']['@quat'] = convert(rot2quat(self.robot_rot)) # We need this because xmltodict skips over single-item lists in the tree worldbody['body'] = [worldbody['body']] if 'geom' in worldbody: worldbody['geom'] = [worldbody['geom']] else: worldbody['geom'] = [] # Add equality section if missing if 'equality' not in self.xml['mujoco']: self.xml['mujoco']['equality'] = OrderedDict() equality = self.xml['mujoco']['equality'] if 'weld' not in equality: equality['weld'] = [] # Add asset section if missing if 'asset' not in self.xml['mujoco']: # old default rgb1: ".4 .5 .6" # old default rgb2: "0 0 0" # light pink: "1 0.44 .81" # light blue: "0.004 0.804 .996" # light purple: ".676 .547 .996" # med blue: "0.527 0.582 0.906" # indigo: "0.293 0 0.508" asset = xmltodict.parse(''' <asset> <texture type="skybox" builtin="gradient" rgb1="0.527 0.582 0.906" rgb2="0.1 0.1 0.35" width="800" height="800" markrgb="1 1 1" mark="random" random="0.001"/> <texture name="texplane" builtin="checker" height="100" width="100" rgb1="0.7 0.7 0.7" rgb2="0.8 0.8 0.8" type="2d"/> <material name="MatPlane" reflectance="0.1" shininess="0.1" specular="0.1" texrepeat="10 10" texture="texplane"/> </asset> ''') self.xml['mujoco']['asset'] = asset['asset'] # Add light to the XML dictionary light = xmltodict.parse('''<b> <light cutoff="100" diffuse="1 1 1" dir="0 0 -1" directional="true" exponent="1" pos="0 0 0.5" specular="0 0 0" castshadow="false"/> </b>''') worldbody['light'] = light['b']['light'] # Add floor to the XML dictionary if missing if not any(g.get('@name') == 'floor' for g in worldbody['geom']): floor = xmltodict.parse(''' <geom name="floor" type="plane" condim="6"/> ''') worldbody['geom'].append(floor['geom']) # Make sure floor renders the same for every world for g in worldbody['geom']: if g['@name'] == 'floor': g.update({'@size': convert(self.floor_size), '@rgba': '1 1 1 1', '@material': 'MatPlane'}) # Add cameras to the XML dictionary cameras = xmltodict.parse('''<b> <camera name="fixednear" pos="0 -2 2" zaxis="0 -1 1"/> <camera name="fixedfar" pos="0 -5 5" zaxis="0 -1 1"/> </b>''') worldbody['camera'] = cameras['b']['camera'] # Build and add a tracking camera (logic needed to ensure orientation correct) theta = self.robot_rot xyaxes = dict( x1=np.cos(theta), x2=-np.sin(theta), x3=0, y1=np.sin(theta), y2=np.cos(theta), y3=1 ) pos = dict( xp=0*np.cos(theta) + (-2)*np.sin(theta), yp=0*(-np.sin(theta)) + (-2)*np.cos(theta), zp=2 ) track_camera = xmltodict.parse('''<b> <camera name="track" mode="track" pos="{xp} {yp} {zp}" xyaxes="{x1} {x2} {x3} {y1} {y2} {y3}"/> </b>'''.format(**pos, **xyaxes)) worldbody['body'][0]['camera'] = [ worldbody['body'][0]['camera'], track_camera['b']['camera'] ] # Add objects to the XML dictionary for name, object in self.objects.items(): assert object['name'] == name, f'Inconsistent {name} {object}' object = object.copy() # don't modify original object object['quat'] = rot2quat(object['rot']) if name=='box': dim = object['size'][0] object['dim'] = dim object['width'] = dim/2 object['x'] = dim object['y'] = dim body = xmltodict.parse(''' <body name="{name}" pos="{pos}" quat="{quat}"> <freejoint name="{name}"/> <geom name="{name}" type="{type}" size="{size}" density="{density}" rgba="{rgba}" group="{group}"/> <geom name="col1" type="{type}" size="{width} {width} {dim}" density="{density}" rgba="{rgba}" group="{group}" pos="{x} {y} 0"/> <geom name="col2" type="{type}" size="{width} {width} {dim}" density="{density}" rgba="{rgba}" group="{group}" pos="-{x} {y} 0"/> <geom name="col3" type="{type}" size="{width} {width} {dim}" density="{density}" rgba="{rgba}" group="{group}" pos="{x} -{y} 0"/> <geom name="col4" type="{type}" size="{width} {width} {dim}" density="{density}" rgba="{rgba}" group="{group}" pos="-{x} -{y} 0"/> </body> '''.format(**{k: convert(v) for k, v in object.items()})) else: body = xmltodict.parse(''' <body name="{name}" pos="{pos}" quat="{quat}"> <freejoint name="{name}"/> <geom name="{name}" type="{type}" size="{size}" density="{density}" rgba="{rgba}" group="{group}"/> </body> '''.format(**{k: convert(v) for k, v in object.items()})) # Append new body to world, making it a list optionally # Add the object to the world worldbody['body'].append(body['body']) # Add mocaps to the XML dictionary for name, mocap in self.mocaps.items(): # Mocap names are suffixed with 'mocap' assert mocap['name'] == name, f'Inconsistent {name} {object}' assert name.replace('mocap', 'obj') in self.objects, f'missing object for {name}' # Add the object to the world mocap = mocap.copy() # don't modify original object mocap['quat'] = rot2quat(mocap['rot']) body = xmltodict.parse(''' <body name="{name}" mocap="true"> <geom name="{name}" type="{type}" size="{size}" rgba="{rgba}" pos="{pos}" quat="{quat}" contype="0" conaffinity="0" group="{group}"/> </body> '''.format(**{k: convert(v) for k, v in mocap.items()})) worldbody['body'].append(body['body']) # Add weld to equality list mocap['body1'] = name mocap['body2'] = name.replace('mocap', 'obj') weld = xmltodict.parse(''' <weld name="{name}" body1="{body1}" body2="{body2}" solref=".02 1.5"/> '''.format(**{k: convert(v) for k, v in mocap.items()})) equality['weld'].append(weld['weld']) # Add geoms to XML dictionary for name, geom in self.geoms.items(): assert geom['name'] == name, f'Inconsistent {name} {geom}' geom = geom.copy() # don't modify original object geom['quat'] = rot2quat(geom['rot']) geom['contype'] = geom.get('contype', 1) geom['conaffinity'] = geom.get('conaffinity', 1) body = xmltodict.parse(''' <body name="{name}" pos="{pos}" quat="{quat}"> <geom name="{name}" type="{type}" size="{size}" rgba="{rgba}" group="{group}" contype="{contype}" conaffinity="{conaffinity}"/> </body> '''.format(**{k: convert(v) for k, v in geom.items()})) # Append new body to world, making it a list optionally # Add the object to the world worldbody['body'].append(body['body']) # Instantiate simulator # print(xmltodict.unparse(self.xml, pretty=True)) self.xml_string = xmltodict.unparse(self.xml) self.model = load_model_from_xml(self.xml_string) self.sim = MjSim(self.model) # Add render contexts to newly created sim if self.render_context is None and self.observe_vision: render_context = MjRenderContextOffscreen(self.sim, device_id=-1, quiet=True) render_context.vopt.geomgroup[:] = 1 self.render_context = render_context if self.render_context is not None: self.render_context.update_sim(self.sim) # Recompute simulation intrinsics from new position self.sim.forward() def rebuild(self, config={}, state=True): ''' Build a new sim from a model if the model changed ''' if state: old_state = self.sim.get_state() #self.config.update(deepcopy(config)) #self.parse(self.config) self.parse(config) self.build() if state: self.sim.set_state(old_state) self.sim.forward() def reset(self, build=True): ''' Reset the world (sim is accessed through self.sim) ''' if build: self.build() # set flag so that renderer knows to update sim self.update_viewer_sim = True def render(self, mode='human'): ''' Render the environment to the screen ''' if self.viewer is None: self.viewer = MjViewer(self.sim) # Turn all the geom groups on self.viewer.vopt.geomgroup[:] = 1 # Set camera if specified if mode == 'human': self.viewer.cam.fixedcamid = -1 self.viewer.cam.type = const.CAMERA_FREE else: self.viewer.cam.fixedcamid = self.model.camera_name2id(mode) self.viewer.cam.type = const.CAMERA_FIXED if self.update_viewer_sim: self.viewer.update_sim(self.sim) self.update_viewer_sim = False self.viewer.render() def robot_com(self): ''' Get the position of the robot center of mass in the simulator world reference frame ''' return self.body_com('robot') def robot_pos(self): ''' Get the position of the robot in the simulator world reference frame ''' return self.body_pos('robot') def robot_mat(self): ''' Get the rotation matrix of the robot in the simulator world reference frame ''' return self.body_mat('robot') def robot_vel(self): ''' Get the velocity of the robot in the simulator world reference frame ''' return self.body_vel('robot') def body_com(self, name): ''' Get the center of mass of a named body in the simulator world reference frame ''' return self.data.subtree_com[self.model.body_name2id(name)].copy() def body_pos(self, name): ''' Get the position of a named body in the simulator world reference frame ''' return self.data.get_body_xpos(name).copy() def body_mat(self, name): ''' Get the rotation matrix of a named body in the simulator world reference frame ''' return self.data.get_body_xmat(name).copy() def body_vel(self, name): ''' Get the velocity of a named body in the simulator world reference frame ''' return self.data.get_body_xvelp(name).copy() class Robot: ''' Simple utility class for getting mujoco-specific info about a robot ''' def __init__(self, path): base_path = os.path.join(BASE_DIR, path) self.sim = MjSim(load_model_from_path(base_path)) self.sim.forward() # Needed to figure out z-height of free joint of offset body self.z_height = self.sim.data.get_body_xpos('robot')[2] # Get a list of geoms in the robot self.geom_names = [n for n in self.sim.model.geom_names if n != 'floor'] # Needed to figure out the observation spaces self.nq = self.sim.model.nq self.nv = self.sim.model.nv # Needed to figure out action space self.nu = self.sim.model.nu # Needed to figure out observation space # See engine.py for an explanation for why we treat these separately self.hinge_pos_names = [] self.hinge_vel_names = [] self.ballquat_names = [] self.ballangvel_names = [] self.sensor_dim = {} for name in self.sim.model.sensor_names: id = self.sim.model.sensor_name2id(name) self.sensor_dim[name] = self.sim.model.sensor_dim[id] sensor_type = self.sim.model.sensor_type[id] if self.sim.model.sensor_objtype[id] == const.OBJ_JOINT: joint_id = self.sim.model.sensor_objid[id] joint_type = self.sim.model.jnt_type[joint_id] if joint_type == const.JNT_HINGE: if sensor_type == const.SENS_JOINTPOS: self.hinge_pos_names.append(name) elif sensor_type == const.SENS_JOINTVEL: self.hinge_vel_names.append(name) else: t = self.sim.model.sensor_type[i] raise ValueError('Unrecognized sensor type {} for joint'.format(t)) elif joint_type == const.JNT_BALL: if sensor_type == const.SENS_BALLQUAT: self.ballquat_names.append(name) elif sensor_type == const.SENS_BALLANGVEL: self.ballangvel_names.append(name) elif joint_type == const.JNT_SLIDE: # Adding slide joints is trivially easy in code, # but this removes one of the good properties about our observations. # (That we are invariant to relative whole-world transforms) # If slide joints are added we sould ensure this stays true! raise ValueError('Slide joints in robots not currently supported')