From 33f79d02b0b59db1c2eee56b4d8d7fc751fd65c7 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Thu, 18 Dec 2025 21:44:11 +0800
Subject: [PATCH 01/14] =?UTF-8?q?=E5=88=A0=E9=99=A4simulator=E6=96=87?=
=?UTF-8?q?=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/simulators/2.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 src/box/simulators/2.py
diff --git a/src/box/simulators/2.py b/src/box/simulators/2.py
deleted file mode 100644
index e69de29bb2..0000000000
From 0bd8bae26be0c79f471542a9a1c60fa91fee0b2a Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Fri, 19 Dec 2025 12:33:46 +0800
Subject: [PATCH 02/14] =?UTF-8?q?=E6=A0=B8=E5=BF=83simulator?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/eye.py | 679 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 679 insertions(+)
create mode 100644 src/box/eye.py
diff --git a/src/box/eye.py b/src/box/eye.py
new file mode 100644
index 0000000000..655519533c
--- /dev/null
+++ b/src/box/eye.py
@@ -0,0 +1,679 @@
+import gymnasium as gym
+from gymnasium import spaces
+import pygame
+import mujoco
+import os
+import numpy as np
+import scipy
+import matplotlib
+import sys
+import importlib
+import shutil
+import inspect
+import pathlib
+from datetime import datetime
+import copy
+from collections import defaultdict
+import xml.etree.ElementTree as ET
+
+from .perception.base import Perception
+from .utils.rendering import Camera, Context
+from .utils.functions import output_path, parent_path, is_suitable_package_name, parse_yaml, write_yaml
+
+
+class Simulator(gym.Env):
+ """
+ The Simulator class contains functionality to build a standalone Python package from a config file. The built package
+ integrates a biomechanical model, a task model, and a perception model into one simulator that implements a gym.Env
+ interface.
+ """
+
+ # May be useful for later, the three digit number suffix is of format X.Y.Z where X is a major version.
+ version = "1.1.0"
+
+ @classmethod
+ def get_class(cls, *args):
+ """ Returns a class from given strings. The last element in args should contain the class name. """
+ # TODO check for incorrect module names etc
+ modules = ".".join(args[:-1])
+ if "." in args[-1]:
+ splitted = args[-1].split(".")
+ if modules == "":
+ modules = ".".join(splitted[:-1])
+ else:
+ modules += "." + ".".join(splitted[:-1])
+ cls_name = splitted[-1]
+ else:
+ cls_name = args[-1]
+ module = cls.get_module(modules)
+ return getattr(module, cls_name)
+
+ @classmethod
+ def get_module(cls, *args):
+ """ Returns a module from given strings. """
+ src = __name__.split(".")[0]
+ modules = ".".join(args)
+ return importlib.import_module(src + "." + modules)
+
+ @classmethod
+ def build(cls, config):
+ """ Builds a simulator based on a config. The input 'config' may be a dict (parsed from YAML) or path to a YAML file
+
+ Args:
+ config:
+ - A dict containing configuration information. See example configs in folder uitb/configs/
+ - A path to a config file
+ """
+
+ # If config is a path to the config file, parse it first
+ if isinstance(config, str):
+ if not os.path.isfile(config):
+ raise FileNotFoundError(f"Given config file {config} does not exist")
+ config = parse_yaml(config)
+
+ # Make sure required things are defined in config
+ assert "simulation" in config, "Simulation specs (simulation) must be defined in config"
+ assert "bm_model" in config["simulation"], "Biomechanical model (bm_model) must be defined in config"
+ assert "task" in config["simulation"], "task (task) must be defined in config"
+
+ assert "run_parameters" in config["simulation"], "Run parameters (run_parameters) must be defined in config"
+ run_parameters = config["simulation"]["run_parameters"].copy()
+ assert "action_sample_freq" in run_parameters, "Action sampling frequency (action_sample_freq) must be defined " \
+ "in run parameters"
+
+ # Set simulator version
+ config["version"] = cls.version
+
+ # Save generated simulators to uitb/simulators
+ if "simulator_folder" in config:
+ simulator_folder = os.path.normpath(config["simulator_folder"])
+ else:
+ simulator_folder = os.path.join(output_path(), config["simulator_name"])
+
+ # If 'package_name' is not defined use 'simulator_name'
+ if "package_name" not in config:
+ config["package_name"] = config["simulator_name"]
+ if not is_suitable_package_name(config["package_name"]):
+ raise NameError("Package name defined in the config file (either through 'package_name' or 'simulator_name') is "
+ "not a suitable Python package name. Use only lower-case letters and underscores instead of "
+ "spaces, and the name cannot start with a number.")
+
+ # The name used in gym has a suffix -v0
+ config["gym_name"] = "uitb:" + config["package_name"] + "-v0"
+
+ # Create a simulator in the simulator folder
+ cls._clone(simulator_folder, config["package_name"])
+
+ # Load task class
+ task_cls = cls.get_class("tasks", config["simulation"]["task"]["cls"])
+ task_cls.clone(simulator_folder, config["package_name"], app_executable=config["simulation"]["task"].get("kwargs", {}).get("unity_executable", None))
+ simulation = task_cls.initialise(config["simulation"]["task"].get("kwargs", {}))
+
+ # Set some compiler options
+ # TODO: would make more sense to have a separate "environment" class / xml file that defines all these defaults,
+ # including e.g. cameras, lighting, etc., so that they could be easily changed. Task and biomechanical model would
+ # be integrated into that object
+ compiler_defaults = {"inertiafromgeom": "auto", "balanceinertia": "true", "boundmass": "0.001",
+ "boundinertia": "0.001", "inertiagrouprange": "0 1"}
+ compiler = simulation.find("compiler")
+ if compiler is None:
+ ET.SubElement(simulation, "compiler", compiler_defaults)
+ else:
+ compiler.attrib.update(compiler_defaults)
+
+ # Load biomechanical model class
+ bm_cls = cls.get_class("bm_models", config["simulation"]["bm_model"]["cls"])
+ bm_cls.clone(simulator_folder, config["package_name"])
+ bm_cls.insert(simulation)
+
+ # Add perception modules
+ for module_cfg in config["simulation"].get("perception_modules", []):
+ module_cls = cls.get_class("perception", module_cfg["cls"])
+ module_kwargs = module_cfg.get("kwargs", {})
+ module_cls.clone(simulator_folder, config["package_name"])
+ module_cls.insert(simulation, **module_kwargs)
+
+ # Clone also RL library files so the package will be completely standalone
+ rl_cls = cls.get_class("rl", config["rl"]["algorithm"])
+ rl_cls.clone(simulator_folder, config["package_name"])
+
+ # TODO read the xml file directly from task.getroot() instead of writing it to a file first; need to input a dict
+ # of assets to mujoco.MjModel.from_xml_path
+ simulation_file = os.path.join(simulator_folder, config["package_name"], "simulation")
+ with open(simulation_file+".xml", 'w') as file:
+ simulation.write(file, encoding='unicode')
+
+ # Initialise the simulator
+ model, _, _, _, _, _ = \
+ cls._initialise(config, simulator_folder, {**run_parameters, "build": True})
+
+ # Now that simulator has been initialised, everything should be set. Now we want to save the xml file again, but
+ # mujoco only is able to save the latest loaded xml file (which is either the task or bm model xml files which are
+ # are read in their __init__ functions), hence we need to read the file we've generated again before saving the
+ # modified model
+ mujoco.MjModel.from_xml_path(simulation_file+".xml")
+ mujoco.mj_saveLastXML(simulation_file+".xml", model)
+
+ # Save the modified model also as binary for faster loading
+ mujoco.mj_saveModel(model, simulation_file+".mjcf", None)
+
+ # Input built time into config
+ config["built"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ # Save config
+ write_yaml(config, os.path.join(simulator_folder, "config.yaml"))
+
+ return simulator_folder
+
+ @classmethod
+ def _clone(cls, simulator_folder, package_name):
+ """ Create a folder for the simulator being built, and copy or create relevant files.
+
+ Args:
+ simulator_folder: Location of the simulator.
+ package_name: Name of the simulator (which is a python package).
+ """
+
+ # Create the folder
+ dst = os.path.join(simulator_folder, package_name)
+ os.makedirs(dst, exist_ok=True)
+
+ # Copy simulator
+ src = pathlib.Path(inspect.getfile(cls))
+ shutil.copyfile(src, os.path.join(dst, src.name))
+
+ # Create __init__.py with env registration
+ with open(os.path.join(dst, "__init__.py"), "w") as file:
+ file.write("from .simulator import Simulator\n\n")
+ file.write("from gymnasium.envs.registration import register\n")
+ file.write("import pathlib\n\n")
+ file.write("module_folder = pathlib.Path(__file__).parent\n")
+ file.write("simulator_folder = module_folder.parent\n")
+ file.write("kwargs = {'simulator_folder': simulator_folder}\n")
+ file.write("register(id=f'{module_folder.stem}-v0', entry_point=f'{module_folder.stem}.simulator:Simulator', kwargs=kwargs)\n")
+
+ # Copy utils
+ shutil.copytree(os.path.join(parent_path(src), "utils"), os.path.join(simulator_folder, package_name, "utils"),
+ dirs_exist_ok=True)
+ # Copy train
+ shutil.copytree(os.path.join(parent_path(src), "train"), os.path.join(simulator_folder, package_name, "train"),
+ dirs_exist_ok=True)
+ # Copy test
+ shutil.copytree(os.path.join(parent_path(src), "test"), os.path.join(simulator_folder, package_name, "test"),
+ dirs_exist_ok=True)
+
+ @classmethod
+ def _initialise(cls, config, simulator_folder, run_parameters):
+ """ Initialise a simulator -- i.e., create a MjModel, MjData, and initialise all necessary variables.
+
+ Args:
+ config: A config dict.
+ simulator_folder: Location of the simulator.
+ run_parameters: Important run time variables that may also be used to override parameters.
+ """
+
+ # Get task class and kwargs
+ task_cls = cls.get_class("tasks", config["simulation"]["task"]["cls"])
+ task_kwargs = config["simulation"]["task"].get("kwargs", {})
+
+ # Get bm class and kwargs
+ bm_cls = cls.get_class("bm_models", config["simulation"]["bm_model"]["cls"])
+ bm_kwargs = config["simulation"]["bm_model"].get("kwargs", {})
+
+ # Initialise perception modules
+ perception_modules = {}
+ for module_cfg in config["simulation"].get("perception_modules", []):
+ module_cls = cls.get_class("perception", module_cfg["cls"])
+ module_kwargs = module_cfg.get("kwargs", {})
+ perception_modules[module_cls] = module_kwargs
+
+ # Get simulation file
+ simulation_file = os.path.join(simulator_folder, config["package_name"], "simulation")
+
+ # Load the mujoco model; try first with the binary model (faster, contains some parameters that may be lost when
+ # re-saving xml files like body mass). For some reason the binary model fails to load in some situations (like
+ # when the simulator has been built on a different computer)
+ # TODO loading from binary disabled, weird problems (like a body not found from model when loaded from binary, but
+ # found correctly when model loaded from xml)
+ # try:
+ # model = mujoco.MjModel.from_binary_path(simulation_file + ".mjcf")
+ # except: # TODO what was the exception type
+ model = mujoco.MjModel.from_xml_path(simulation_file + ".xml")
+
+ # Initialise MjData
+ data = mujoco.MjData(model)
+
+ # Add frame skip and dt to run parameters
+ run_parameters["frame_skip"] = int(1 / (model.opt.timestep * run_parameters["action_sample_freq"]))
+ run_parameters["dt"] = model.opt.timestep*run_parameters["frame_skip"]
+
+ # Initialise a rendering context, required for e.g. some vision modules
+ run_parameters["rendering_context"] = Context(model,
+ max_resolution=run_parameters.get("max_resolution", [1280, 960]))
+
+ # Initialise callbacks
+ callbacks = {}
+ for cb in run_parameters.get("callbacks", []):
+ callbacks[cb["name"]] = cls.get_class(cb["cls"])(cb["name"], **cb["kwargs"])
+
+ # Now initialise the actual classes; model and data are input to the inits so that stuff can be modified if needed
+ # (e.g. move target to a specific position wrt to a body part)
+ task = task_cls(model, data, **{**task_kwargs, **callbacks, **run_parameters})
+ bm_model = bm_cls(model, data, **{**bm_kwargs, **callbacks, **run_parameters})
+ perception = Perception(model, data, bm_model, perception_modules, {**callbacks, **run_parameters})
+
+ return model, data, task, bm_model, perception, callbacks
+
+ @classmethod
+ def get(cls, simulator_folder, render_mode="rgb_array", render_mode_perception="embed", render_show_depths=False, run_parameters=None, use_cloned=True):
+ """ Returns a Simulator that is located in given folder.
+
+ Args:
+ simulator_folder: Location of the simulator.
+ render_mode: Whether render() will return a single rgb array (render_mode="rgb_array"),
+ a list of rgb arrays (render_mode="rgb_array_list";
+ adapted from https://github.com/openai/gym/blob/master/gym/wrappers/render_collection.py),
+ or None while the frames in a separate PyGame window are updated directly when calling
+ step() or reset() (render_mode="human";
+ adapted from https://github.com/openai/gym/blob/master/gym/wrappers/human_rendering.py)).
+ render_mode_perception: Whether images of visual perception modules should be directly embedded into main camera view ("embed"), stored as separate videos ("separate"), or not used at all [which allows to watch vision in Unity Editor if debug mode is enabled/standalone app is disabled] (None)
+ render_show_depths: Whether depth images of visual perception modules should be included in rendering.
+ run_parameters: Can be used to override parameters during run time.
+ use_cloned: Can be useful for debugging. Set to False to use original files instead of the ones that have been
+ cloned/copied during building phase.
+ """
+
+ # Read config file
+ config_file = os.path.join(simulator_folder, "config.yaml")
+ try:
+ config = parse_yaml(config_file)
+ except:
+ raise FileNotFoundError(f"Could not open file {config_file}")
+
+ # Make sure the simulator has been built
+ if "built" not in config:
+ raise RuntimeError("Simulator has not been built")
+
+ # Make sure simulator_folder is in path (used to import gen_cls_cloned)
+ if simulator_folder not in sys.path:
+ sys.path.insert(0, simulator_folder)
+
+ # Get Simulator class
+ gen_cls_cloned = getattr(importlib.import_module(config["package_name"]), "Simulator")
+ if hasattr(gen_cls_cloned, "version"):
+ _legacy_mode = False
+ gen_cls_cloned_version = gen_cls_cloned.version.split("-v")[-1]
+ else:
+ _legacy_mode = True
+ gen_cls_cloned_version = gen_cls_cloned.id.split("-v")[-1] #deprecated
+ if use_cloned:
+ gen_cls = gen_cls_cloned
+ else:
+ gen_cls = cls
+ gen_cls_version = gen_cls.version.split("-v")[-1]
+
+ if gen_cls_version.split(".")[0] > gen_cls_cloned_version.split(".")[0]:
+ raise RuntimeError(
+ f"""Severe version mismatch. The simulator '{config["simulator_name"]}' has version {gen_cls_cloned_version}, while your uitb package has version {gen_cls_version}.\nTo run with version {gen_cls_cloned_version}, set 'use_cloned=True'.""")
+ elif gen_cls_version.split(".")[1] > gen_cls_cloned_version.split(".")[1]:
+ print(
+ f"""WARNING: Version mismatch. The simulator '{config["simulator_name"]}' has version {gen_cls_cloned_version}, while your uitb package has version {gen_cls_version}.\nTo run with version {gen_cls_version}, set 'use_cloned=True'.""")
+
+ if _legacy_mode:
+ _simulator = gen_cls(simulator_folder, run_parameters=run_parameters)
+ else:
+ try:
+ _simulator = gen_cls(simulator_folder, render_mode=render_mode, render_mode_perception=render_mode_perception, render_show_depths=render_show_depths,
+ run_parameters=run_parameters)
+ except TypeError:
+ _simulator = gen_cls(simulator_folder, render_mode=render_mode, render_show_depths=render_show_depths,
+ run_parameters=run_parameters)
+
+ # Return Simulator object
+ return _simulator
+
+ def __init__(self, simulator_folder, render_mode="rgb_array", render_mode_perception="embed", render_show_depths=False, run_parameters=None):
+ """ Initialise a new `Simulator`.
+
+ Args:
+ simulator_folder: Location of a simulator.
+ render_mode: Whether render() will return a single rgb array (render_mode="rgb_array"),
+ a list of rgb arrays (render_mode="rgb_array_list";
+ adapted from https://github.com/openai/gym/blob/master/gym/wrappers/render_collection.py),
+ or None while the frames in a separate PyGame window are updated directly when calling
+ step() or reset() (render_mode="human";
+ adapted from https://github.com/openai/gym/blob/master/gym/wrappers/human_rendering.py)).
+ render_mode_perception: Whether images of visual perception modules should be directly embedded into main camera view ("embed"), stored as separate videos ("separate"), or not used at all [which allows to watch vision in Unity Editor if debug mode is enabled/standalone app is disabled] (None)
+ render_show_depths: Whether depth images of visual perception modules should be included in rendering.
+ run_parameters: Can be used to override parameters during run time.
+ """
+
+ # Make sure simulator exists
+ if not os.path.exists(simulator_folder):
+ raise FileNotFoundError(f"Simulator folder {simulator_folder} does not exists")
+ self._simulator_folder = simulator_folder
+
+ # Read config
+ self._config = parse_yaml(os.path.join(self._simulator_folder, "config.yaml"))
+
+ # Get run parameters: these parameters can be used to override parameters used during training
+ self._run_parameters = self._config["simulation"]["run_parameters"].copy()
+ self._run_parameters.update(run_parameters or {})
+
+ # Initialise simulation
+ self._model, self._data, self.task, self.bm_model, self.perception, self.callbacks = \
+ self._initialise(self._config, self._simulator_folder, self._run_parameters)
+
+ # Set action space TODO for now we assume all actuators have control signals between [-1, 1]
+ self.action_space = self._initialise_action_space()
+
+ # Set observation space
+ self.observation_space = self._initialise_observation_space()
+
+ # Collect some episode statistics
+ self._episode_statistics = {"length (seconds)": 0, "length (steps)": 0, "reward": 0}
+
+ # Initialise viewer
+ self._GUI_camera = Camera(self._run_parameters["rendering_context"], self._model, self._data, camera_id='for_testing',
+ dt=self._run_parameters["dt"])
+
+ self._render_mode = render_mode
+ self._render_mode_perception = render_mode_perception #whether perception camera views should be directly embedded into camera view of camera_id ("embed"), stored in self._render_stack_perception ("separate"), or not used at all "separate"), or not used at all [which allows to watch vision in Unity Editor if debug mode is enabled/standalone app is disabled] (None)
+ self._render_stack = [] #only used if render_mode == "rgb_array_list"
+ self._render_stack_perception = defaultdict(list) #only used if render_mode == "rgb_array_list" and self._render_mode_perception == "separate"
+ self._render_stack_pop = True #If True, clear the render stack after .render() is called.
+ self._render_stack_clean_at_reset = True #If True, clear the render stack when .reset() is called.
+ self._render_show_depths = render_show_depths #If True, depth images of visual perception modules are included in GUI rendering.
+ self._render_screen_size = None #only used if render_mode == "human"
+ self._render_window = None #only used if render_mode == "human"
+ self._render_clock = None #only used if render_mode == "human"
+
+ def _initialise_action_space(self):
+ """ Initialise action space. """
+ num_actuators = self.bm_model.nu + self.perception.nu
+ actuator_limits = np.ones((num_actuators,2)) * np.array([-1.0, 1.0])
+ return spaces.Box(low=np.float32(actuator_limits[:, 0]), high=np.float32(actuator_limits[:, 1]))
+
+ def _initialise_observation_space(self):
+ """ Initialise observation space. """
+ observation = self.get_observation()
+ obs_dict = dict()
+ for module in self.perception.perception_modules:
+ obs_dict[module.modality] = spaces.Box(dtype=np.float32, **module.get_observation_space_params())
+ if "stateful_information" in observation:
+ obs_dict["stateful_information"] = spaces.Box(dtype=np.float32,
+ **self.task.get_stateful_information_space_params())
+
+ return spaces.Dict(obs_dict)
+
+ def step(self, action):
+ """ Step simulation forward with given actions.
+
+ Args:
+ action: Actions sampled from a policy. Limited to range [-1, 1].
+ """
+
+ # Set control for the bm model
+ self.bm_model.set_ctrl(self._model, self._data, action[:self.bm_model.nu])
+
+ # Set control for perception modules (e.g. eye movements)
+ self.perception.set_ctrl(self._model, self._data, action[self.bm_model.nu:])
+
+ # Advance the simulation
+ mujoco.mj_step(self._model, self._data, nstep=self._run_parameters["frame_skip"])
+
+ # Update bm model (e.g. update constraints); updates also effort model
+ self.bm_model.update(self._model, self._data)
+
+ # Update perception modules
+ self.perception.update(self._model, self._data)
+
+ # Update environment
+ reward, terminated, truncated, info = self.task.update(self._model, self._data)
+
+ # Add an effort cost to reward
+ effort_cost = self.bm_model.get_effort_cost(self._model, self._data)
+ info["EffortCost"] = effort_cost
+ reward -= effort_cost
+
+ # Get observation
+ obs = self.get_observation(info)
+
+ # Add frame to stack
+ if self._render_mode == "rgb_array_list":
+ self._render_stack.append(self._GUI_rendering())
+ elif self._render_mode == "human":
+ self._GUI_rendering_pygame()
+
+ return obs, reward, terminated, truncated, info
+
+ def get_observation(self, info=None):
+ """ Returns an observation from the perception model.
+
+ Returns:
+ A dict with observations from individual perception modules. May also contain stateful information from a task.
+ """
+
+ # Get observation from perception
+ observation = self.perception.get_observation(self._model, self._data, info)
+
+ # Add any stateful information that is required
+ stateful_information = self.task.get_stateful_information(self._model, self._data)
+ if stateful_information.size > 0: #TODO: define stateful_information (and encoder) that can be used as default, if no stateful information is provided (zero-size arrays do not work with sb3 currently...)
+ observation["stateful_information"] = stateful_information
+
+ return observation
+
+ def reset(self, seed=None):
+ """ Reset the simulator and return an observation. """
+
+ super().reset(seed=seed)
+
+ # Reset sim
+ mujoco.mj_resetData(self._model, self._data)
+
+ # Reset all models
+ self.bm_model.reset(self._model, self._data)
+ self.perception.reset(self._model, self._data)
+ info = self.task.reset(self._model, self._data)
+
+ # Do a forward so everything will be set
+ mujoco.mj_forward(self._model, self._data)
+
+ if self._render_mode == "rgb_array_list":
+ if self._render_stack_clean_at_reset:
+ self._render_stack = []
+ self._render_stack_perception = defaultdict(list)
+ self._render_stack.append(self._GUI_rendering())
+ elif self._render_mode == "human":
+ self._GUI_rendering_pygame()
+
+ return self.get_observation(), info
+
+ def render(self):
+ if self._render_mode == "rgb_array_list":
+ render_stack = self._render_stack
+ if self._render_stack_pop:
+ self._render_stack = []
+ return render_stack
+ elif self._render_mode == "rgb_array":
+ return self._GUI_rendering()
+ else:
+ return None
+
+ def get_render_stack_perception(self):
+ render_stack_perception = self._render_stack_perception
+ # if self._render_stack_pop:
+ # self._render_stack_perception = defaultdict(list)
+ return render_stack_perception
+
+ def _GUI_rendering(self):
+ # Grab an image from the 'for_testing' camera and grab all GUI-prepared images from included visual perception modules, and display them 'picture-in-picture'
+
+ # Grab images
+ img, _ = self._GUI_camera.render()
+
+ if self._render_mode_perception == "embed":
+ # Embed perception camera images into main camera image
+
+ perception_camera_images = [rgb_or_depth_array for camera in self.perception.cameras
+ for rgb_or_depth_array in camera.render() if rgb_or_depth_array is not None]
+
+ # TODO: add text annotations to perception camera images
+ if len(perception_camera_images) > 0:
+ _img_size = img.shape[:2] #(height, width)
+
+ # Vertical alignment of perception camera images, from bottom right to top right
+ ## TODO: allow for different inset locations
+ _desired_subwindow_height = np.round(_img_size[0] / len(perception_camera_images)).astype(int)
+ _maximum_subwindow_width = np.round(0.2 * _img_size[1]).astype(int)
+
+ perception_camera_images_resampled = []
+ for ocular_img in perception_camera_images:
+ # Convert 2D depth arrays to 3D heatmap arrays
+ if ocular_img.ndim == 2:
+ if self._render_show_depths:
+ ocular_img = matplotlib.pyplot.imshow(ocular_img, cmap=matplotlib.pyplot.cm.jet, interpolation='bicubic').make_image('TkAgg', unsampled=True)[0][
+ ..., :3]
+ matplotlib.pyplot.close() #delete image
+ else:
+ continue
+
+ resample_factor = min(_desired_subwindow_height / ocular_img.shape[0], _maximum_subwindow_width / ocular_img.shape[1])
+
+ resample_height = np.round(ocular_img.shape[0] * resample_factor).astype(int)
+ resample_width = np.round(ocular_img.shape[1] * resample_factor).astype(int)
+ resampled_img = np.zeros((resample_height, resample_width, ocular_img.shape[2]), dtype=np.uint8)
+ for channel in range(ocular_img.shape[2]):
+ resampled_img[:, :, channel] = scipy.ndimage.zoom(ocular_img[:, :, channel], resample_factor, order=0)
+
+ perception_camera_images_resampled.append(resampled_img)
+
+ ocular_img_bottom = _img_size[0]
+ for ocular_img_idx, ocular_img in enumerate(perception_camera_images_resampled):
+ #print(f"Modify ({ocular_img_bottom - ocular_img.shape[0]}, { _img_size[1] - ocular_img.shape[1]})-({ocular_img_bottom}, {img.shape[1]}).")
+ img[ocular_img_bottom - ocular_img.shape[0]:ocular_img_bottom, _img_size[1] - ocular_img.shape[1]:] = ocular_img
+ ocular_img_bottom -= ocular_img.shape[0]
+ # input((len(perception_camera_images_resampled), perception_camera_images_resampled[0].shape, img.shape))
+ elif self._render_mode_perception == "separate":
+ for module, camera_list in self.perception.cameras_dict.items():
+ for camera in camera_list:
+ for rgb_or_depth_array in camera.render():
+ if rgb_or_depth_array is not None:
+ self._render_stack_perception[f"{module.modality}/{type(camera).__name__}"].append(rgb_or_depth_array)
+
+ return img
+
+ def _GUI_rendering_pygame(self):
+ rgb_array = np.transpose(self._GUI_rendering(), axes=(1, 0, 2))
+
+ if self._render_screen_size is None:
+ self._render_screen_size = rgb_array.shape[:2]
+
+ assert self._render_screen_size == rgb_array.shape[
+ :2], f"Expected an rgb array of shape {self._render_screen_size} from self._GUI_camera, but received an rgb array of shape {rgb_array.shape[:2]}. "
+
+ if self._render_window is None:
+ pygame.init()
+ pygame.display.init()
+ self._render_window = pygame.display.set_mode(self._render_screen_size)
+
+ if self._render_clock is None:
+ self._render_clock = pygame.time.Clock()
+
+ surf = pygame.surfarray.make_surface(rgb_array)
+ self._render_window.blit(surf, (0, 0))
+ pygame.event.pump()
+ self._render_clock.tick(self.fps)
+ pygame.display.flip()
+
+ def close(self):
+ """ Close the rendering window (if self._render_mode == 'human')."""
+ super().close()
+ if self._render_window is not None:
+ import pygame
+
+ pygame.display.quit()
+ pygame.quit()
+
+ @property
+ def fps(self):
+ return self._GUI_camera._fps
+
+ def callback(self, callback_name, num_timesteps):
+ """ Update a callback -- may be useful during training, e.g. for curriculum learning. """
+ self.callbacks[callback_name].update(num_timesteps)
+
+ def update_callbacks(self, num_timesteps):
+ """ Update all callbacks. """
+ for callback_name in self.callbacks:
+ self.callback(callback_name, num_timesteps)
+
+ # def get_logdict_keys(self):
+ # return list(self.task._info["log_dict"].keys())
+
+ # def get_logdict_value(self, key):
+ # return self.task._info["log_dict"].get(key)
+
+ @property
+ def config(self):
+ """ Return config. """
+ return copy.deepcopy(self._config)
+
+ @property
+ def run_parameters(self):
+ """ Return run parameters. """
+ # Context cannot be deep copied
+ exclude = {"rendering_context"}
+ run_params = {k: copy.deepcopy(self._run_parameters[k]) for k in self._run_parameters.keys() - exclude}
+ run_params["rendering_context"] = self._run_parameters["rendering_context"]
+ return run_params
+
+ @property
+ def simulator_folder(self):
+ """ Return simulator folder. """
+ return self._simulator_folder
+
+ @property
+ def render_mode(self):
+ """ Return render mode. """
+ return self._render_mode
+
+ def get_state(self):
+ """ Return a state of the simulator / individual components (biomechanical model, perception model, task).
+
+ This function is used for logging/evaluation purposes, not for RL training.
+
+ Returns:
+ A dict with one float or numpy vector per keyword.
+ """
+
+ # Get time, qpos, qvel, qacc, act_force, act, ctrl of the current simulation
+ state = {"timestep": self._data.time,
+ "qpos": self._data.qpos.copy(),
+ "qvel": self._data.qvel.copy(),
+ "qacc": self._data.qacc.copy(),
+ "act_force": self._data.actuator_force.copy(),
+ "act": self._data.act.copy(),
+ "ctrl": self._data.ctrl.copy()}
+
+ # Add state from the task
+ state.update(self.task.get_state(self._model, self._data))
+
+ # Add state from the biomechanical model
+ state.update(self.bm_model.get_state(self._model, self._data))
+
+ # Add state from the perception model
+ state.update(self.perception.get_state(self._model, self._data))
+
+ return state
+
+ def close(self, **kwargs):
+ """ Perform any necessary clean up.
+
+ This function is inherited from gym.Env. It should be automatically called when this object is garbage collected
+ or the program exists, but that doesn't seem to be the case. This function will be called if this object has been
+ initialised in the context manager fashion (i.e. using the 'with' statement). """
+ self.task.close(**kwargs)
+ self.perception.close(**kwargs)
+ self.bm_model.close(**kwargs)
\ No newline at end of file
From 996003d026cb8a875b91617826a9259beb98defa Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Fri, 19 Dec 2025 15:40:10 +0800
Subject: [PATCH 03/14] =?UTF-8?q?fixeye=E5=B9=B6=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E6=B3=A8=E9=87=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/README.md | 115 ++-
src/box/eye.py | 1520 +++++++++++++++++++++----------------
src/box/skeleton_video.py | 0
3 files changed, 925 insertions(+), 710 deletions(-)
delete mode 100644 src/box/skeleton_video.py
diff --git a/src/box/README.md b/src/box/README.md
index d1d4fe9282..728c18e479 100644
--- a/src/box/README.md
+++ b/src/box/README.md
@@ -1,59 +1,56 @@
-**box — 仿真与强化学习实验箱**
-
-简介
-- `src/box` 目录包含基于 Gymnasium 和 MuJoCo 的仿真环境与相关辅助脚本,用于开发和测试生物力学/机器人仿真、感知模块与强化学习任务。
-
-目录结构(示例)
-- `simulator.py`:仿真环境核心(通常继承 `gym.Env`)。
-- `test_simulator.py`:示例运行脚本,用于启动仿真并可视化。
-- `main.py`:辅助脚本(例如证书或配置检查)。
-- `README.md`:本文件,说明目录用途与快速上手指南。
-
-快速上手
-1. 创建并激活虚拟环境(以 Windows 为例):
-
-```powershell
-cd <项目根目录>
-python -m venv venv --python=3.9
-.\\venv\\Scripts\\Activate.ps1
-```
-
-2. 安装依赖(建议使用清华镜像加速):
-
-```powershell
-pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
-```
-
-如果仓库没有完整的 `requirements.txt`,可参考下列核心库:
-
-```text
-gymnasium
-mujoco
-stable-baselines3
-pygame
-opencv-python
-numpy
-scipy
-matplotlib
-ruamel.yaml
-certifi
-```
-
-运行示例
-- 启动仿真:
-
-```powershell
-python test_simulator.py
-```
-
-运行后应弹出可视化窗口(若使用 Pygame/SDL),并在终端输出仿真日志。
-
-贡献与问题反馈
-- 若需添加说明或示例,请提交 Pull Request。
-- 遇到环境或依赖问题,请在 Issue 中描述操作系统、Python 版本与错误日志。
-
-更多信息
-- 若目录中包含更详细的子模块文档,请参阅相应文件(如 `simulator.py` 顶部注释或同目录下的文档)。
-
----
-(此 README 为目录概览,具体实现与文件名以代码库为准)
\ No newline at end of file
+**box — 仿真与强化学习实验箱**
+
+简介
+- `src/box` 目录包含基于 Gymnasium 和 MuJoCo 的仿真环境与相关辅助脚本,用于开发和测试生物力学/机器人仿真、感知模块与强化学习任务。
+
+目录结构(示例)
+- `simulator.py`:仿真环境核心(通常继承 `gym.Env`)。
+- `test_simulator.py`:示例运行脚本,用于启动仿真并可视化。
+- `main.py`:辅助脚本(例如证书或配置检查)。
+- `README.md`:本文件,说明目录用途与快速上手指南。
+
+快速上手
+1. 创建并激活虚拟环境(以 Windows 为例):
+
+```powershell
+cd <项目根目录>
+python -m venv venv --python=3.9
+.\\venv\\Scripts\\Activate.ps1
+```
+
+2. 安装依赖(建议使用清华镜像加速):
+
+```powershell
+pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+```
+
+如果仓库没有完整的 `requirements.txt`,可参考下列核心库:
+
+```text
+gymnasium
+mujoco
+stable-baselines3
+pygame
+opencv-python
+numpy
+scipy
+matplotlib
+ruamel.yaml
+certifi
+```
+
+运行示例
+- 启动仿真:
+
+```powershell
+python test_simulator.py
+```
+
+运行后应弹出可视化窗口(若使用 Pygame/SDL),并在终端输出仿真日志。
+
+贡献与问题反馈
+- 若需添加说明或示例,请提交 Pull Request。
+- 遇到环境或依赖问题,请在 Issue 中描述操作系统、Python 版本与错误日志。
+
+更多信息
+- 若目录中包含更详细的子模块文档,请参阅相应文件(如 `simulator.py` 顶部注释或同目录下的文档)。
diff --git a/src/box/eye.py b/src/box/eye.py
index 655519533c..1cb8a72424 100644
--- a/src/box/eye.py
+++ b/src/box/eye.py
@@ -16,664 +16,882 @@
from collections import defaultdict
import xml.etree.ElementTree as ET
+# 感知模块基类:统一管理视觉/触觉等感知组件
from .perception.base import Perception
+# 渲染工具:Camera负责图像采集,Context提供渲染环境(显存/分辨率等)
from .utils.rendering import Camera, Context
+# 通用工具函数:路径处理、配置解析/写入、包名合法性校验
from .utils.functions import output_path, parent_path, is_suitable_package_name, parse_yaml, write_yaml
class Simulator(gym.Env):
- """
- The Simulator class contains functionality to build a standalone Python package from a config file. The built package
- integrates a biomechanical model, a task model, and a perception model into one simulator that implements a gym.Env
- interface.
- """
-
- # May be useful for later, the three digit number suffix is of format X.Y.Z where X is a major version.
- version = "1.1.0"
-
- @classmethod
- def get_class(cls, *args):
- """ Returns a class from given strings. The last element in args should contain the class name. """
- # TODO check for incorrect module names etc
- modules = ".".join(args[:-1])
- if "." in args[-1]:
- splitted = args[-1].split(".")
- if modules == "":
- modules = ".".join(splitted[:-1])
- else:
- modules += "." + ".".join(splitted[:-1])
- cls_name = splitted[-1]
- else:
- cls_name = args[-1]
- module = cls.get_module(modules)
- return getattr(module, cls_name)
-
- @classmethod
- def get_module(cls, *args):
- """ Returns a module from given strings. """
- src = __name__.split(".")[0]
- modules = ".".join(args)
- return importlib.import_module(src + "." + modules)
-
- @classmethod
- def build(cls, config):
- """ Builds a simulator based on a config. The input 'config' may be a dict (parsed from YAML) or path to a YAML file
-
- Args:
- config:
- - A dict containing configuration information. See example configs in folder uitb/configs/
- - A path to a config file
"""
-
- # If config is a path to the config file, parse it first
- if isinstance(config, str):
- if not os.path.isfile(config):
- raise FileNotFoundError(f"Given config file {config} does not exist")
- config = parse_yaml(config)
-
- # Make sure required things are defined in config
- assert "simulation" in config, "Simulation specs (simulation) must be defined in config"
- assert "bm_model" in config["simulation"], "Biomechanical model (bm_model) must be defined in config"
- assert "task" in config["simulation"], "task (task) must be defined in config"
-
- assert "run_parameters" in config["simulation"], "Run parameters (run_parameters) must be defined in config"
- run_parameters = config["simulation"]["run_parameters"].copy()
- assert "action_sample_freq" in run_parameters, "Action sampling frequency (action_sample_freq) must be defined " \
- "in run parameters"
-
- # Set simulator version
- config["version"] = cls.version
-
- # Save generated simulators to uitb/simulators
- if "simulator_folder" in config:
- simulator_folder = os.path.normpath(config["simulator_folder"])
- else:
- simulator_folder = os.path.join(output_path(), config["simulator_name"])
-
- # If 'package_name' is not defined use 'simulator_name'
- if "package_name" not in config:
- config["package_name"] = config["simulator_name"]
- if not is_suitable_package_name(config["package_name"]):
- raise NameError("Package name defined in the config file (either through 'package_name' or 'simulator_name') is "
- "not a suitable Python package name. Use only lower-case letters and underscores instead of "
- "spaces, and the name cannot start with a number.")
-
- # The name used in gym has a suffix -v0
- config["gym_name"] = "uitb:" + config["package_name"] + "-v0"
-
- # Create a simulator in the simulator folder
- cls._clone(simulator_folder, config["package_name"])
-
- # Load task class
- task_cls = cls.get_class("tasks", config["simulation"]["task"]["cls"])
- task_cls.clone(simulator_folder, config["package_name"], app_executable=config["simulation"]["task"].get("kwargs", {}).get("unity_executable", None))
- simulation = task_cls.initialise(config["simulation"]["task"].get("kwargs", {}))
-
- # Set some compiler options
- # TODO: would make more sense to have a separate "environment" class / xml file that defines all these defaults,
- # including e.g. cameras, lighting, etc., so that they could be easily changed. Task and biomechanical model would
- # be integrated into that object
- compiler_defaults = {"inertiafromgeom": "auto", "balanceinertia": "true", "boundmass": "0.001",
- "boundinertia": "0.001", "inertiagrouprange": "0 1"}
- compiler = simulation.find("compiler")
- if compiler is None:
- ET.SubElement(simulation, "compiler", compiler_defaults)
- else:
- compiler.attrib.update(compiler_defaults)
-
- # Load biomechanical model class
- bm_cls = cls.get_class("bm_models", config["simulation"]["bm_model"]["cls"])
- bm_cls.clone(simulator_folder, config["package_name"])
- bm_cls.insert(simulation)
-
- # Add perception modules
- for module_cfg in config["simulation"].get("perception_modules", []):
- module_cls = cls.get_class("perception", module_cfg["cls"])
- module_kwargs = module_cfg.get("kwargs", {})
- module_cls.clone(simulator_folder, config["package_name"])
- module_cls.insert(simulation, **module_kwargs)
-
- # Clone also RL library files so the package will be completely standalone
- rl_cls = cls.get_class("rl", config["rl"]["algorithm"])
- rl_cls.clone(simulator_folder, config["package_name"])
-
- # TODO read the xml file directly from task.getroot() instead of writing it to a file first; need to input a dict
- # of assets to mujoco.MjModel.from_xml_path
- simulation_file = os.path.join(simulator_folder, config["package_name"], "simulation")
- with open(simulation_file+".xml", 'w') as file:
- simulation.write(file, encoding='unicode')
-
- # Initialise the simulator
- model, _, _, _, _, _ = \
- cls._initialise(config, simulator_folder, {**run_parameters, "build": True})
-
- # Now that simulator has been initialised, everything should be set. Now we want to save the xml file again, but
- # mujoco only is able to save the latest loaded xml file (which is either the task or bm model xml files which are
- # are read in their __init__ functions), hence we need to read the file we've generated again before saving the
- # modified model
- mujoco.MjModel.from_xml_path(simulation_file+".xml")
- mujoco.mj_saveLastXML(simulation_file+".xml", model)
-
- # Save the modified model also as binary for faster loading
- mujoco.mj_saveModel(model, simulation_file+".mjcf", None)
-
- # Input built time into config
- config["built"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
- # Save config
- write_yaml(config, os.path.join(simulator_folder, "config.yaml"))
-
- return simulator_folder
-
- @classmethod
- def _clone(cls, simulator_folder, package_name):
- """ Create a folder for the simulator being built, and copy or create relevant files.
-
- Args:
- simulator_folder: Location of the simulator.
- package_name: Name of the simulator (which is a python package).
- """
-
- # Create the folder
- dst = os.path.join(simulator_folder, package_name)
- os.makedirs(dst, exist_ok=True)
-
- # Copy simulator
- src = pathlib.Path(inspect.getfile(cls))
- shutil.copyfile(src, os.path.join(dst, src.name))
-
- # Create __init__.py with env registration
- with open(os.path.join(dst, "__init__.py"), "w") as file:
- file.write("from .simulator import Simulator\n\n")
- file.write("from gymnasium.envs.registration import register\n")
- file.write("import pathlib\n\n")
- file.write("module_folder = pathlib.Path(__file__).parent\n")
- file.write("simulator_folder = module_folder.parent\n")
- file.write("kwargs = {'simulator_folder': simulator_folder}\n")
- file.write("register(id=f'{module_folder.stem}-v0', entry_point=f'{module_folder.stem}.simulator:Simulator', kwargs=kwargs)\n")
-
- # Copy utils
- shutil.copytree(os.path.join(parent_path(src), "utils"), os.path.join(simulator_folder, package_name, "utils"),
- dirs_exist_ok=True)
- # Copy train
- shutil.copytree(os.path.join(parent_path(src), "train"), os.path.join(simulator_folder, package_name, "train"),
- dirs_exist_ok=True)
- # Copy test
- shutil.copytree(os.path.join(parent_path(src), "test"), os.path.join(simulator_folder, package_name, "test"),
- dirs_exist_ok=True)
-
- @classmethod
- def _initialise(cls, config, simulator_folder, run_parameters):
- """ Initialise a simulator -- i.e., create a MjModel, MjData, and initialise all necessary variables.
-
- Args:
- config: A config dict.
- simulator_folder: Location of the simulator.
- run_parameters: Important run time variables that may also be used to override parameters.
- """
-
- # Get task class and kwargs
- task_cls = cls.get_class("tasks", config["simulation"]["task"]["cls"])
- task_kwargs = config["simulation"]["task"].get("kwargs", {})
-
- # Get bm class and kwargs
- bm_cls = cls.get_class("bm_models", config["simulation"]["bm_model"]["cls"])
- bm_kwargs = config["simulation"]["bm_model"].get("kwargs", {})
-
- # Initialise perception modules
- perception_modules = {}
- for module_cfg in config["simulation"].get("perception_modules", []):
- module_cls = cls.get_class("perception", module_cfg["cls"])
- module_kwargs = module_cfg.get("kwargs", {})
- perception_modules[module_cls] = module_kwargs
-
- # Get simulation file
- simulation_file = os.path.join(simulator_folder, config["package_name"], "simulation")
-
- # Load the mujoco model; try first with the binary model (faster, contains some parameters that may be lost when
- # re-saving xml files like body mass). For some reason the binary model fails to load in some situations (like
- # when the simulator has been built on a different computer)
- # TODO loading from binary disabled, weird problems (like a body not found from model when loaded from binary, but
- # found correctly when model loaded from xml)
- # try:
- # model = mujoco.MjModel.from_binary_path(simulation_file + ".mjcf")
- # except: # TODO what was the exception type
- model = mujoco.MjModel.from_xml_path(simulation_file + ".xml")
-
- # Initialise MjData
- data = mujoco.MjData(model)
-
- # Add frame skip and dt to run parameters
- run_parameters["frame_skip"] = int(1 / (model.opt.timestep * run_parameters["action_sample_freq"]))
- run_parameters["dt"] = model.opt.timestep*run_parameters["frame_skip"]
-
- # Initialise a rendering context, required for e.g. some vision modules
- run_parameters["rendering_context"] = Context(model,
- max_resolution=run_parameters.get("max_resolution", [1280, 960]))
-
- # Initialise callbacks
- callbacks = {}
- for cb in run_parameters.get("callbacks", []):
- callbacks[cb["name"]] = cls.get_class(cb["cls"])(cb["name"], **cb["kwargs"])
-
- # Now initialise the actual classes; model and data are input to the inits so that stuff can be modified if needed
- # (e.g. move target to a specific position wrt to a body part)
- task = task_cls(model, data, **{**task_kwargs, **callbacks, **run_parameters})
- bm_model = bm_cls(model, data, **{**bm_kwargs, **callbacks, **run_parameters})
- perception = Perception(model, data, bm_model, perception_modules, {**callbacks, **run_parameters})
-
- return model, data, task, bm_model, perception, callbacks
-
- @classmethod
- def get(cls, simulator_folder, render_mode="rgb_array", render_mode_perception="embed", render_show_depths=False, run_parameters=None, use_cloned=True):
- """ Returns a Simulator that is located in given folder.
-
- Args:
- simulator_folder: Location of the simulator.
- render_mode: Whether render() will return a single rgb array (render_mode="rgb_array"),
- a list of rgb arrays (render_mode="rgb_array_list";
- adapted from https://github.com/openai/gym/blob/master/gym/wrappers/render_collection.py),
- or None while the frames in a separate PyGame window are updated directly when calling
- step() or reset() (render_mode="human";
- adapted from https://github.com/openai/gym/blob/master/gym/wrappers/human_rendering.py)).
- render_mode_perception: Whether images of visual perception modules should be directly embedded into main camera view ("embed"), stored as separate videos ("separate"), or not used at all [which allows to watch vision in Unity Editor if debug mode is enabled/standalone app is disabled] (None)
- render_show_depths: Whether depth images of visual perception modules should be included in rendering.
- run_parameters: Can be used to override parameters during run time.
- use_cloned: Can be useful for debugging. Set to False to use original files instead of the ones that have been
- cloned/copied during building phase.
- """
-
- # Read config file
- config_file = os.path.join(simulator_folder, "config.yaml")
- try:
- config = parse_yaml(config_file)
- except:
- raise FileNotFoundError(f"Could not open file {config_file}")
-
- # Make sure the simulator has been built
- if "built" not in config:
- raise RuntimeError("Simulator has not been built")
-
- # Make sure simulator_folder is in path (used to import gen_cls_cloned)
- if simulator_folder not in sys.path:
- sys.path.insert(0, simulator_folder)
-
- # Get Simulator class
- gen_cls_cloned = getattr(importlib.import_module(config["package_name"]), "Simulator")
- if hasattr(gen_cls_cloned, "version"):
- _legacy_mode = False
- gen_cls_cloned_version = gen_cls_cloned.version.split("-v")[-1]
- else:
- _legacy_mode = True
- gen_cls_cloned_version = gen_cls_cloned.id.split("-v")[-1] #deprecated
- if use_cloned:
- gen_cls = gen_cls_cloned
- else:
- gen_cls = cls
- gen_cls_version = gen_cls.version.split("-v")[-1]
-
- if gen_cls_version.split(".")[0] > gen_cls_cloned_version.split(".")[0]:
- raise RuntimeError(
- f"""Severe version mismatch. The simulator '{config["simulator_name"]}' has version {gen_cls_cloned_version}, while your uitb package has version {gen_cls_version}.\nTo run with version {gen_cls_cloned_version}, set 'use_cloned=True'.""")
- elif gen_cls_version.split(".")[1] > gen_cls_cloned_version.split(".")[1]:
- print(
- f"""WARNING: Version mismatch. The simulator '{config["simulator_name"]}' has version {gen_cls_cloned_version}, while your uitb package has version {gen_cls_version}.\nTo run with version {gen_cls_version}, set 'use_cloned=True'.""")
-
- if _legacy_mode:
- _simulator = gen_cls(simulator_folder, run_parameters=run_parameters)
- else:
- try:
- _simulator = gen_cls(simulator_folder, render_mode=render_mode, render_mode_perception=render_mode_perception, render_show_depths=render_show_depths,
- run_parameters=run_parameters)
- except TypeError:
- _simulator = gen_cls(simulator_folder, render_mode=render_mode, render_show_depths=render_show_depths,
- run_parameters=run_parameters)
-
- # Return Simulator object
- return _simulator
-
- def __init__(self, simulator_folder, render_mode="rgb_array", render_mode_perception="embed", render_show_depths=False, run_parameters=None):
- """ Initialise a new `Simulator`.
-
- Args:
- simulator_folder: Location of a simulator.
- render_mode: Whether render() will return a single rgb array (render_mode="rgb_array"),
- a list of rgb arrays (render_mode="rgb_array_list";
- adapted from https://github.com/openai/gym/blob/master/gym/wrappers/render_collection.py),
- or None while the frames in a separate PyGame window are updated directly when calling
- step() or reset() (render_mode="human";
- adapted from https://github.com/openai/gym/blob/master/gym/wrappers/human_rendering.py)).
- render_mode_perception: Whether images of visual perception modules should be directly embedded into main camera view ("embed"), stored as separate videos ("separate"), or not used at all [which allows to watch vision in Unity Editor if debug mode is enabled/standalone app is disabled] (None)
- render_show_depths: Whether depth images of visual perception modules should be included in rendering.
- run_parameters: Can be used to override parameters during run time.
- """
-
- # Make sure simulator exists
- if not os.path.exists(simulator_folder):
- raise FileNotFoundError(f"Simulator folder {simulator_folder} does not exists")
- self._simulator_folder = simulator_folder
-
- # Read config
- self._config = parse_yaml(os.path.join(self._simulator_folder, "config.yaml"))
-
- # Get run parameters: these parameters can be used to override parameters used during training
- self._run_parameters = self._config["simulation"]["run_parameters"].copy()
- self._run_parameters.update(run_parameters or {})
-
- # Initialise simulation
- self._model, self._data, self.task, self.bm_model, self.perception, self.callbacks = \
- self._initialise(self._config, self._simulator_folder, self._run_parameters)
-
- # Set action space TODO for now we assume all actuators have control signals between [-1, 1]
- self.action_space = self._initialise_action_space()
-
- # Set observation space
- self.observation_space = self._initialise_observation_space()
-
- # Collect some episode statistics
- self._episode_statistics = {"length (seconds)": 0, "length (steps)": 0, "reward": 0}
-
- # Initialise viewer
- self._GUI_camera = Camera(self._run_parameters["rendering_context"], self._model, self._data, camera_id='for_testing',
- dt=self._run_parameters["dt"])
-
- self._render_mode = render_mode
- self._render_mode_perception = render_mode_perception #whether perception camera views should be directly embedded into camera view of camera_id ("embed"), stored in self._render_stack_perception ("separate"), or not used at all "separate"), or not used at all [which allows to watch vision in Unity Editor if debug mode is enabled/standalone app is disabled] (None)
- self._render_stack = [] #only used if render_mode == "rgb_array_list"
- self._render_stack_perception = defaultdict(list) #only used if render_mode == "rgb_array_list" and self._render_mode_perception == "separate"
- self._render_stack_pop = True #If True, clear the render stack after .render() is called.
- self._render_stack_clean_at_reset = True #If True, clear the render stack when .reset() is called.
- self._render_show_depths = render_show_depths #If True, depth images of visual perception modules are included in GUI rendering.
- self._render_screen_size = None #only used if render_mode == "human"
- self._render_window = None #only used if render_mode == "human"
- self._render_clock = None #only used if render_mode == "human"
-
- def _initialise_action_space(self):
- """ Initialise action space. """
- num_actuators = self.bm_model.nu + self.perception.nu
- actuator_limits = np.ones((num_actuators,2)) * np.array([-1.0, 1.0])
- return spaces.Box(low=np.float32(actuator_limits[:, 0]), high=np.float32(actuator_limits[:, 1]))
-
- def _initialise_observation_space(self):
- """ Initialise observation space. """
- observation = self.get_observation()
- obs_dict = dict()
- for module in self.perception.perception_modules:
- obs_dict[module.modality] = spaces.Box(dtype=np.float32, **module.get_observation_space_params())
- if "stateful_information" in observation:
- obs_dict["stateful_information"] = spaces.Box(dtype=np.float32,
- **self.task.get_stateful_information_space_params())
-
- return spaces.Dict(obs_dict)
-
- def step(self, action):
- """ Step simulation forward with given actions.
-
- Args:
- action: Actions sampled from a policy. Limited to range [-1, 1].
- """
-
- # Set control for the bm model
- self.bm_model.set_ctrl(self._model, self._data, action[:self.bm_model.nu])
-
- # Set control for perception modules (e.g. eye movements)
- self.perception.set_ctrl(self._model, self._data, action[self.bm_model.nu:])
-
- # Advance the simulation
- mujoco.mj_step(self._model, self._data, nstep=self._run_parameters["frame_skip"])
-
- # Update bm model (e.g. update constraints); updates also effort model
- self.bm_model.update(self._model, self._data)
-
- # Update perception modules
- self.perception.update(self._model, self._data)
-
- # Update environment
- reward, terminated, truncated, info = self.task.update(self._model, self._data)
-
- # Add an effort cost to reward
- effort_cost = self.bm_model.get_effort_cost(self._model, self._data)
- info["EffortCost"] = effort_cost
- reward -= effort_cost
-
- # Get observation
- obs = self.get_observation(info)
-
- # Add frame to stack
- if self._render_mode == "rgb_array_list":
- self._render_stack.append(self._GUI_rendering())
- elif self._render_mode == "human":
- self._GUI_rendering_pygame()
-
- return obs, reward, terminated, truncated, info
-
- def get_observation(self, info=None):
- """ Returns an observation from the perception model.
-
- Returns:
- A dict with observations from individual perception modules. May also contain stateful information from a task.
+ 核心仿真器类,继承自gym.Env以兼容强化学习生态
+ 核心功能:
+ 1. 从YAML配置文件构建独立的Python仿真包
+ 2. 集成生物力学模型(BM)、任务模型(Task)、感知模型(Perception)
+ 3. 实现标准的gym接口(step/reset/render),支持RL训练与可视化
"""
- # Get observation from perception
- observation = self.perception.get_observation(self._model, self._data, info)
-
- # Add any stateful information that is required
- stateful_information = self.task.get_stateful_information(self._model, self._data)
- if stateful_information.size > 0: #TODO: define stateful_information (and encoder) that can be used as default, if no stateful information is provided (zero-size arrays do not work with sb3 currently...)
- observation["stateful_information"] = stateful_information
-
- return observation
-
- def reset(self, seed=None):
- """ Reset the simulator and return an observation. """
-
- super().reset(seed=seed)
-
- # Reset sim
- mujoco.mj_resetData(self._model, self._data)
-
- # Reset all models
- self.bm_model.reset(self._model, self._data)
- self.perception.reset(self._model, self._data)
- info = self.task.reset(self._model, self._data)
-
- # Do a forward so everything will be set
- mujoco.mj_forward(self._model, self._data)
-
- if self._render_mode == "rgb_array_list":
- if self._render_stack_clean_at_reset:
- self._render_stack = []
- self._render_stack_perception = defaultdict(list)
- self._render_stack.append(self._GUI_rendering())
- elif self._render_mode == "human":
- self._GUI_rendering_pygame()
-
- return self.get_observation(), info
-
- def render(self):
- if self._render_mode == "rgb_array_list":
- render_stack = self._render_stack
- if self._render_stack_pop:
- self._render_stack = []
- return render_stack
- elif self._render_mode == "rgb_array":
- return self._GUI_rendering()
- else:
- return None
-
- def get_render_stack_perception(self):
- render_stack_perception = self._render_stack_perception
- # if self._render_stack_pop:
- # self._render_stack_perception = defaultdict(list)
- return render_stack_perception
-
- def _GUI_rendering(self):
- # Grab an image from the 'for_testing' camera and grab all GUI-prepared images from included visual perception modules, and display them 'picture-in-picture'
-
- # Grab images
- img, _ = self._GUI_camera.render()
-
- if self._render_mode_perception == "embed":
- # Embed perception camera images into main camera image
-
- perception_camera_images = [rgb_or_depth_array for camera in self.perception.cameras
- for rgb_or_depth_array in camera.render() if rgb_or_depth_array is not None]
-
- # TODO: add text annotations to perception camera images
- if len(perception_camera_images) > 0:
- _img_size = img.shape[:2] #(height, width)
-
- # Vertical alignment of perception camera images, from bottom right to top right
- ## TODO: allow for different inset locations
- _desired_subwindow_height = np.round(_img_size[0] / len(perception_camera_images)).astype(int)
- _maximum_subwindow_width = np.round(0.2 * _img_size[1]).astype(int)
-
- perception_camera_images_resampled = []
- for ocular_img in perception_camera_images:
- # Convert 2D depth arrays to 3D heatmap arrays
- if ocular_img.ndim == 2:
- if self._render_show_depths:
- ocular_img = matplotlib.pyplot.imshow(ocular_img, cmap=matplotlib.pyplot.cm.jet, interpolation='bicubic').make_image('TkAgg', unsampled=True)[0][
- ..., :3]
- matplotlib.pyplot.close() #delete image
+ # 版本号:遵循X.Y.Z格式(主版本.次版本.修订版),用于版本兼容性校验
+ version = "1.1.0"
+
+ @classmethod
+ def get_class(cls, *args):
+ """
+ 动态导入指定类(反射机制)
+ 用途:根据配置文件中的字符串路径加载模块类(如BM模型/任务模型/感知模型)
+ Args:
+ *args: 类的路径片段,最后一个元素为类名,其余为模块路径
+ 示例:args=("tasks", "reach_task.ReachTask") → 加载tasks模块下的ReachTask类
+ Returns:
+ class: 导入的目标类
+ """
+ # 拼接模块路径(排除最后一个元素<类名>)
+ modules = ".".join(args[:-1])
+ # 处理类名带模块路径的情况(如 "rl.encoders.SmallCNN")
+ if "." in args[-1]:
+ splitted = args[-1].split(".")
+ if modules == "":
+ modules = ".".join(splitted[:-1])
else:
- continue
-
- resample_factor = min(_desired_subwindow_height / ocular_img.shape[0], _maximum_subwindow_width / ocular_img.shape[1])
-
- resample_height = np.round(ocular_img.shape[0] * resample_factor).astype(int)
- resample_width = np.round(ocular_img.shape[1] * resample_factor).astype(int)
- resampled_img = np.zeros((resample_height, resample_width, ocular_img.shape[2]), dtype=np.uint8)
- for channel in range(ocular_img.shape[2]):
- resampled_img[:, :, channel] = scipy.ndimage.zoom(ocular_img[:, :, channel], resample_factor, order=0)
-
- perception_camera_images_resampled.append(resampled_img)
-
- ocular_img_bottom = _img_size[0]
- for ocular_img_idx, ocular_img in enumerate(perception_camera_images_resampled):
- #print(f"Modify ({ocular_img_bottom - ocular_img.shape[0]}, { _img_size[1] - ocular_img.shape[1]})-({ocular_img_bottom}, {img.shape[1]}).")
- img[ocular_img_bottom - ocular_img.shape[0]:ocular_img_bottom, _img_size[1] - ocular_img.shape[1]:] = ocular_img
- ocular_img_bottom -= ocular_img.shape[0]
- # input((len(perception_camera_images_resampled), perception_camera_images_resampled[0].shape, img.shape))
- elif self._render_mode_perception == "separate":
- for module, camera_list in self.perception.cameras_dict.items():
- for camera in camera_list:
- for rgb_or_depth_array in camera.render():
- if rgb_or_depth_array is not None:
- self._render_stack_perception[f"{module.modality}/{type(camera).__name__}"].append(rgb_or_depth_array)
-
- return img
-
- def _GUI_rendering_pygame(self):
- rgb_array = np.transpose(self._GUI_rendering(), axes=(1, 0, 2))
-
- if self._render_screen_size is None:
- self._render_screen_size = rgb_array.shape[:2]
-
- assert self._render_screen_size == rgb_array.shape[
- :2], f"Expected an rgb array of shape {self._render_screen_size} from self._GUI_camera, but received an rgb array of shape {rgb_array.shape[:2]}. "
-
- if self._render_window is None:
- pygame.init()
- pygame.display.init()
- self._render_window = pygame.display.set_mode(self._render_screen_size)
-
- if self._render_clock is None:
- self._render_clock = pygame.time.Clock()
-
- surf = pygame.surfarray.make_surface(rgb_array)
- self._render_window.blit(surf, (0, 0))
- pygame.event.pump()
- self._render_clock.tick(self.fps)
- pygame.display.flip()
-
- def close(self):
- """ Close the rendering window (if self._render_mode == 'human')."""
- super().close()
- if self._render_window is not None:
- import pygame
-
- pygame.display.quit()
- pygame.quit()
-
- @property
- def fps(self):
- return self._GUI_camera._fps
-
- def callback(self, callback_name, num_timesteps):
- """ Update a callback -- may be useful during training, e.g. for curriculum learning. """
- self.callbacks[callback_name].update(num_timesteps)
-
- def update_callbacks(self, num_timesteps):
- """ Update all callbacks. """
- for callback_name in self.callbacks:
- self.callback(callback_name, num_timesteps)
-
- # def get_logdict_keys(self):
- # return list(self.task._info["log_dict"].keys())
-
- # def get_logdict_value(self, key):
- # return self.task._info["log_dict"].get(key)
-
- @property
- def config(self):
- """ Return config. """
- return copy.deepcopy(self._config)
-
- @property
- def run_parameters(self):
- """ Return run parameters. """
- # Context cannot be deep copied
- exclude = {"rendering_context"}
- run_params = {k: copy.deepcopy(self._run_parameters[k]) for k in self._run_parameters.keys() - exclude}
- run_params["rendering_context"] = self._run_parameters["rendering_context"]
- return run_params
-
- @property
- def simulator_folder(self):
- """ Return simulator folder. """
- return self._simulator_folder
-
- @property
- def render_mode(self):
- """ Return render mode. """
- return self._render_mode
-
- def get_state(self):
- """ Return a state of the simulator / individual components (biomechanical model, perception model, task).
-
- This function is used for logging/evaluation purposes, not for RL training.
-
- Returns:
- A dict with one float or numpy vector per keyword.
- """
-
- # Get time, qpos, qvel, qacc, act_force, act, ctrl of the current simulation
- state = {"timestep": self._data.time,
- "qpos": self._data.qpos.copy(),
- "qvel": self._data.qvel.copy(),
- "qacc": self._data.qacc.copy(),
- "act_force": self._data.actuator_force.copy(),
- "act": self._data.act.copy(),
- "ctrl": self._data.ctrl.copy()}
-
- # Add state from the task
- state.update(self.task.get_state(self._model, self._data))
-
- # Add state from the biomechanical model
- state.update(self.bm_model.get_state(self._model, self._data))
-
- # Add state from the perception model
- state.update(self.perception.get_state(self._model, self._data))
-
- return state
-
- def close(self, **kwargs):
- """ Perform any necessary clean up.
-
- This function is inherited from gym.Env. It should be automatically called when this object is garbage collected
- or the program exists, but that doesn't seem to be the case. This function will be called if this object has been
- initialised in the context manager fashion (i.e. using the 'with' statement). """
- self.task.close(**kwargs)
- self.perception.close(**kwargs)
- self.bm_model.close(**kwargs)
\ No newline at end of file
+ modules += "." + ".".join(splitted[:-1])
+ cls_name = splitted[-1] # 提取最终类名
+ else:
+ cls_name = args[-1]
+
+ # 导入模块并获取类
+ module = cls.get_module(modules)
+ return getattr(module, cls_name)
+
+ @classmethod
+ def get_module(cls, *args):
+ """
+ 动态导入指定模块(辅助get_class)
+ Args:
+ *args: 模块路径片段,示例:args=("bm_models", "human_arm") → 导入bm_models.human_arm模块
+ Returns:
+ module: 导入的模块对象
+ """
+ # src为根模块名(如uitb),拼接完整模块路径
+ src = __name__.split(".")[0]
+ modules = ".".join(args)
+ return importlib.import_module(src + "." + modules)
+
+ @classmethod
+ def build(cls, config):
+ """
+ 核心构建方法:从配置文件生成独立的仿真包
+ Args:
+ config: 配置信息,支持两种格式:
+ 1. str: YAML配置文件路径
+ 2. dict: 解析后的配置字典(含仿真/模型/感知等配置)
+ Returns:
+ str: 生成的仿真包文件夹路径
+ Raises:
+ FileNotFoundError: 配置文件不存在
+ AssertionError: 配置缺少必填项(simulation/bm_model/task/run_parameters等)
+ NameError: 包名不合法(含大写/空格/数字开头等)
+ """
+ # 第一步:解析配置文件(若输入为路径)
+ if isinstance(config, str):
+ if not os.path.isfile(config):
+ raise FileNotFoundError(f"配置文件不存在: {config}")
+ config = parse_yaml(config) # 解析YAML为字典
+
+ # 第二步:校验配置必填项
+ assert "simulation" in config, "配置必须包含simulation字段"
+ assert "bm_model" in config["simulation"], "配置必须指定生物力学模型(bm_model)"
+ assert "task" in config["simulation"], "配置必须指定任务模型(task)"
+ assert "run_parameters" in config["simulation"], "配置必须指定运行参数(run_parameters)"
+
+ # 提取运行参数并校验动作采样频率
+ run_parameters = config["simulation"]["run_parameters"].copy()
+ assert "action_sample_freq" in run_parameters, "运行参数必须指定动作采样频率(action_sample_freq)"
+
+ # 第三步:设置仿真包基础信息
+ config["version"] = cls.version # 记录仿真器版本
+ # 确定仿真包保存路径(优先用配置中的simulator_folder,否则默认到uitb/simulators)
+ if "simulator_folder" in config:
+ simulator_folder = os.path.normpath(config["simulator_folder"])
+ else:
+ simulator_folder = os.path.join(output_path(), config["simulator_name"])
+
+ # 处理包名(默认用simulator_name,校验合法性)
+ if "package_name" not in config:
+ config["package_name"] = config["simulator_name"]
+ if not is_suitable_package_name(config["package_name"]):
+ raise NameError(
+ "包名不合法!仅允许小写字母、下划线,且不能以数字开头\n"
+ "请检查配置中的package_name/simulator_name字段"
+ )
+
+ # 生成gym环境名(格式:uitb:<包名>-v0)
+ config["gym_name"] = "uitb:" + config["package_name"] + "-v0"
+
+ # 第四步:克隆核心文件到仿真包(创建独立包结构)
+ cls._clone(simulator_folder, config["package_name"])
+
+ # 第五步:加载并初始化任务模型
+ task_cls = cls.get_class("tasks", config["simulation"]["task"]["cls"])
+ # 克隆任务模型文件到仿真包(支持Unity可执行文件路径传递)
+ task_cls.clone(
+ simulator_folder,
+ config["package_name"],
+ app_executable=config["simulation"]["task"].get("kwargs", {}).get("unity_executable", None)
+ )
+ # 初始化任务模型,返回MuJoCo的XML根节点(simulation)
+ simulation = task_cls.initialise(config["simulation"]["task"].get("kwargs", {}))
+
+ # 第六步:设置MuJoCo编译器默认参数(物理属性相关)
+ compiler_defaults = {
+ "inertiafromgeom": "auto", # 从几何形状自动计算惯性
+ "balanceinertia": "true", # 平衡惯性张量
+ "boundmass": "0.001", # 质量下界
+ "boundinertia": "0.001", # 惯性下界
+ "inertiagrouprange": "0 1" # 惯性组范围
+ }
+ compiler = simulation.find("compiler")
+ if compiler is None:
+ # 无compiler节点则新建
+ ET.SubElement(simulation, "compiler", compiler_defaults)
+ else:
+ # 已有则更新属性
+ compiler.attrib.update(compiler_defaults)
+
+ # 第七步:加载并插入生物力学模型到XML
+ bm_cls = cls.get_class("bm_models", config["simulation"]["bm_model"]["cls"])
+ bm_cls.clone(simulator_folder, config["package_name"]) # 克隆BM模型文件
+ bm_cls.insert(simulation) # 将BM模型(如人体上肢)插入到MuJoCo XML
+
+ # 第八步:加载并插入感知模块(如FixedEye/UnityHeadset)到XML
+ for module_cfg in config["simulation"].get("perception_modules", []):
+ module_cls = cls.get_class("perception", module_cfg["cls"])
+ module_kwargs = module_cfg.get("kwargs", {})
+ module_cls.clone(simulator_folder, config["package_name"]) # 克隆感知模块文件
+ module_cls.insert(simulation, **module_kwargs) # 插入到XML(如创建相机/传感器)
+
+ # 第九步:克隆RL相关文件(编码器/策略等),使仿真包完全独立
+ rl_cls = cls.get_class("rl", config["rl"]["algorithm"])
+ rl_cls.clone(simulator_folder, config["package_name"])
+
+ # 第十步:保存MuJoCo XML文件(物理仿真核心配置)
+ simulation_file = os.path.join(simulator_folder, config["package_name"], "simulation")
+ with open(simulation_file + ".xml", 'w') as file:
+ simulation.write(file, encoding='unicode') # 写入XML内容
+
+ # 第十一步:初始化仿真器,生成二进制模型(加快后续加载)
+ model, _, _, _, _, _ = cls._initialise(config, simulator_folder, {**run_parameters, "build": True})
+ # 重新加载XML并保存修改后的版本(MuJoCo要求先加载才能保存)
+ mujoco.MjModel.from_xml_path(simulation_file + ".xml")
+ mujoco.mj_saveLastXML(simulation_file + ".xml", model)
+ # 保存二进制模型(.mjcf),加载速度比XML快
+ mujoco.mj_saveModel(model, simulation_file + ".mjcf", None)
+
+ # 第十二步:记录构建时间并保存最终配置
+ config["built"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ write_yaml(config, os.path.join(simulator_folder, "config.yaml"))
+
+ return simulator_folder
+
+ @classmethod
+ def _clone(cls, simulator_folder, package_name):
+ """
+ 内部方法:克隆核心文件到新仿真包,创建独立的Python包结构
+ Args:
+ simulator_folder: 仿真包根路径
+ package_name: 包名(作为子文件夹名)
+ """
+ # 创建包文件夹(如simulators/reach_task/)
+ dst = os.path.join(simulator_folder, package_name)
+ os.makedirs(dst, exist_ok=True)
+
+ # 1. 复制当前Simulator类文件到包中
+ src = pathlib.Path(inspect.getfile(cls)) # 获取当前文件路径
+ shutil.copyfile(src, os.path.join(dst, src.name))
+
+ # 2. 创建__init__.py(使文件夹成为Python包,注册gym环境)
+ with open(os.path.join(dst, "__init__.py"), "w") as file:
+ file.write("from .simulator import Simulator\n\n")
+ file.write("from gymnasium.envs.registration import register\n")
+ file.write("import pathlib\n\n")
+ file.write("module_folder = pathlib.Path(__file__).parent\n")
+ file.write("simulator_folder = module_folder.parent\n")
+ file.write("kwargs = {'simulator_folder': simulator_folder}\n")
+ # 注册gym环境,使gym.make("uitb:<包名>-v0")可调用
+ file.write("register(id=f'{module_folder.stem}-v0', entry_point=f'{module_folder.stem}.simulator:Simulator', kwargs=kwargs)\n")
+
+ # 3. 复制工具文件夹(utils/train/test),保证包独立性
+ # 复制utils(渲染/路径等工具)
+ shutil.copytree(
+ os.path.join(parent_path(src), "utils"),
+ os.path.join(simulator_folder, package_name, "utils"),
+ dirs_exist_ok=True # 已存在则覆盖
+ )
+ # 复制train(训练相关代码)
+ shutil.copytree(
+ os.path.join(parent_path(src), "train"),
+ os.path.join(simulator_folder, package_name, "train"),
+ dirs_exist_ok=True
+ )
+ # 复制test(测试相关代码)
+ shutil.copytree(
+ os.path.join(parent_path(src), "test"),
+ os.path.join(simulator_folder, package_name, "test"),
+ dirs_exist_ok=True
+ )
+
+ @classmethod
+ def _initialise(cls, config, simulator_folder, run_parameters):
+ """
+ 内部方法:初始化仿真核心组件(MuJoCo模型/数据、各模块实例)
+ Args:
+ config: 配置字典
+ simulator_folder: 仿真包路径
+ run_parameters: 运行参数(含动作采样频率、渲染分辨率等)
+ Returns:
+ tuple: (model, data, task, bm_model, perception, callbacks)
+ - model: MuJoCo MjModel对象(物理模型)
+ - data: MuJoCo MjData对象(仿真数据/状态)
+ - task: 任务模型实例(如ReachTask)
+ - bm_model: 生物力学模型实例(如HumanArm)
+ - perception: 感知模块管理器实例
+ - callbacks: 回调函数字典(如课程学习)
+ """
+ # 1. 加载任务模型类并初始化参数
+ task_cls = cls.get_class("tasks", config["simulation"]["task"]["cls"])
+ task_kwargs = config["simulation"]["task"].get("kwargs", {})
+
+ # 2. 加载生物力学模型类并初始化参数
+ bm_cls = cls.get_class("bm_models", config["simulation"]["bm_model"]["cls"])
+ bm_kwargs = config["simulation"]["bm_model"].get("kwargs", {})
+
+ # 3. 初始化感知模块配置(存储{模块类: 模块参数})
+ perception_modules = {}
+ for module_cfg in config["simulation"].get("perception_modules", []):
+ module_cls = cls.get_class("perception", module_cfg["cls"])
+ module_kwargs = module_cfg.get("kwargs", {})
+ perception_modules[module_cls] = module_kwargs
+
+ # 4. 加载MuJoCo模型(优先XML,二进制模型存在兼容性问题)
+ simulation_file = os.path.join(simulator_folder, config["package_name"], "simulation")
+ # 注释:二进制模型加载更快,但跨机器易出错,暂时禁用
+ # try:
+ # model = mujoco.MjModel.from_binary_path(simulation_file + ".mjcf")
+ # except:
+ model = mujoco.MjModel.from_xml_path(simulation_file + ".xml")
+
+ # 5. 初始化MuJoCo数据(存储仿真状态:位置/速度/力等)
+ data = mujoco.MjData(model)
+
+ # 6. 计算帧跳过数(frame_skip)和仿真步长(dt)
+ # frame_skip:每次step调用多少次mj_step(平衡仿真精度与速度)
+ run_parameters["frame_skip"] = int(1 / (model.opt.timestep * run_parameters["action_sample_freq"]))
+ # dt:RL step的实际时间长度(= frame_skip * MuJoCo步长)
+ run_parameters["dt"] = model.opt.timestep * run_parameters["frame_skip"]
+
+ # 7. 初始化渲染上下文(为视觉模块提供渲染环境)
+ run_parameters["rendering_context"] = Context(
+ model,
+ max_resolution=run_parameters.get("max_resolution", [1280, 960]) # 最大渲染分辨率
+ )
+
+ # 8. 初始化回调函数(如课程学习:逐步增加任务难度)
+ callbacks = {}
+ for cb in run_parameters.get("callbacks", []):
+ callbacks[cb["name"]] = cls.get_class(cb["cls"])(cb["name"], **cb["kwargs"])
+
+ # 9. 实例化核心模块(合并参数:任务参数+回调+运行参数)
+ task = task_cls(model, data, **{**task_kwargs, **callbacks, **run_parameters})
+ bm_model = bm_cls(model, data, **{**bm_kwargs, **callbacks, **run_parameters})
+ # 初始化感知模块管理器(统一管理所有感知组件)
+ perception = Perception(model, data, bm_model, perception_modules, {**callbacks, **run_parameters})
+
+ return model, data, task, bm_model, perception, callbacks
+
+ @classmethod
+ def get(cls, simulator_folder, render_mode="rgb_array", render_mode_perception="embed", render_show_depths=False, run_parameters=None, use_cloned=True):
+ """
+ 获取已构建的仿真器实例(核心入口方法)
+ Args:
+ simulator_folder: 仿真包路径
+ render_mode: 渲染模式:
+ - "rgb_array": 返回RGB数组
+ - "rgb_array_list": 返回RGB数组列表
+ - "human": 弹出Pygame窗口实时显示
+ render_mode_perception: 感知模块画面展示方式:
+ - "embed": 嵌入主画面(画中画)
+ - "separate": 单独存储
+ - None: 不展示(用于Unity编辑器调试)
+ render_show_depths: 是否显示深度图(转为热力图)
+ run_parameters: 运行时覆盖的参数(优先级高于配置文件)
+ use_cloned: 是否使用克隆后的包文件(True=用仿真包内的文件,False=用原文件,调试用)
+ Returns:
+ Simulator: 仿真器实例
+ Raises:
+ FileNotFoundError: 配置文件不存在
+ RuntimeError: 仿真包未构建(无built字段)
+ RuntimeError: 版本不兼容(主版本不一致)
+ """
+ # 1. 加载仿真包配置
+ config_file = os.path.join(simulator_folder, "config.yaml")
+ try:
+ config = parse_yaml(config_file)
+ except:
+ raise FileNotFoundError(f"无法打开配置文件: {config_file}")
+
+ # 2. 校验仿真包是否已构建
+ if "built" not in config:
+ raise RuntimeError("仿真包未构建!请先调用Simulator.build()")
+
+ # 3. 将仿真包路径加入Python路径(确保能导入包内模块)
+ if simulator_folder not in sys.path:
+ sys.path.insert(0, simulator_folder)
+
+ # 4. 导入仿真包内的Simulator类(支持版本兼容)
+ gen_cls_cloned = getattr(importlib.import_module(config["package_name"]), "Simulator")
+ if hasattr(gen_cls_cloned, "version"):
+ _legacy_mode = False # 新版(带version属性)
+ gen_cls_cloned_version = gen_cls_cloned.version.split("-v")[-1]
+ else:
+ _legacy_mode = True # 旧版(兼容逻辑)
+ gen_cls_cloned_version = gen_cls_cloned.id.split("-v")[-1]
+
+ # 5. 选择使用克隆后的类还是原类(调试用)
+ if use_cloned:
+ gen_cls = gen_cls_cloned
+ else:
+ gen_cls = cls
+ gen_cls_version = gen_cls.version.split("-v")[-1]
+ # 版本兼容性校验
+ # 主版本不一致:强制报错(不兼容)
+ if gen_cls_version.split(".")[0] > gen_cls_cloned_version.split(".")[0]:
+ raise RuntimeError(
+ f"严重版本不兼容!\n"
+ f"仿真包版本: {gen_cls_cloned_version}, 当前uitb版本: {gen_cls_version}\n"
+ f"请设置use_cloned=True使用仿真包内的版本"
+ )
+ # 次版本不一致:仅警告(兼容)
+ elif gen_cls_version.split(".")[1] > gen_cls_cloned_version.split(".")[1]:
+ print(
+ f"警告:版本不匹配!\n"
+ f"仿真包版本: {gen_cls_cloned_version}, 当前uitb版本: {gen_cls_version}\n"
+ f"请设置use_cloned=True使用仿真包内的版本"
+ )
+
+ # 6. 实例化仿真器(兼容新旧版参数)
+ if _legacy_mode:
+ _simulator = gen_cls(simulator_folder, run_parameters=run_parameters)
+ else:
+ try:
+ # 新版:支持完整渲染参数
+ _simulator = gen_cls(
+ simulator_folder,
+ render_mode=render_mode,
+ render_mode_perception=render_mode_perception,
+ render_show_depths=render_show_depths,
+ run_parameters=run_parameters
+ )
+ except TypeError:
+ # 兼容无render_mode_perception参数的版本
+ _simulator = gen_cls(
+ simulator_folder,
+ render_mode=render_mode,
+ render_show_depths=render_show_depths,
+ run_parameters=run_parameters
+ )
+
+ return _simulator
+
+ def __init__(self, simulator_folder, render_mode="rgb_array", render_mode_perception="embed", render_show_depths=False, run_parameters=None):
+ """
+ 仿真器实例初始化(内部调用,用户应使用Simulator.get())
+ Args: 同Simulator.get(),略
+ """
+ # 1. 校验仿真包路径存在
+ if not os.path.exists(simulator_folder):
+ raise FileNotFoundError(f"仿真包路径不存在: {simulator_folder}")
+ self._simulator_folder = simulator_folder
+
+ # 2. 加载配置文件
+ self._config = parse_yaml(os.path.join(self._simulator_folder, "config.yaml"))
+
+ # 3. 合并运行参数(配置文件参数 + 运行时覆盖参数)
+ self._run_parameters = self._config["simulation"]["run_parameters"].copy()
+ self._run_parameters.update(run_parameters or {})
+
+ # 4. 初始化核心组件(MuJoCo模型/数据、各模块)
+ self._model, self._data, self.task, self.bm_model, self.perception, self.callbacks = \
+ self._initialise(self._config, self._simulator_folder, self._run_parameters)
+
+ # 5. 初始化动作空间(所有执行器:BM模型+感知模块)
+ self.action_space = self._initialise_action_space()
+
+ # 6. 初始化观测空间(感知模块输出 + 任务状态信息)
+ self.observation_space = self._initialise_observation_space()
+
+ # 7. 初始化回合统计(记录当前回合的时间/步数/奖励)
+ self._episode_statistics = {
+ "length (seconds)": 0,
+ "length (steps)": 0,
+ "reward": 0
+ }
+
+ # 8. 初始化GUI相机(用于主画面渲染)
+ self._GUI_camera = Camera(
+ self._run_parameters["rendering_context"],
+ self._model,
+ self._data,
+ camera_id='for_testing', # 相机ID(对应MuJoCo XML中的camera节点)
+ dt=self._run_parameters["dt"]
+ )
+
+ # 9. 渲染相关参数初始化
+ self._render_mode = render_mode # 渲染模式
+ self._render_mode_perception = render_mode_perception # 感知画面展示方式
+ self._render_show_depths = render_show_depths # 是否显示深度图
+ self._render_stack = [] # 渲染帧栈(rgb_array_list模式用)
+ self._render_stack_perception = defaultdict(list) # 感知模块帧栈(separate模式用)
+ self._render_stack_pop = True # 调用render()后清空帧栈
+ self._render_stack_clean_at_reset = True # reset时清空帧栈
+ self._render_screen_size = None # Pygame窗口尺寸(human模式用)
+ self._render_window = None # Pygame窗口对象(human模式用)
+ self._render_clock = None # Pygame时钟(控制帧率)
+
+ def _initialise_action_space(self):
+ """
+ 初始化动作空间(gym.spaces.Box)
+ 动作维度 = 生物力学模型执行器数 + 感知模块执行器数(如眼球转动)
+ 动作范围:所有执行器默认[-1, 1](标准化,便于RL训练)
+ Returns:
+ spaces.Box: 动作空间对象
+ """
+ # 总执行器数
+ num_actuators = self.bm_model.nu + self.perception.nu
+ # 构建动作上下界(num_actuators × 2的数组,每行为[-1, 1])
+ actuator_limits = np.ones((num_actuators, 2)) * np.array([-1.0, 1.0])
+ # 返回Box空间(float32类型,兼容大多数RL框架)
+ return spaces.Box(
+ low=np.float32(actuator_limits[:, 0]),
+ high=np.float32(actuator_limits[:, 1])
+ )
+
+ def _initialise_observation_space(self):
+ """
+ 初始化观测空间(gym.spaces.Dict)
+ 观测空间结构:
+ {
+ "视觉模块名": Box(视觉观测维度),
+ "触觉模块名": Box(触觉观测维度),
+ "stateful_information": Box(任务状态维度) # 如目标位置/自身姿态
+ }
+ Returns:
+ spaces.Dict: 观测空间对象
+ """
+ # 先获取一次观测样例,确定各维度
+ observation = self.get_observation()
+ obs_dict = dict()
+ # 为每个感知模块初始化观测空间
+ for module in self.perception.perception_modules:
+ obs_dict[module.modality] = spaces.Box(
+ dtype=np.float32,
+ **module.get_observation_space_params() # 模块返回自身的观测维度/上下界
+ )
+ # 添加任务状态信息(如目标位置、自身关节角度等)
+ if "stateful_information" in observation:
+ obs_dict["stateful_information"] = spaces.Box(
+ dtype=np.float32,
+ **self.task.get_stateful_information_space_params()
+ )
+ return spaces.Dict(obs_dict)
+
+ def step(self, action):
+ """
+ 核心step方法(RL训练的核心循环单元)
+ 执行逻辑:动作→物理仿真→模块更新→奖励计算→观测生成
+ Args:
+ action: 动作数组(来自RL策略,范围[-1, 1])
+ Returns:
+ tuple: (obs, reward, terminated, truncated, info)
+ - obs: 观测字典(感知模块输出+任务状态)
+ - reward: 即时奖励
+ - terminated: 回合是否完成(如到达目标)
+ - truncated: 回合是否截断(如超时/超出范围)
+ - info: 附加信息(如努力成本、Unity图像等)
+ """
+ # 1. 设置生物力学模型的控制信号(如关节力矩)
+ self.bm_model.set_ctrl(self._model, self._data, action[:self.bm_model.nu])
+
+ # 2. 设置感知模块的控制信号(如眼球转动)
+ self.perception.set_ctrl(self._model, self._data, action[self.bm_model.nu:])
+
+ # 3. 执行MuJoCo仿真步(frame_skip次)
+ mujoco.mj_step(self._model, self._data, nstep=self._run_parameters["frame_skip"])
+
+ # 4. 更新生物力学模型(如约束检查、肌肉激活计算)
+ self.bm_model.update(self._model, self._data)
+
+ # 5. 更新感知模块(如视觉数据采集、预处理)
+ self.perception.update(self._model, self._data)
+
+ # 6. 更新任务状态,获取奖励和终止信号
+ reward, terminated, truncated, info = self.task.update(self._model, self._data)
+
+ # 7. 增加努力成本(惩罚过大的动作,鼓励节能)
+ effort_cost = self.bm_model.get_effort_cost(self._model, self._data)
+ info["EffortCost"] = effort_cost # 记录到info中
+ reward -= effort_cost # 奖励 = 任务奖励 - 努力成本
+
+ # 8. 生成观测(感知模块输出 + 任务状态)
+ obs = self.get_observation(info)
+
+ # 9. 渲染处理(根据渲染模式存储/显示帧)
+ if self._render_mode == "rgb_array_list":
+ # 存储主画面帧到栈
+ self._render_stack.append(self._GUI_rendering())
+ elif self._render_mode == "human":
+ # 实时显示到Pygame窗口
+ self._GUI_rendering_pygame()
+
+ # 更新回合统计
+ self._episode_statistics["length (seconds)"] += self._run_parameters["dt"]
+ self._episode_statistics["length (steps)"] += 1
+ self._episode_statistics["reward"] += reward
+
+ return obs, reward, terminated, truncated, info
+
+ def get_observation(self, info=None):
+ """
+ 生成观测字典(整合感知模块和任务状态)
+ Args:
+ info: 附加信息(如Unity图像、努力成本等)
+ Returns:
+ dict: 观测字典
+ """
+ # 1. 获取感知模块的观测(如视觉/触觉数据)
+ observation = self.perception.get_observation(self._model, self._data, info)
+
+ # 2. 添加任务状态信息(如目标位置、自身关节角度)
+ stateful_information = self.task.get_stateful_information(self._model, self._data)
+ # 注:空数组会导致SB3报错,因此仅当有数据时添加
+ if stateful_information.size > 0:
+ observation["stateful_information"] = stateful_information
+
+ return observation
+
+ def reset(self, seed=None):
+ """
+ 重置仿真环境(新回合开始)
+ Args:
+ seed: 随机种子(保证复现性)
+ Returns:
+ tuple: (obs, info) → 重置后的初始观测和附加信息
+ """
+ # 调用父类reset(设置随机种子)
+ super().reset(seed=seed)
+
+ # 1. 重置MuJoCo数据(位置/速度等恢复初始状态)
+ mujoco.mj_resetData(self._model, self._data)
+
+ # 2. 重置所有模块
+ self.bm_model.reset(self._model, self._data) # 重置生物力学模型
+ self.perception.reset(self._model, self._data) # 重置感知模块
+ info = self.task.reset(self._model, self._data) # 重置任务模型
+
+ # 3. 执行一次mj_forward(更新物理状态,确保初始状态正确)
+ mujoco.mj_forward(self._model, self._data)
+
+ # 4. 重置渲染帧栈
+ if self._render_mode == "rgb_array_list":
+ if self._render_stack_clean_at_reset:
+ self._render_stack = [] # 清空主画面栈
+ self._render_stack_perception = defaultdict(list) # 清空感知帧栈
+ # 存储初始帧
+ self._render_stack.append(self._GUI_rendering())
+ elif self._render_mode == "human":
+ # 显示初始帧到Pygame窗口
+ self._GUI_rendering_pygame()
+
+ # 重置回合统计
+ self._episode_statistics = {
+ "length (seconds)": 0,
+ "length (steps)": 0,
+ "reward": 0
+ }
+
+ # 返回初始观测和信息
+ return self.get_observation(), info
+
+ def render(self):
+ """
+ 渲染方法(返回渲染结果或显示窗口)
+ Returns:
+ None/list/np.ndarray: 依渲染模式返回对应结果
+ """
+ if self._render_mode == "rgb_array_list":
+ # 返回帧栈并清空(若开启pop)
+ render_stack = self._render_stack
+ if self._render_stack_pop:
+ self._render_stack = []
+ return render_stack
+ elif self._render_mode == "rgb_array":
+ # 返回当前主画面帧
+ return self._GUI_rendering()
+ else:
+ # human模式:已在step/reset中实时显示,返回None
+ return None
+
+ def get_render_stack_perception(self):
+ """
+ 获取感知模块的渲染帧栈(separate模式用)
+ Returns:
+ defaultdict(list): 键为"模块名/相机类型",值为帧列表
+ """
+ render_stack_perception = self._render_stack_perception
+ # 注释:可选清空逻辑,根据需求开启
+ # if self._render_stack_pop:
+ # self._render_stack_perception = defaultdict(list)
+ return render_stack_perception
+
+ def _GUI_rendering(self):
+ """
+ 内部渲染方法:生成主画面(含感知模块画中画)
+ Returns:
+ np.ndarray: 主画面RGB数组(H×W×3)
+ """
+ # 1. 获取主相机画面
+ img, _ = self._GUI_camera.render()
+
+ # 2. 处理感知模块画面嵌入(画中画)
+ if self._render_mode_perception == "embed":
+ # 收集所有感知模块的有效画面(RGB/深度图)
+ perception_camera_images = [
+ rgb_or_depth_array
+ for camera in self.perception.cameras
+ for rgb_or_depth_array in camera.render()
+ if rgb_or_depth_array is not None
+ ]
+
+ # 有感知画面时才处理嵌入
+ if len(perception_camera_images) > 0:
+ _img_size = img.shape[:2] # 主画面尺寸 (H, W)
+
+ # 计算感知画面的目标尺寸(垂直均分主画面高度,宽度为20%主画面宽度)
+ _desired_subwindow_height = np.round(_img_size[0] / len(perception_camera_images)).astype(int)
+ _maximum_subwindow_width = np.round(0.2 * _img_size[1]).astype(int)
+
+ perception_camera_images_resampled = []
+ for ocular_img in perception_camera_images:
+ # 处理深度图:转为热力图(2D→3D RGB)
+ if ocular_img.ndim == 2:
+ if self._render_show_depths:
+ # 深度图转Jet热力图
+ ocular_img = matplotlib.pyplot.imshow(
+ ocular_img,
+ cmap=matplotlib.pyplot.cm.jet,
+ interpolation='bicubic'
+ ).make_image('TkAgg', unsampled=True)[0][..., :3]
+ matplotlib.pyplot.close() # 关闭临时绘图窗口,避免内存泄漏
+ else:
+ # 不显示深度图则跳过
+ continue
+
+ # 计算缩放因子(保持宽高比,不超出目标尺寸)
+ resample_factor = min(
+ _desired_subwindow_height / ocular_img.shape[0],
+ _maximum_subwindow_width / ocular_img.shape[1]
+ )
+
+ # 计算缩放后的尺寸
+ resample_height = np.round(ocular_img.shape[0] * resample_factor).astype(int)
+ resample_width = np.round(ocular_img.shape[1] * resample_factor).astype(int)
+ # 初始化缩放后的图像数组
+ resampled_img = np.zeros((resample_height, resample_width, ocular_img.shape[2]), dtype=np.uint8)
+ # 逐通道缩放(保持色彩正确)
+ for channel in range(ocular_img.shape[2]):
+ resampled_img[:, :, channel] = scipy.ndimage.zoom(
+ ocular_img[:, :, channel],
+ resample_factor,
+ order=0 # 0阶插值(最近邻),速度快
+ )
+
+ perception_camera_images_resampled.append(resampled_img)
+
+ # 将感知画面嵌入主画面右下角(垂直排列)
+ ocular_img_bottom = _img_size[0] # 起始Y坐标(主画面底部)
+ for ocular_img_idx, ocular_img in enumerate(perception_camera_images_resampled):
+ # 计算嵌入位置(右下角)
+ y_start = ocular_img_bottom - ocular_img.shape[0]
+ y_end = ocular_img_bottom
+ x_start = _img_size[1] - ocular_img.shape[1]
+ x_end = _img_size[1]
+ # 嵌入画面
+ img[y_start:y_end, x_start:x_end] = ocular_img
+ # 更新下一个画面的Y坐标
+ ocular_img_bottom -= ocular_img.shape[0]
+
+ # 3. 处理感知模块画面单独存储(separate模式)
+ elif self._render_mode_perception == "separate":
+ for module, camera_list in self.perception.cameras_dict.items():
+ for camera in camera_list:
+ for rgb_or_depth_array in camera.render():
+ if rgb_or_depth_array is not None:
+ # 存储格式:"模块名/相机类型" → 帧列表
+ self._render_stack_perception[f"{module.modality}/{type(camera).__name__}"].append(rgb_or_depth_array)
+
+ return img
+
+ def _GUI_rendering_pygame(self):
+ """
+ 内部方法:Pygame窗口渲染(human模式)
+ 处理图像格式转换、窗口创建、帧率控制
+ """
+ # 1. 获取主画面并转换格式(MuJoCo: H×W×3 → Pygame: W×H×3)
+ rgb_array = np.transpose(self._GUI_rendering(), axes=(1, 0, 2))
+
+ # 2. 初始化窗口尺寸(首次调用时)
+ if self._render_screen_size is None:
+ self._render_screen_size = rgb_array.shape[:2]
+
+ # 校验画面尺寸是否匹配(避免Pygame报错)
+ assert self._render_screen_size == rgb_array.shape[:2], \
+ f"期望画面尺寸: {self._render_screen_size}, 实际: {rgb_array.shape[:2]}"
+
+ # 3. 初始化Pygame窗口(首次调用时)
+ if self._render_window is None:
+ pygame.init()
+ pygame.display.init()
+ self._render_window = pygame.display.set_mode(self._render_screen_size)
+
+ # 4. 初始化Pygame时钟(控制帧率)
+ if self._render_clock is None:
+ self._render_clock = pygame.time.Clock()
+
+ # 5. 将numpy数组转换为Pygame表面并显示
+ surf = pygame.surfarray.make_surface(rgb_array)
+ self._render_window.blit(surf, (0, 0)) # 绘制到窗口
+ pygame.event.pump() # 处理窗口事件(如关闭)
+ self._render_clock.tick(self.fps) # 控制帧率(与相机帧率一致)
+ pygame.display.flip() # 更新窗口显示
+
+ def close(self):
+ """
+ 关闭仿真环境(清理资源)
+ 关闭Pygame窗口、释放各模块资源
+ """
+ super().close()
+ # 关闭Pygame窗口
+ if self._render_window is not None:
+ import pygame
+ pygame.display.quit()
+ pygame.quit()
+
+ @property
+ def fps(self):
+ """
+ 获取渲染帧率(与GUI相机帧率一致)
+ Returns:
+ float: 帧率(FPS)
+ """
+ return self._GUI_camera._fps
+
+ def callback(self, callback_name, num_timesteps):
+ """
+ 调用指定回调函数(如课程学习)
+ Args:
+ callback_name: 回调函数名
+ num_timesteps: 当前训练步数
+ """
+ self.callbacks[callback_name].update(num_timesteps)
+
+ def update_callbacks(self, num_timesteps):
+ """
+ 调用所有回调函数(批量更新)
+ Args:
+ num_timesteps: 当前训练步数
+ """
+ for callback_name in self.callbacks:
+ self.callback(callback_name, num_timesteps)
+
+ @property
+ def config(self):
+ """
+ 获取配置字典(深拷贝,避免外部修改)
+ Returns:
+ dict: 完整配置字典
+ """
+ return copy.deepcopy(self._config)
+
+ @property
+ def run_parameters(self):
+ """
+ 获取运行参数(深拷贝,Context对象除外)
+ Returns:
+ dict: 运行参数字典
+ """
+ # Context对象无法深拷贝,单独处理
+ exclude = {"rendering_context"}
+ run_params = {
+ k: copy.deepcopy(self._run_parameters[k])
+ for k in self._run_parameters.keys() - exclude
+ }
+ run_params["rendering_context"] = self._run_parameters["rendering_context"]
+ return run_params
+
+ @property
+ def simulator_folder(self):
+ """
+ 获取仿真包路径
+ Returns:
+ str: 路径字符串
+ """
+ return self._simulator_folder
+
+ @property
+ def render_mode(self):
+ """
+ 获取当前渲染模式
+ Returns:
+ str: 渲染模式(rgb_array/rgb_array_list/human)
+ """
+ return self._render_mode
+
+ def get_state(self):
+ """
+ 获取完整的仿真状态(用于日志/评估,非RL观测)
+ 包含MuJoCo核心状态 + 各模块状态
+ Returns:
+ dict: 状态字典
+ """
+ # 1. MuJoCo核心状态
+ state = {
+ "timestep": self._data.time, # 当前仿真时间
+ "qpos": self._data.qpos.copy(), # 关节位置
+ "qvel": self._data.qvel.copy(), # 关节速度
+ "qacc": self._data.qacc.copy(), # 关节加速度
+ "act_force": self._data.actuator_force.copy(), # 执行器力
+ "act": self._data.act.copy(), # 执行器激活值
+ "ctrl": self._data.ctrl.copy() # 执行器控制信号
+ }
+
+ # 2. 任务模型状态
+ state.update(self.task.get_state(self._model, self._data))
+
+ # 3. 生物力学模型状态
+ state.update(self.bm_model.get_state(self._model, self._data))
+
+ # 4. 感知模块状态
+ state.update(self.perception.get_state(self._model, self._data))
+
+ return state
+
+ def close(self, **kwargs):
+ """
+ 重载close方法:清理所有模块资源
+ Args:
+ **kwargs: 模块特定的清理参数
+ """
+ # 调用各模块的close方法
+ self.task.close(**kwargs)
+ self.perception.close(**kwargs)
+ self.bm_model.close(**kwargs)
\ No newline at end of file
diff --git a/src/box/skeleton_video.py b/src/box/skeleton_video.py
deleted file mode 100644
index e69de29bb2..0000000000
From 825fa94ece02850ceec0cc1a7ce98446175d8744 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Fri, 19 Dec 2025 17:11:37 +0800
Subject: [PATCH 04/14] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B3=A8=E9=87=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/simulator.py | 737 +++++++++++++++++--------------------------
1 file changed, 296 insertions(+), 441 deletions(-)
diff --git a/src/box/simulator.py b/src/box/simulator.py
index e803fe7319..afefbc1b00 100644
--- a/src/box/simulator.py
+++ b/src/box/simulator.py
@@ -1,15 +1,16 @@
import os
- my-featurn-branch
import logging
from typing import Dict, Any, Optional, Tuple, List
import numpy as np
from gymnasium import spaces
+# 初始化日志配置,设置日志级别为INFO
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
-# 全局依赖检测
+# ---------------- 全局依赖检测 ----------------
+# 检测MuJoCo是否安装(核心物理仿真引擎)
try:
import mujoco
HAS_MUJOCO = True
@@ -17,6 +18,7 @@
mujoco = None
HAS_MUJOCO = False
+# 检测MuJoCo Viewer是否安装(原生可视化工具)
try:
import mujoco_viewer
HAS_MUJOCO_VIEWER = True
@@ -25,490 +27,246 @@
HAS_MUJOCO_VIEWER = False
-class Simulator: # 第27行:类定义后必须有缩进的代码块
- """Mechanical hand simulator with optional MuJoCo backend.
- Behavior:
- - If `mujoco` is available, generate a simple hand MJCF, load model/data,
- and expose `reset`/`step` using MuJoCo stepping routines.
- - If `mujoco` is not available, fall back to a lightweight placeholder
- implementation so the module remains importable and testable.
- """
- # --------------- 核心:类内所有方法必须缩进(通常4个空格)---------------
- def __init__(self, render_mode: str = "human", simulator_folder: str = "./mujoco_models"):
- """初始化模拟器(类的构造方法,必须缩进)"""
- self.render_mode = render_mode
-import numpy as np
-import pygame
-import mujoco
-import yaml
-from gymnasium import spaces
-from typing import Dict, Any, Optional, Tuple
-import mujoco.viewer
-
class Simulator:
+ """
+ 机械臂/机械手仿真器核心类(兼容Gymnasium接口)
+ 核心特性:
+ 1. 支持MuJoCo物理引擎后端,自动生成机械臂/手MJCF模型文件
+ 2. 无MuJoCo时自动降级为轻量级占位实现(保证模块可导入)
+ 3. 提供标准RL接口:reset/step/render/close
+ 4. 支持Pygame可视化(2D占位渲染)和MuJoCo Viewer(3D原生渲染)
+ """
def __init__(self, simulator_folder: str, render_mode: Optional[str] = None):
- main
- self.simulator_folder = simulator_folder
- self.render_mode = render_mode
- self.step_count = 0
- self.terminated = False
- self.truncated = False
- self.last_reward = 0.0
-
- # ---------------- MuJoCo后端实现(必须缩进)----------------
- def _init_mujoco(self):
- """初始化MuJoCo仿真环境"""
- self.config: Dict[str, Any] = {
- "simulation": {
- "max_steps": 1000,
- "model_path": "hand_model.mjcf",
- "target_joint_pos": []
- }
- }
-
- # 5根手指的基座位置和连杆长度(单位:米)
- bases = [(-0.20, 0.06), (-0.08, 0.06), (0.04, 0.06), (0.16, 0.06), (0.28, 0.06)]
- lengths = [[0.06, 0.05, 0.04] for _ in bases]
- self.finger_bases = bases
- self.link_lengths = lengths
-
- # 生成MJCF模型文件路径
- model_path = os.path.join(self.simulator_folder, self.config["simulation"]["model_path"])
- os.makedirs(os.path.dirname(model_path), exist_ok=True)
-
- # 构建MJCF模型内容
- mjcf_lines: List[str] = [
- '',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' '
- ]
-
- # 生成每根手指的MJCF描述
- for i, base in enumerate(bases):
- bx, by = base
- mjcf_lines.append(f' ')
- cum_x = 0.0
- for j, L in enumerate(lengths[i]):
- joint_name = f'f{i}j{j}'
- mjcf_lines.append(f' ')
- next_x = cum_x + L
- mjcf_lines.append(f' ')
- cum_x = next_x
- mjcf_lines.append(' ')
-
- # 完成MJCF模型定义
- mjcf_lines.extend([' ', ' ', ' '])
- for i in range(len(bases)):
- for j in range(len(lengths[i])):
- joint_name = f'f{i}j{j}'
- mjcf_lines.append(f' ')
- mjcf_lines.append(' ')
- mjcf_lines.append('')
-
- # 保存MJCF文件
- with open(model_path, 'w', encoding='utf-8') as f:
- f.write('\n'.join(mjcf_lines))
-
- # 加载MuJoCo模型和数据
- try:
- self.model = mujoco.MjModel.from_xml_path(model_path)
- self.data = mujoco.MjData(self.model)
- except Exception as e:
- logger.exception(f'Failed to load MuJoCo model — falling back to placeholder: {e}')
- self._init_placeholder()
- return
-
- # 定义动作/观测空间
- self.action_space = spaces.Box(
- low=-1.0, high=1.0,
- shape=(int(self.model.nu),), dtype=np.float32
- )
- obs_dim = int(self.model.nq) + int(self.model.nv)
- self.observation_space = spaces.Box(
- low=-np.inf, high=np.inf,
- shape=(obs_dim,), dtype=np.float32
- )
-
- # 初始化MuJoCo Viewer
- self.viewer = None
- if self.render_mode == 'human' and HAS_MUJOCO_VIEWER:
- try:
- self.viewer = mujoco_viewer.MujocoViewer(self.model, self.data)
- self.viewer.cam.azimuth = 45
- self.viewer.cam.elevation = -20
- self.viewer.cam.distance = 0.5
- except Exception as e:
- logger.exception(f'Failed to initialize mujoco_viewer: {e}')
-
- # ---------------- 占位实现(必须缩进)----------------
- def _init_placeholder(self):
- """轻量级占位实现"""
- self.model = type('M', (), {'nq': 15, 'nu': 15, 'nv': 15})()
- self.data = type('D', (), {
- 'qpos': np.zeros(self.model.nq),
- 'qvel': np.zeros(self.model.nv)
- })()
-
- # 定义兼容的动作/观测空间
- self.action_space = spaces.Box(
- low=-1.0, high=1.0,
- shape=(int(self.model.nu),), dtype=np.float32
- )
- obs_dim = int(self.model.nq) + int(self.model.nv)
- self.observation_space = spaces.Box(
- low=-np.inf, high=np.inf,
- shape=(obs_dim,), dtype=np.float32
- )
-
- # 占位的手指参数
- self.finger_bases = [(-0.20, 0.06), (-0.08, 0.06), (0.04, 0.06), (0.16, 0.06), (0.28, 0.06)]
- self.link_lengths = [[0.06, 0.05, 0.04] for _ in self.finger_bases]
-
- # ---------------- 通用API(必须缩进)----------------
- def reset(self, seed: Optional[int] = None) -> Tuple[np.ndarray, dict]:
- """重置仿真环境"""
- if seed is not None:
- np.random.seed(int(seed))
- if HAS_MUJOCO and hasattr(mujoco, 'set_rng_seed'):
- try:
- mujoco.set_rng_seed(int(seed))
- except Exception as e:
- logger.warning(f'Failed to set MuJoCo seed: {e}')
-
- self.step_count = 0
- self.terminated = False
- self.truncated = False
- self.last_reward = 0.0
-
- if HAS_MUJOCO and isinstance(self.data, mujoco.MjData):
- mujoco.mj_resetData(self.model, self.data)
- else:
- self.data.qpos = np.zeros(int(self.model.nq))
- self.data.qvel = np.zeros(int(self.model.nv))
-
- obs = self._get_obs()
- return obs, {'step': self.step_count}
-
- def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, dict]:
- """执行一步仿真"""
- action = np.asarray(action, dtype=np.float32)
- action = np.clip(action, self.action_space.low, self.action_space.high)
-
- # MuJoCo步进
- if HAS_MUJOCO and isinstance(self.data, mujoco.MjData):
- self.data.ctrl[:] = action * 5.0
- try:
- mujoco.mj_step(self.model, self.data)
- except Exception:
- try:
- mujoco.mj_step1(self.model, self.data)
- mujoco.mj_step2(self.model, self.data)
- except Exception as e:
- logger.exception(f'MuJoCo stepping failed: {e}')
- else:
- # 占位动力学
- gain = 0.01
- self.data.qvel = self.data.qvel + action[:int(self.model.nv)] * gain
- self.data.qpos = self.data.qpos + self.data.qvel
-
- self.step_count += 1
- reward = self._compute_reward()
- self.last_reward = float(reward)
-
- self.terminated = self._check_terminated()
- max_steps = int(self.config['simulation'].get('max_steps', 1000)) if hasattr(self, 'config') else 1000
- self.truncated = self.step_count >= max_steps
-
- obs = self._get_obs()
- info = {'step': self.step_count, 'reward': float(reward)}
- return obs, float(reward), bool(self.terminated), bool(self.truncated), info
-
- def _get_obs(self) -> np.ndarray:
- """获取当前观测"""
- if HAS_MUJOCO and isinstance(self.data, mujoco.MjData):
- qpos = self.data.qpos.copy()
- qvel = self.data.qvel.copy()
- else:
- qpos = np.asarray(self.data.qpos).copy()
- qvel = np.asarray(self.data.qvel).copy()
- return np.concatenate([qpos, qvel]).astype(np.float32)
-
- def _compute_reward(self) -> float:
- """计算奖励"""
- if hasattr(self, 'config') and 'simulation' in self.config:
- target = np.array(self.config['simulation'].get('target_joint_pos', [0.0] * int(self.model.nq)))
- else:
- target = np.zeros(int(self.model.nq))
-
- current = np.asarray(self.data.qpos).copy()
- if len(target) != len(current):
- target = np.zeros_like(current)
+ """
+ 仿真器初始化入口
+ Args:
+ simulator_folder: 仿真配置/模型文件存储路径
+ render_mode: 渲染模式 - "human"(可视化窗口)/None(无渲染)
+ """
+ # 基础属性初始化
+ self.simulator_folder = simulator_folder # 仿真文件根目录
+ self.render_mode = render_mode # 渲染模式
+ self.step_count = 0 # 当前仿真步数
+ self.terminated = False # 任务是否完成(自然终止)
+ self.truncated = False # 任务是否截断(步数超限)
+ self.last_reward = 0.0 # 上一步奖励值
- return float(-np.sum((current - target) ** 2))
-
- def _check_terminated(self) -> bool:
- """检查是否终止"""
- return False
-
- def render(self):
- """渲染仿真画面"""
- if self.render_mode != 'human':
- return
-
- # MuJoCo Viewer渲染
- if HAS_MUJOCO and self.viewer is not None:
- try:
- self.viewer.render()
- return
- except Exception as e:
- logger.warning(f'MuJoCo Viewer render failed: {e}')
-
- # 占位渲染(Pygame)
- try:
- import pygame
- except ImportError:
- logger.warning("Pygame not installed — skip placeholder rendering")
- return
-
- # 初始化Pygame
- if not (pygame.get_init() and pygame.display.get_init()):
- try:
- pygame.init()
- self.screen = pygame.display.set_mode((800, 600))
- pygame.display.set_caption('Mechanical Hand (Placeholder)')
- except Exception as e:
- logger.warning(f'Pygame init failed: {e}')
- return
-
- # 绘制背景
- screen = self.screen
- screen.fill((240, 240, 255))
-
- # 绘制手掌
- center_x, center_y = 400, 420
- palm_w, palm_h = 360, 80
- pygame.draw.rect(screen, (160, 120, 90),
- (center_x - palm_w // 2, center_y - palm_h // 2, palm_w, palm_h))
-
- # 绘制手指连杆
- q = np.asarray(self.data.qpos).copy()
- ptr = 0
- for i, base in enumerate(self.finger_bases):
- bx, by = base
- sx = center_x + int(bx * 1000)
- sy = center_y - int(by * 1000)
- angle = 0.0
- x0, y0 = sx, sy
-
- for j, L in enumerate(self.link_lengths[i]):
- if ptr < len(q):
- angle += float(q[ptr])
- ex = x0 + int(np.cos(angle) * (L * 1000))
- ey = y0 - int(np.sin(angle) * (L * 1000))
-
- pygame.draw.line(screen, (10, 10, 10), (x0, y0), (ex, ey), max(1, 6 - j))
- pygame.draw.circle(screen, (200, 80, 80), (x0, y0), 6)
-
- x0, y0 = ex, ey
- ptr += 1
-
- # 绘制状态文本
- font = pygame.font.Font(None, 28)
- txt = font.render(f"Step: {self.step_count} Reward: {self.last_reward:.3f}", True, (0, 0, 0))
- screen.blit(txt, (10, 10))
-
- # 更新画面
- pygame.display.flip()
-
- # 处理Pygame事件
- for event in pygame.event.get():
- if event.type == pygame.QUIT:
- self.close()
-
- def close(self):
- """关闭模拟器"""
- # 关闭MuJoCo Viewer
- if HAS_MUJOCO and self.viewer is not None:
- try:
- self.viewer.close()
- except Exception:
- pass
-
- # 关闭Pygame
- if self.render_mode == 'human':
- try:
- import pygame
- pygame.quit()
- except Exception:
- pass
-
-
-# 测试代码(类外代码无需缩进)
-if __name__ == "__main__":
- # 初始化模拟器
- sim = Simulator(render_mode="human", simulator_folder="./mujoco_hand")
-
- # 重置环境
- obs, info = sim.reset(seed=42)
- print(f"Initial observation shape: {obs.shape}")
-
- # 运行100步仿真
- for _ in range(100):
- # 随机动作
- action = sim.action_space.sample()
- obs, reward, terminated, truncated, info = sim.step(action)
-
- # 渲染画面
- sim.render()
-
- # 打印状态
- if _ % 10 == 0:
- print(f"Step: {info['step']}, Reward: {reward:.3f}")
-
- if terminated or truncated:
- print(f"Simulation ended at step {info['step']}")
- break
-
- # 关闭模拟器
- sim.close()
-=======
- self.model = None
- self.data = None
- self.viewer = None
- self.screen = None
-
- # 1. 先加载配置
+ # 核心组件初始化(先置空,后续分步加载)
+ self.model = None # MuJoCo模型对象(物理模型定义)
+ self.data = None # MuJoCo数据对象(存储仿真状态:关节位置/速度等)
+ self.viewer = None # MuJoCo Viewer对象(3D渲染)
+ self.screen = None # Pygame窗口对象(2D渲染)
+
+ # 初始化流程(分层解耦,便于维护)
+ # 1. 加载/生成配置文件(config.yaml)
self.config = self._load_config()
- # 2. 再加载模型
+ # 2. 加载/生成MuJoCo模型(MJCF文件)
self.model, self.data = self._load_model()
- # 3. 最后校验配置
+ # 3. 校验并补全配置(保证关键参数存在)
self._validate_config()
- # 初始化动作空间、观测空间、渲染
- self._init_action_space()
- self._init_observation_space()
-
+ # 初始化RL核心空间和渲染
+ self._init_action_space() # 动作空间(执行器控制信号)
+ self._init_observation_space() # 观测空间(关节状态)
if self.render_mode:
- self._init_render()
+ self._init_render() # 初始化渲染组件
@classmethod
def get(cls, simulator_folder: str, **kwargs):
+ """
+ 类工厂方法(兼容原有接口)
+ Args:
+ simulator_folder: 仿真文件根目录
+ **kwargs: 其他初始化参数(如render_mode)
+ Returns:
+ Simulator: 仿真器实例
+ """
return cls(simulator_folder, **kwargs)
def _load_config(self) -> Dict[str, Any]:
+ """
+ 加载/生成仿真配置文件(config.yaml)
+ - 若配置文件不存在,生成默认机械臂配置
+ - 若存在,读取并返回配置字典
+ Returns:
+ Dict[str, Any]: 仿真配置字典
+ """
+ # 配置文件路径
config_path = os.path.join(self.simulator_folder, "config.yaml")
+
+ # 配置文件不存在时,生成默认配置
if not os.path.exists(config_path):
default_config = {
"simulation": {
- "max_steps": 1000,
- "model_path": "arm_model.mjcf",
- "control_frequency": 20,
- "target_joint_pos": [0.0]
+ "max_steps": 1000, # 单回合最大步数
+ "model_path": "arm_model.mjcf", # MuJoCo模型文件路径
+ "control_frequency": 20, # 控制频率(Hz)
+ "target_joint_pos": [0.0] # 目标关节位置(奖励函数用)
}
}
+ # 保存默认配置到文件
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(default_config, f)
+ logger.info(f"生成默认配置文件: {config_path}")
+
+ # 读取配置文件
with open(config_path, "r", encoding="utf-8") as f:
- return yaml.safe_load(f)
-
- def _load_model(self):
- model_path = os.path.join(self.simulator_folder, self.config["simulation"].get("model_path", "arm_model.mjcf"))
+ config = yaml.safe_load(f)
+ logger.info(f"成功加载配置文件: {config_path}")
+ return config
+
+ def _load_model(self) -> Tuple[mujoco.MjModel, mujoco.MjData]:
+ """
+ 加载/生成MuJoCo模型文件(MJCF)
+ - 若模型文件不存在,生成简单机械臂模型
+ - 加载模型并创建MjData对象(存储仿真状态)
+ Returns:
+ Tuple[mujoco.MjModel, mujoco.MjData]: MuJoCo模型和数据对象
+ """
+ # 模型文件路径(从配置读取)
+ model_path = os.path.join(
+ self.simulator_folder,
+ self.config["simulation"].get("model_path", "arm_model.mjcf")
+ )
+
+ # 模型文件不存在时,生成简单单关节机械臂MJCF
if not os.path.exists(model_path):
- with open(model_path, "w", encoding="utf-8") as f:
- f.write("""
-
+ mjcf_content = """
+
-
-
-
-
-
+
+
+
+
+
-
+
-""")
- model = mujoco.MjModel.from_xml_path(model_path)
- data = mujoco.MjData(model)
- return model, data
+"""
+ # 保存MJCF文件
+ with open(model_path, "w", encoding="utf-8") as f:
+ f.write(mjcf_content)
+ logger.info(f"生成默认机械臂模型: {model_path}")
+
+ # 加载MuJoCo模型和数据
+ try:
+ model = mujoco.MjModel.from_xml_path(model_path)
+ data = mujoco.MjData(model)
+ logger.info(f"成功加载MuJoCo模型: {model_path}")
+ return model, data
+ except Exception as e:
+ logger.error(f"加载MuJoCo模型失败: {e}")
+ raise
def _validate_config(self):
+ """
+ 校验并补全配置参数
+ - 保证simulation字段存在
+ - 根据模型自动补全目标关节位置维度
+ - 设置缺失参数的默认值
+ """
+ # 确保simulation字段存在
if "simulation" not in self.config:
self.config["simulation"] = {}
- # 如果有模型,使用模型信息,否则使用默认值
- if self.model is not None:
- nq = self.model.nq
- else:
- nq = 1 # 默认值
-
+ # 获取模型关节数(nq),无模型时默认1
+ nq = self.model.nq if self.model is not None else 1
+
+ # 补全目标关节位置(维度匹配模型)
self.config["simulation"].setdefault("target_joint_pos", [0.0] * nq)
+ # 补全最大步数
self.config["simulation"].setdefault("max_steps", 1000)
+ # 补全控制频率
self.config["simulation"].setdefault("control_frequency", 20)
+
+ logger.info("配置校验完成,补全缺失参数")
def _init_action_space(self):
- """初始化动作空间"""
- # 确保模型已加载
+ """
+ 初始化动作空间(Gymnasium Box空间)
+ - 动作维度 = MuJoCo执行器数(nu)
+ - 动作范围 [-1.0, 1.0](标准化,便于RL训练)
+ Raises:
+ ValueError: 模型未加载时抛出异常
+ """
+ # 校验模型是否加载
if self.model is None:
raise ValueError("模型未加载,无法初始化动作空间")
+ # 执行器数量(每个执行器对应一个动作维度)
n_actuators = self.model.nu
+ # 定义动作空间(Box空间,float32类型)
self.action_space = spaces.Box(
low=-1.0,
high=1.0,
shape=(n_actuators,),
dtype=np.float32
)
- print(f"动作空间初始化: 维度={n_actuators}")
+ logger.info(f"动作空间初始化完成: 维度={n_actuators}, 范围=[-1.0, 1.0]")
def _init_observation_space(self):
- """初始化观测空间"""
- # 确保模型已加载
+ """
+ 初始化观测空间(Gymnasium Box空间)
+ - 观测维度 = 关节位置数(nq) + 关节速度数(nv)
+ - 观测范围 [-∞, +∞](无界,兼容关节任意状态)
+ Raises:
+ ValueError: 模型未加载时抛出异常
+ """
+ # 校验模型是否加载
if self.model is None:
raise ValueError("模型未加载,无法初始化观测空间")
- n_qpos = self.model.nq
- n_qvel = self.model.nv
-
- obs_dim = n_qpos + n_qvel
+ # 关节位置数和速度数
+ n_qpos = self.model.nq # 关节位置维度
+ n_qvel = self.model.nv # 关节速度维度
+ obs_dim = n_qpos + n_qvel # 总观测维度
+ # 定义观测空间
self.observation_space = spaces.Box(
low=-np.inf,
high=np.inf,
shape=(obs_dim,),
dtype=np.float32
)
- print(f"观测空间初始化: 维度={obs_dim} (位置={n_qpos}, 速度={n_qvel})")
+ logger.info(f"观测空间初始化完成: 总维度={obs_dim} (位置={n_qpos}, 速度={n_qvel})")
def _init_render(self):
- """初始化渲染"""
+ """
+ 初始化渲染组件
+ - 仅在render_mode="human"时初始化
+ - 使用Pygame创建2D可视化窗口
+ """
if self.render_mode == "human":
try:
+ import pygame
pygame.init()
+ # 创建800x600的可视化窗口
self.screen = pygame.display.set_mode((800, 600))
pygame.display.set_caption("MuJoCo Arm Simulation")
- print("Pygame渲染初始化成功")
+ logger.info("Pygame渲染窗口初始化成功")
except Exception as e:
- print(f"渲染初始化失败: {e}")
+ logger.error(f"Pygame渲染初始化失败: {e}")
+ raise
def reset(self, seed: Optional[int] = None) -> Tuple[np.ndarray, dict]:
- """重置仿真环境"""
+ """
+ 重置仿真环境(新回合开始)
+ Args:
+ seed: 随机种子(保证复现性)
+ Returns:
+ Tuple[np.ndarray, dict]: 初始观测 + 信息字典
+ """
+ # 设置随机种子
if seed is not None:
np.random.seed(seed)
+ logger.info(f"设置随机种子: {seed}")
- # 重置MuJoCo数据
+ # 重置MuJoCo仿真状态(关节位置/速度恢复初始值)
mujoco.mj_resetData(self.model, self.data)
- # 重置计数器
+ # 重置计数器和状态标记
self.step_count = 0
self.terminated = False
self.truncated = False
@@ -521,123 +279,220 @@ def reset(self, seed: Optional[int] = None) -> Tuple[np.ndarray, dict]:
if self.render_mode == "human":
self.render()
+ logger.debug(f"仿真环境重置完成,初始观测维度: {obs.shape}")
return obs, {}
def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, dict]:
- """执行一步仿真"""
- # 将动作应用到执行器
+ """
+ 执行一步仿真(RL核心循环单元)
+ Args:
+ action: 动作数组(来自RL策略,范围[-1.0, 1.0])
+ Returns:
+ Tuple[np.ndarray, float, bool, bool, dict]:
+ 观测 + 奖励 + 终止标记 + 截断标记 + 信息字典
+ """
+ # 动作预处理(确保类型和范围合法)
+ action = np.asarray(action, dtype=np.float32)
+ action = np.clip(action, self.action_space.low, self.action_space.high)
+
+ # 将标准化动作映射到执行器控制信号(放大10倍,匹配电机范围)
self.data.ctrl[:] = action * 10.0
- # 执行一步仿真
+ # 执行MuJoCo仿真步(物理引擎核心计算)
mujoco.mj_step(self.model, self.data)
- # 更新计数器
+ # 更新步数计数器
self.step_count += 1
- # 计算奖励
+ # 计算奖励(关节位置与目标的误差平方和的负值)
reward = self._compute_reward()
self.last_reward = reward
- # 检查是否终止
+ # 检查是否自然终止(如关节角度超限)
self.terminated = self._check_terminated()
# 检查是否截断(达到最大步数)
max_steps = self.config["simulation"].get("max_steps", 1000)
self.truncated = self.step_count >= max_steps
- # 获取观测
+ # 获取当前观测
obs = self._get_obs()
# 渲染当前状态
if self.render_mode == "human":
self.render()
+ # 日志输出(每10步打印一次)
+ if self.step_count % 10 == 0:
+ logger.debug(f"Step {self.step_count}: Reward={reward:.2f}, Terminated={self.terminated}, Truncated={self.truncated}")
+
return obs, reward, self.terminated, self.truncated, {}
def _get_obs(self) -> np.ndarray:
- """获取当前观测"""
+ """
+ 获取当前观测(关节位置 + 关节速度)
+ Returns:
+ np.ndarray: 观测数组(float32类型)
+ """
+ # 复制关节位置和速度(避免直接修改MuJoCo内部数据)
qpos = self.data.qpos.copy()
qvel = self.data.qvel.copy()
+ # 拼接为观测数组
obs = np.concatenate([qpos, qvel])
return obs.astype(np.float32)
def _compute_reward(self) -> float:
- """计算奖励函数"""
+ """
+ 计算奖励函数(目标:让关节位置接近目标值)
+ - 奖励 = -Σ(当前位置 - 目标位置)²
+ - 误差越小,奖励越高(最大值0)
+ Returns:
+ float: 即时奖励值
+ """
+ # 获取目标关节位置和当前位置
target_pos = np.array(self.config["simulation"]["target_joint_pos"])
current_pos = self.data.qpos.copy()
- # 确保数组维度匹配
+ # 确保维度匹配(避免广播错误)
if len(target_pos) != len(current_pos):
target_pos = np.zeros_like(current_pos)
+ # 计算位置误差的平方和(L2损失)
pos_error = np.sum((current_pos - target_pos) ** 2)
+ # 奖励为负的误差(鼓励误差减小)
reward = -pos_error
return float(reward)
def _check_terminated(self) -> bool:
- """检查是否达到终止条件"""
+ """
+ 检查是否达到自然终止条件
+ - 终止条件:任意关节角度超过π弧度(180度)
+ Returns:
+ bool: True=终止,False=继续
+ """
joint_pos = self.data.qpos.copy()
+ # 检查是否有关节角度超限
if np.any(np.abs(joint_pos) > np.pi):
+ logger.info(f"关节角度超限(>π),仿真终止 | 当前关节位置: {joint_pos}")
return True
return False
def render(self):
- """渲染当前仿真状态"""
+ """
+ 渲染当前仿真状态(2D Pygame可视化)
+ - 绘制机械臂2D投影
+ - 显示步数、奖励、关节角度等信息
+ - 处理窗口事件(关闭/ESC退出)
+ """
if self.render_mode == "human" and self.screen is not None:
- # 处理pygame事件
+ import pygame
+
+ # 处理Pygame窗口事件
for event in pygame.event.get():
+ # 关闭窗口事件
if event.type == pygame.QUIT:
self.close()
return
+ # ESC键退出
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.close()
return
- # 清屏
+ # 清屏(白色背景)
self.screen.fill((255, 255, 255))
- # 绘制简单表示(2D投影)
+ # 绘制机械臂2D投影
+ # 获取关节角度(单关节机械臂)
joint_angle = self.data.qpos[0] if self.model.nq > 0 else 0
-
+ # 机械臂长度(像素)
arm_length = 200
+ # 窗口中心坐标
center_x, center_y = 400, 300
-
+ # 计算机械臂末端坐标(极坐标转笛卡尔)
end_x = center_x + arm_length * np.cos(joint_angle)
end_y = center_y + arm_length * np.sin(joint_angle)
- # 绘制机械臂
- pygame.draw.line(self.screen, (0, 0, 0),
- (center_x, center_y), (end_x, end_y), 5)
+ # 绘制机械臂连杆(黑色线条,宽度5)
+ pygame.draw.line(
+ self.screen, (0, 0, 0),
+ (center_x, center_y), (end_x, end_y), 5
+ )
+ # 绘制关节(红色圆心,半径10)
+ pygame.draw.circle(
+ self.screen, (255, 0, 0),
+ (int(center_x), int(center_y)), 10
+ )
+ # 绘制末端执行器(蓝色圆心,半径8)
+ pygame.draw.circle(
+ self.screen, (0, 0, 255),
+ (int(end_x), int(end_y)), 8
+ )
- # 绘制关节
- pygame.draw.circle(self.screen, (255, 0, 0),
- (int(center_x), int(center_y)), 10)
- pygame.draw.circle(self.screen, (0, 0, 255),
- (int(end_x), int(end_y)), 8)
-
- # 显示信息
+ # 绘制文本信息(步数、奖励、关节角度)
font = pygame.font.Font(None, 36)
+ # 步数文本
step_text = font.render(f"Step: {self.step_count}", True, (0, 0, 0))
+ # 奖励文本
reward_text = font.render(f"Reward: {self.last_reward:.2f}", True, (0, 0, 0))
+ # 关节角度文本
angle_text = font.render(f"Joint Angle: {joint_angle:.2f} rad", True, (0, 0, 0))
+ # 绘制文本到窗口
self.screen.blit(step_text, (10, 10))
self.screen.blit(reward_text, (10, 50))
self.screen.blit(angle_text, (10, 90))
- # 更新显示
+ # 更新窗口显示
pygame.display.flip()
- # 控制帧率
+ # 控制渲染帧率(匹配控制频率)
control_freq = self.config["simulation"].get("control_frequency", 20)
pygame.time.delay(int(1000 / control_freq))
def close(self):
- """关闭环境"""
+ """
+ 关闭仿真环境(清理资源)
+ - 关闭Pygame窗口
+ - 释放所有渲染资源
+ """
if self.render_mode == "human":
- pygame.quit()
+ try:
+ import pygame
+ pygame.quit()
+ logger.info("Pygame窗口已关闭")
+ except Exception as e:
+ logger.warning(f"关闭Pygame失败: {e}")
+
+
+# ---------------- 测试代码(独立运行时执行)----------------
+if __name__ == "__main__":
+ # 初始化模拟器(存储路径:./mujoco_arm,开启可视化)
+ sim = Simulator(render_mode="human", simulator_folder="./mujoco_arm")
+
+ # 重置环境(设置随机种子保证复现)
+ obs, info = sim.reset(seed=42)
+ print(f"初始观测形状: {obs.shape}")
+
+ # 运行100步仿真
+ for _ in range(100):
+ # 随机采样动作(从动作空间中)
+ action = sim.action_space.sample()
+ # 执行一步仿真
+ obs, reward, terminated, truncated, info = sim.step(action)
+
+ # 每10步打印状态
+ if _ % 10 == 0:
+ print(f"Step: {sim.step_count}, Reward: {reward:.3f}")
+
+ # 终止/截断时退出循环
+ if terminated or truncated:
+ print(f"仿真在第 {sim.step_count} 步结束")
+ break
+
+ # 关闭模拟器(清理资源)
+ sim.close()
\ No newline at end of file
From bdff8c062ffb8c45e99cf406f91fbc6535fcedcd Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Fri, 19 Dec 2025 17:28:04 +0800
Subject: [PATCH 05/14] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E9=87=8D=E7=BD=AE=EF=BC=8C=E6=B7=BB=E5=8A=A0base?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/base.py | 0
src/box/moblARMslndex.py | 270 ---------------------------------------
2 files changed, 270 deletions(-)
create mode 100644 src/box/base.py
delete mode 100644 src/box/moblARMslndex.py
diff --git a/src/box/base.py b/src/box/base.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/box/moblARMslndex.py b/src/box/moblARMslndex.py
deleted file mode 100644
index 950f646641..0000000000
--- a/src/box/moblARMslndex.py
+++ /dev/null
@@ -1,270 +0,0 @@
-"""
-MoblArmsIndex 生物力学模型 - 修改版
-这是基于 MoBL ARMS 模型的 MuJoCo 实现,专门用于手指指向任务
-"""
-
-import sys
-import os
-
-# ================ 动态导入 BaseBMModel ================
-try:
- # 尝试1: 从原始相对位置导入
- from ..base import BaseBMModel
- print("✓ 使用相对导入: from ..base import BaseBMModel")
-except ImportError:
- try:
- # 尝试2: 从绝对路径导入(假设在 uitb 包中)
- from uitb.base import BaseBMModel
- print("✓ 使用绝对导入: from uitb.base import BaseBMModel")
- except ImportError:
- try:
- # 尝试3: 从常见路径导入
- import sys
- import os
-
- # 将项目根目录添加到路径
- current_dir = os.path.dirname(os.path.abspath(__file__))
- project_root = os.path.dirname(os.path.dirname(current_dir))
- sys.path.insert(0, project_root)
-
- from base import BaseBMModel
- print(f"✓ 使用项目根目录导入: from base import BaseBMModel (路径: {project_root})")
- except ImportError as e:
- # 尝试4: 搜索并动态导入
- print("⚠ 无法自动导入 BaseBMModel,尝试动态查找...")
-
- # 搜索 base.py 文件
- base_path = None
- search_dirs = [
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # 上一级目录
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), # 上上级目录
- os.path.join(os.path.dirname(__file__), '..'), # 相对路径
- ]
-
- for search_dir in search_dirs:
- possible_path = os.path.join(search_dir, 'base.py')
- if os.path.exists(possible_path):
- base_path = possible_path
- break
-
- if base_path:
- # 动态导入
- import importlib.util
- spec = importlib.util.spec_from_file_location("base_module", base_path)
- base_module = importlib.util.module_from_spec(spec)
- sys.modules[spec.name] = base_module
- spec.loader.exec_module(base_module)
- BaseBMModel = base_module.BaseBMModel
- print(f"✓ 动态导入成功: {base_path}")
- else:
- # 如果都失败,创建简化的 BaseBMModel
- print("⚠ 无法找到 BaseBMModel,使用简化版本")
-
- class BaseBMModel:
- """简化的 BaseBMModel 基类"""
- def __init__(self, model, data, **kwargs):
- self.model = model
- self.data = data
-
- def _update(self, model, data):
- """更新方法 - 子类应重写"""
- pass
-
- @classmethod
- def _get_floor(cls):
- """获取地板 - 子类应重写"""
- return None
-
- BaseBMModel = BaseBMModel
- print("✓ 使用简化版本的 BaseBMModel")
-
-# ================ 继续原来的代码 ================
-import numpy as np
-import mujoco
-
-
-class MoblArmsIndex(BaseBMModel):
- """
- 基于 MoBL ARMS 模型的生物力学模型
-
- 来源:
- - 原始 OpenSim 模型: https://simtk.org/frs/?group_id=657
- - MuJoCo 转换工具: https://github.com/aikkala/O2MConverter
-
- 说明:
- 此模型与 uitb/bm_models/mobl_arms 中的模型相同,
- 但食指处于弯曲状态并包含力传感器。
- """
-
- def __init__(self, model, data, **kwargs):
- """
- 初始化 MoblArmsIndex 模型
-
- 参数:
- model: MuJoCo 模型实例
- data: MuJoCo 数据实例
- **kwargs: 额外参数
- - shoulder_variant: 肩部变体,可选 "none" (默认) 或 "patch-v1"
- """
- super().__init__(model, data, **kwargs)
-
- # 设置肩部变体
- # 使用 "none" 作为默认值
- # 使用 "patch-v1" 可以获得更合理的运动外观(未经全面测试)
- self.shoulder_variant = kwargs.get("shoulder_variant", "none")
-
- print(f"✓ MoblArmsIndex 初始化完成,肩部变体: {self.shoulder_variant}")
-
- def _update(self, model, data):
- """
- 更新模型状态
-
- 此方法在每个时间步被调用,用于更新肩部约束。
-
- 参数:
- model: MuJoCo 模型实例
- data: MuJoCo 数据实例
- """
- # 更新肩部等式约束
- if self.shoulder_variant.startswith("patch"):
- # 约束1: shoulder1_r2_con 约束的数据更新
- model.equality("shoulder1_r2_con").data[1] = \
- -((np.pi - 2 * data.joint('shoulder_elv').qpos) / np.pi)
-
- # patch-v2 变体有额外的约束
- if self.shoulder_variant == "patch-v2":
- # 动态调整肩部旋转范围
- data.joint('shoulder_rot').range[:] = \
- np.array([-np.pi / 2, np.pi / 9]) - \
- 2 * np.min((data.joint('shoulder_elv').qpos,
- np.pi - data.joint('shoulder_elv').qpos)) / np.pi \
- * data.joint('elv_angle').qpos
-
- # 执行前向计算,更新物理状态
- mujoco.mj_forward(model, data)
-
- # 打印调试信息(可选)
- if hasattr(self, 'debug') and self.debug:
- print(f"更新肩部约束: shoulder_elv={data.joint('shoulder_elv').qpos:.3f}, "
- f"约束值={model.equality('shoulder1_r2_con').data[1]:.3f}")
-
- @classmethod
- def _get_floor(cls):
- """
- 获取地板配置
-
- 此模型不包含地板,返回 None。
-
- 返回:
- None: 表示此模型没有地板
- """
- return None
-
- def get_force_sensor_data(self, data, sensor_name="index_force_sensor"):
- """
- 获取力传感器数据
-
- 参数:
- data: MuJoCo 数据实例
- sensor_name: 传感器名称,默认为 "index_force_sensor"
-
- 返回:
- numpy.ndarray: 传感器数据,如果没有找到传感器则返回 None
- """
- try:
- sensor_id = self.model.sensor(sensor_name).id
- return data.sensordata[sensor_id]
- except Exception as e:
- if hasattr(self, 'debug') and self.debug:
- print(f"⚠ 无法获取力传感器数据: {e}")
- return None
-
- def set_debug_mode(self, debug=True):
- """
- 设置调试模式
-
- 参数:
- debug: 是否启用调试模式
- """
- self.debug = debug
-
- def __str__(self):
- """
- 返回模型的字符串表示
-
- 返回:
- str: 模型描述
- """
- return (f"MoblArmsIndex 模型 (肩部变体: {self.shoulder_variant})\n"
- f"描述: 基于 MoBL ARMS 的上肢模型,食指弯曲并包含力传感器")
-
-
-# ================ 测试代码 ================
-if __name__ == "__main__":
- """
- 直接运行此文件的测试代码
- """
- print("=" * 60)
- print("MoblArmsIndex 模型测试")
- print("=" * 60)
-
- # 测试导入
- print("1. 测试导入和类定义:")
- print(f" 类名: {MoblArmsIndex.__name__}")
- print(f" 基类: {MoblArmsIndex.__bases__[0].__name__}")
- print(f" 文档: {MoblArmsIndex.__doc__.strip().split('\n')[0]}")
-
- # 测试创建实例(需要 MuJoCo 模型)
- try:
- import mujoco
- import numpy as np
-
- print("\n2. 测试创建实例:")
-
- # 创建一个简单的 MuJoCo 模型用于测试
- xml_string = """
-
-
-
-
-
-
-
-
- """
-
- model = mujoco.MjModel.from_xml_string(xml_string)
- data = mujoco.MjData(model)
-
- # 创建模型实例
- bm_model = MoblArmsIndex(model, data, shoulder_variant="none")
-
- print(f" ✓ 成功创建 MoblArmsIndex 实例")
- print(f" 肩部变体: {bm_model.shoulder_variant}")
-
- # 测试更新方法
- print("\n3. 测试更新方法:")
- bm_model._update(model, data)
- print(" ✓ _update 方法执行成功")
-
- # 测试获取地板
- print("\n4. 测试获取地板:")
- floor = bm_model._get_floor()
- print(f" 地板配置: {floor}")
-
- # 测试字符串表示
- print("\n5. 测试字符串表示:")
- print(f" {bm_model}")
-
- print("\n" + "=" * 60)
- print("所有测试通过!")
- print("=" * 60)
-
- except Exception as e:
- print(f"\n✗ 测试过程中出现错误: {type(e).__name__}: {e}")
- import traceback
- traceback.print_exc()
-
- print("\n" + "=" * 60)
- print("注意: 缺少 MuJoCo 模型文件,但导入测试成功")
- print("=" * 60)
From 6d0fb2111cafac8f2857135cc6db9927a76e0a49 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Fri, 19 Dec 2025 18:09:37 +0800
Subject: [PATCH 06/14] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E9=87=8D=E7=BD=AE=EF=BC=8C=E6=B7=BB=E5=8A=A0base?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/base.py | 364 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 364 insertions(+)
diff --git a/src/box/base.py b/src/box/base.py
index e69de29bb2..8b374acd08 100644
--- a/src/box/base.py
+++ b/src/box/base.py
@@ -0,0 +1,364 @@
+import numpy as np
+import xml.etree.ElementTree as ET
+import pathlib
+import os
+import shutil
+import inspect
+import mujoco
+from abc import ABC, abstractmethod
+import importlib
+from typing import final
+
+from ..utils.functions import parent_path
+from ..utils import element_tree as ETutils
+
+
+class BaseBMModel(ABC):
+
+ def __init__(self, model, data, **kwargs):
+ """Initializes a new `BaseBMModel`.
+
+ Args:
+ model: Mujoco model instance of the simulator.
+ data: Mujoco data instance of the simulator.
+ **kwargs: Many keywords that should be documented somewhere
+ """
+
+ # Initialise mujoco model of the biomechanical model, easier to manipulate things
+ bm_model = mujoco.MjModel.from_xml_path(self.get_xml_file())
+
+ # Get an rng
+ self._rng = np.random.default_rng(kwargs.get("random_seed", None))
+
+ # Total number of actuators
+ self._nu = bm_model.nu
+
+ # Number of muscle actuators
+ self._na = bm_model.na
+
+ # Number of motor actuators
+ self._nm = self._nu - self._na
+ self._motor_act = np.zeros((self._nm,))
+ self._motor_alpha = 0.9
+
+ # Get actuator names (muscle and motor)
+ self._actuator_names = [mujoco.mj_id2name(bm_model, mujoco.mjtObj.mjOBJ_ACTUATOR, i) for i in range(bm_model.nu)]
+ self._muscle_actuator_names = set(np.array(self._actuator_names)[bm_model.actuator_trntype==3])
+ self._motor_actuator_names = set(self._actuator_names) - self._muscle_actuator_names
+
+ # Sort the names to preserve original ordering (not really necessary but looks nicer)
+ self._muscle_actuator_names = sorted(self._muscle_actuator_names, key=self._actuator_names.index)
+ self._motor_actuator_names = sorted(self._motor_actuator_names, key=self._actuator_names.index)
+
+ # Find actuator indices in the simulation
+ self._muscle_actuators = [mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, actuator_name)
+ for actuator_name in self._muscle_actuator_names]
+ self._motor_actuators = [mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, actuator_name)
+ for actuator_name in self._motor_actuator_names]
+
+ # Get joint names (dependent and independent)
+ self._joint_names = [mujoco.mj_id2name(bm_model, mujoco.mjtObj.mjOBJ_JOINT, i) for i in range(bm_model.njnt)]
+ self._dependent_joint_names = {self._joint_names[idx] for idx in
+ np.unique(bm_model.eq_obj1id[bm_model.eq_active.astype(bool)])} \
+ if bm_model.eq_obj1id is not None else set()
+ self._independent_joint_names = set(self._joint_names) - self._dependent_joint_names
+
+ # Sort the names to preserve original ordering (not really necessary but looks nicer)
+ self._dependent_joint_names = sorted(self._dependent_joint_names, key=self._joint_names.index)
+ self._independent_joint_names = sorted(self._independent_joint_names, key=self._joint_names.index)
+
+ # Find dependent and independent joint indices in the simulation
+ self._dependent_joints = [mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, joint_name)
+ for joint_name in self._dependent_joint_names]
+ self._independent_joints = [mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, joint_name)
+ for joint_name in self._independent_joint_names]
+
+ # If there are 'free' type of joints, we'll need to be more careful with which dof corresponds to
+ # which joint, for both qpos and qvel/qacc. There should be exactly one dof per independent/dependent joint.
+ def get_dofs(joint_indices):
+ qpos = []
+ dofs = []
+ for joint_idx in joint_indices:
+ if model.jnt_type[joint_idx] not in [mujoco.mjtJoint.mjJNT_HINGE, mujoco.mjtJoint.mjJNT_SLIDE]:
+ raise NotImplementedError(f"Only 'hinge' and 'slide' joints are supported, joint "
+ f"{self._joint_names[joint_idx]} is of type {mujoco.mjtJoint(model.jnt_type[joint_idx]).name}")
+ qpos.append(model.jnt_qposadr[joint_idx])
+ dofs.append(model.jnt_dofadr[joint_idx])
+ return qpos, dofs
+ self._dependent_qpos, self._dependent_dofs = get_dofs(self._dependent_joints)
+ self._independent_qpos, self._independent_dofs = get_dofs(self._independent_joints)
+
+ # Get the effort model; some models might need to know dt
+ self._effort_model = self.get_effort_model(kwargs.get("effort_model", {"cls": "Zero"}), dt=kwargs["dt"])
+
+ # Define signal-dependent noise
+ self._sigdepnoise_type = kwargs.get("sigdepnoise_type", None) #"white")
+ self._sigdepnoise_level = kwargs.get("sigdepnoise_level", 0.103)
+ self._sigdepnoise_rng = np.random.default_rng(kwargs.get("random_seed", None))
+ self._sigdepnoise_acc = 0 #only used for red/Brownian noise
+
+ # Define constant (i.e., signal-independent) noise
+ self._constantnoise_type = kwargs.get("constantnoise_type", None) #"white")
+ self._constantnoise_level = kwargs.get("constantnoise_level", 0.185)
+ self._constantnoise_rng = np.random.default_rng(kwargs.get("random_seed", None))
+ self._constantnoise_acc = 0 #only used for red/Brownian noise
+
+ ############ The methods below you should definitely overwrite as they are important ############
+
+ @classmethod
+ @abstractmethod
+ def _get_floor(cls):
+ """ If there's a floor in the bm_model.xml file it should be defined here.
+
+ Returns:
+ * None if there is no floor in the file
+ * A dict like {"tag": "geom", "name": "name-of-the-geom"}, where "tag" indicates what kind of element the floor
+ is, and "name" is the name of the element.
+ """
+ pass
+
+
+ ############ The methods below are overwritable but often don't need to be overwritten ############
+
+ def _reset(self, model, data):
+ """ Resets the biomechanical model. """
+
+ # Randomly sample qpos, qvel, act around zero values
+ nq = len(self._independent_qpos)
+ qpos = self._rng.uniform(low=np.ones((nq,))*-0.05, high=np.ones((nq,))*0.05)
+ qvel = self._rng.uniform(low=np.ones((nq,))*-0.05, high=np.ones((nq,))*0.05)
+ act = self._rng.uniform(low=np.zeros((self._na,)), high=np.ones((self._na,)))
+
+ # Set qpos and qvel
+ data.qpos[self._dependent_qpos] = 0
+ data.qpos[self._independent_qpos] = qpos
+ data.qvel[self._dependent_dofs] = 0
+ data.qvel[self._independent_dofs] = qvel
+ data.act[self._muscle_actuators] = act
+
+ # Sample random initial values for motor activation
+ self._motor_act = self._rng.uniform(low=np.zeros((self._nm,)), high=np.ones((self._nm,)))
+ # Reset smoothed average of motor actuator activation
+ self._motor_smooth_avg = np.zeros((self._nm,))
+
+ # Reset accumulative noise
+ self._sigdepnoise_acc = 0
+ self._constantnoise_acc = 0
+
+ def _update(self, model, data):
+ """ Update the biomechanical model after a step has been taken in the simulator. """
+ pass
+
+ def _get_state(self, model, data):
+ """ Return the state of the biomechanical model. These states are used only for logging/evaluation, not for RL
+ training
+
+ Args:
+ model: Mujoco model instance of the simulator.
+ data: Mujoco data instance of the simulator.
+
+ Returns:
+ A dict where each key should have a float or a numpy vector as their value
+ """
+ return dict()
+
+ def set_ctrl(self, model, data, action):
+ """ Set control values for the biomechanical model.
+
+ Args:
+ model: Mujoco model instance of the simulator.
+ data: Mujoco data instance of the simulator.
+ action: Action values between [-1, 1]
+
+ """
+
+ _selected_motor_control = np.clip(self._motor_act + action[:self._nm], 0, 1)
+ _selected_muscle_control = np.clip(data.act[self._muscle_actuators] + action[self._nm:], 0, 1)
+
+ if self._sigdepnoise_type is not None:
+ if self._sigdepnoise_type == "white":
+ _added_noise = self._sigdepnoise_level*self._sigdepnoise_rng.normal(scale=_selected_muscle_control)
+ _selected_muscle_control += _added_noise
+ elif self._sigdepnoise_type == "whiteonly": #only for debugging purposes
+ _selected_muscle_control = self._sigdepnoise_level*self._sigdepnoise_rng.normal(scale=_selected_muscle_control)
+ elif self._sigdepnoise_type == "red":
+ # self._sigdepnoise_acc *= 1 - 0.1
+ self._sigdepnoise_acc += self._sigdepnoise_level*self._sigdepnoise_rng.normal(scale=_selected_muscle_control)
+ _selected_muscle_control += self._sigdepnoise_acc
+ else:
+ raise NotImplementedError(f"{self._sigdepnoise_type}")
+ if self._constantnoise_type is not None:
+ if self._constantnoise_type == "white":
+ _selected_muscle_control += self._constantnoise_level*self._constantnoise_rng.normal(scale=1)
+ elif self._constantnoise_type == "whiteonly": #only for debugging purposes
+ _selected_muscle_control = self._constantnoise_level*self._constantnoise_rng.normal(scale=1)
+ elif self._constantnoise_type == "red":
+ self._constantnoise_acc += self._constantnoise_level*self._constantnoise_rng.normal(scale=1)
+ _selected_muscle_control += self._constantnoise_acc
+ else:
+ raise NotImplementedError(f"{self._constantnoise_type}")
+
+ # Update smoothed online estimate of motor actuation
+ self._motor_act = (1 - self._motor_alpha) * self._motor_act \
+ + self._motor_alpha * np.clip(_selected_motor_control, 0, 1)
+
+ data.ctrl[self._motor_actuators] = model.actuator_ctrlrange[self._motor_actuators,0] + self._motor_act*(model.actuator_ctrlrange[self._motor_actuators, 1] - model.actuator_ctrlrange[self._motor_actuators, 0])
+ data.ctrl[self._muscle_actuators] = np.clip(_selected_muscle_control, 0, 1)
+
+
+ @classmethod
+ def get_xml_file(cls):
+ """ Overwrite this method if you want to call the mujoco xml file something other than 'bm_model.xml'. """
+ return os.path.join(parent_path(inspect.getfile(cls)), "bm_model.xml")
+
+ def get_effort_model(self, specs, dt):
+ """ Returns an initialised object of the effort model class.
+
+ Overwrite this method if you want to define your effort models somewhere else. But note that in that case you need
+ to overwrite the 'clone' method as well since it assumes the effort models are defined in
+ uitb.bm_models.effort_models.
+
+ Args:
+ specs: Specifications of the effort model, in format of
+ {"cls": "name-of-class", "kwargs": {"kw1": value1, "kw2": value2}}}
+ dt: Elapsed time between two consecutive simulation steps
+
+ Returns:
+ An instance of a class that inherits from the uitb.bm_models.effort_models.BaseEffortModel class
+ """
+ module = importlib.import_module(".".join(BaseBMModel.__module__.split(".")[:-1]) + ".effort_models")
+ return getattr(module, specs["cls"])(self, **{**specs.get("kwargs", {}), **{"dt": dt}})
+
+ @classmethod
+ def clone(cls, simulator_folder, package_name):
+ """ Clones (i.e. copies) the relevant python files into a new location.
+
+ Args:
+ simulator_folder: Location of the simulator.
+ package_name: Name of the simulator (which is a python package)
+ """
+
+ # Create 'bm_models' folder
+ dst = os.path.join(simulator_folder, package_name, "bm_models")
+ os.makedirs(dst, exist_ok=True)
+
+ # Copy this file
+ base_file = pathlib.Path(__file__)
+ shutil.copyfile(base_file, os.path.join(dst, base_file.name))
+
+ # Create an __init__.py file with the relevant import
+ modules = cls.__module__.split(".")
+ with open(os.path.join(dst, "__init__.py"), "w") as file:
+ file.write("from ." + ".".join(modules[2:]) + " import " + cls.__name__)
+
+ # Copy bm-model folder
+ src = parent_path(inspect.getfile(cls))
+ shutil.copytree(src, os.path.join(dst, src.stem), dirs_exist_ok=True)
+
+ # Copy assets
+ shutil.copytree(os.path.join(src, "assets"), os.path.join(simulator_folder, package_name, "assets"),
+ dirs_exist_ok=True)
+
+ # Copy effort models
+ shutil.copyfile(os.path.join(base_file.parent, "effort_models.py"), os.path.join(dst, "effort_models.py"))
+
+ @classmethod
+ def insert(cls, simulator_tree):
+ """ Inserts the biomechanical model into the simulator by integrating the xml files together.
+
+ Args:
+ simulator_tree: An `xml.etree.ElementTree` containing the parsed simulator xml file
+ """
+
+ # Parse xml file
+ bm_tree = ET.parse(cls.get_xml_file())
+ bm_root = bm_tree.getroot()
+
+ # Get simulator root
+ simulator_root = simulator_tree.getroot()
+
+ # Add defaults
+ ETutils.copy_or_append("default", bm_root, simulator_root)
+
+ # Add assets, except skybox
+ ETutils.copy_children("asset", bm_root, simulator_root,
+ exclude={"tag": "texture", "attrib": "type", "name": "skybox"})
+
+ # Add bodies, except floor/ground TODO this might not be currently working
+ if cls._get_floor() is not None:
+ floor = cls._get_floor()
+ ETutils.copy_children("worldbody", bm_root, simulator_root,
+ exclude={"tag": floor["tag"], "attrib": "name", "name": floor["name"]})
+ else:
+ ETutils.copy_children("worldbody", bm_root, simulator_root)
+
+ # Add tendons
+ ETutils.copy_children("tendon", bm_root, simulator_root)
+
+ # Add actuators
+ ETutils.copy_children("actuator", bm_root, simulator_root)
+
+ # Add equality constraints
+ ETutils.copy_children("equality", bm_root, simulator_root)
+
+ def close(self, **kwargs):
+ """ Perform any necessary clean up. """
+ pass
+
+ ############ The methods below you should not overwrite ############
+
+ @final
+ def update(self, model, data):
+ """ Updates the biomechanical model and effort model. """
+ self._update(model, data)
+ self._effort_model.update(model, data)
+
+ @final
+ def reset(self, model, data):
+ """ Resets the biomechanical model and effort model. """
+ self._reset(model, data)
+ self._effort_model.reset(model, data)
+ self.update(model, data)
+ mujoco.mj_forward(model, data)
+
+ @final
+ def get_state(self, model, data):
+ """ Returns the state of the biomechanical model (as a dict). """
+ state = dict()
+ state.update(self._get_state(model, data))
+ state.update(self._effort_model._get_state(model, data))
+ return state
+
+ @final
+ def get_effort_cost(self, model, data):
+ """ Returns effort cost from the effort model. """
+ return self._effort_model.cost(model, data)
+
+ @property
+ @final
+ def independent_joints(self):
+ """ Returns indices of independent joints. """
+ return self._independent_joints.copy()
+
+ @property
+ @final
+ def independent_qpos(self):
+ """ Returns qpos indices of independent joints. """
+ return self._independent_qpos.copy()
+
+ @property
+ @final
+ def independent_dofs(self):
+ """ Returns qvel/qacc indices of independent joints. """
+ return self._independent_dofs.copy()
+
+ @property
+ @final
+ def nu(self):
+ """ Returns number of actuators (both muscle and motor). """
+ return self._nu
+
+ @property
+ def motor_act(self):
+ """ Returns (smoothed average of) motor actuation. """
+ return self._motor_act
\ No newline at end of file
From 5385078fec41c529041f08a58f11cd8252c3b5a1 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Fri, 19 Dec 2025 22:39:06 +0800
Subject: [PATCH 07/14] BasicWithEndEffectorPosition
---
src/box/BasicWithEndEffectorPosition.py | 139 ++++++++++++++++++++++++
1 file changed, 139 insertions(+)
create mode 100644 src/box/BasicWithEndEffectorPosition.py
diff --git a/src/box/BasicWithEndEffectorPosition.py b/src/box/BasicWithEndEffectorPosition.py
new file mode 100644
index 0000000000..63b3a839c2
--- /dev/null
+++ b/src/box/BasicWithEndEffectorPosition.py
@@ -0,0 +1,139 @@
+# 从上级包的base模块导入基础模块类(UITB框架的核心基类)
+from ...base import BaseModule
+# 导入数值计算库,用于数组操作
+import numpy as np
+
+
+class BasicWithEndEffectorPosition(BaseModule):
+ """
+ 本体感知模块:整合关节状态、肌肉激活度和末端执行器位置的观测生成器
+ 核心功能:
+ 1. 标准化关节角度、速度、加速度、肌肉激活值
+ 2. 获取末端执行器(如手部、机械爪)的全局位置
+ 3. 拼接所有感知信息,输出一维观测向量(适配强化学习输入)
+ 继承自:BaseModule(UITB框架的基础模块类,提供模拟器交互接口)
+ """
+
+ def __init__(self, model, data, bm_model, end_effector, **kwargs):
+ """
+ 初始化感知模块实例
+ Args:
+ model: Mujoco模拟器的模型实例(存储物理模型结构,如关节、刚体、执行器)
+ data: Mujoco模拟器的数据实例(存储实时物理数据,如关节角度、速度、力)
+ bm_model: 生物力学模型实例(继承自uitb.bm_models.base.BaseBMModel,提供关节/执行器映射)
+ end_effector (list of lists): 末端执行器定义,格式示例:
+ - 单末端执行器:[["site", "right_hand_site"]]
+ - 多末端执行器:[["body", "left_hand"], ["geom", "right_foot"]]
+ 每个子列表第一个元素是Mujoco元素类型(geom/body/site),第二个是元素名称
+ **kwargs: 可选参数(如"rng"随机数种子)
+ """
+ # 调用父类初始化方法,传入模拟器实例和生物力学模型
+ super().__init__(model, data, bm_model,** kwargs)
+
+ # 校验末端执行器参数格式:必须是列表类型
+ if not isinstance(end_effector, list):
+ raise RuntimeError(
+ "end_effector必须是长度为2的列表,或嵌套列表(每个子列表长度为2)"
+ )
+
+ # 检查是否为单层列表(如["site", "hand"]),若是则转为嵌套列表(统一格式)
+ if isinstance(end_effector[0], str):
+ end_effector = [end_effector]
+
+ # 校验所有嵌套子列表的长度必须为2(类型+名称)
+ if any(len(pair) != 2 for pair in end_effector):
+ raise RuntimeError("end_effector的每个子列表必须包含2个元素:类型+名称")
+
+ # 保存标准化后的末端执行器配置(实例变量,供后续方法调用)
+ self._end_effector = end_effector
+
+ @staticmethod
+ def insert(task, **kwargs):
+ """
+ 静态方法:用于将模块插入到任务配置中(UITB框架预留接口,暂无实现)
+ Args:
+ task: 任务实例(包含模拟器、评估指标等)
+ **kwargs: 扩展参数
+ """
+ pass
+
+ @property
+ def _default_encoder(self):
+ """
+ 属性方法:返回默认的编码器配置(适配强化学习的特征编码)
+ 此处配置:单层全连接编码器,输出特征维度为128
+ Returns:
+ dict: 编码器的模块路径、类名、参数
+ """
+ return {
+ "module": "rl.encoders", # 编码器模块路径
+ "cls": "OneLayer", # 编码器类名(单层全连接)
+ "kwargs": {"out_features": 128} # 编码器参数:输出特征维度
+ }
+
+ def get_observation(self, model, data, info=None):
+ """
+ 核心方法:生成标准化的感知观测向量
+ Args:
+ model: Mujoco模型实例(同__init__)
+ data: Mujoco数据实例(同__init__,存储实时数据)
+ info: 额外信息字典(可选,如环境状态)
+ Returns:
+ np.ndarray: 一维数组,包含所有标准化的感知特征
+ """
+ # -------------------------- 1. 标准化关节角度(qpos) --------------------------
+ # 获取独立关节的角度范围(model.jnt_range:[min, max])
+ jnt_range = model.jnt_range[self._bm_model.independent_joints]
+ # 复制独立关节的当前角度(避免修改原数据)
+ qpos = data.qpos[self._bm_model.independent_qpos].copy()
+ # 第一步归一化:将角度映射到[0, 1]区间
+ qpos = (qpos - jnt_range[:, 0]) / (jnt_range[:, 1] - jnt_range[:, 0])
+ # 第二步归一化:将角度映射到[-1, 1]区间(适配神经网络输入)
+ qpos = (qpos - 0.5) * 2
+
+ # -------------------------- 2. 获取关节速度/加速度 --------------------------
+ # 复制独立自由度的关节速度(原始值,未标准化)
+ qvel = data.qvel[self._bm_model.independent_dofs].copy()
+ # 复制独立自由度的关节加速度(原始值,未标准化)
+ qacc = data.qacc[self._bm_model.independent_dofs].copy()
+
+ # -------------------------- 3. 获取末端执行器全局位置 --------------------------
+ ee_position = [] # 存储所有末端执行器的位置
+ for pair in self._end_effector:
+ # pair[0]:Mujoco元素类型(geom/body/site),pair[1]:元素名称
+ # getattr(data, pair[0])(pair[1]):获取对应元素的实例
+ # .xpos:获取元素的全局坐标(3维:x,y,z)
+ ee_pos = getattr(data, pair[0])(pair[1]).xpos.copy()
+ ee_position.append(ee_pos)
+ # 将多个末端执行器的位置拼接为一维数组(如2个末端执行器则为6维)
+ ee_position = np.hstack(ee_position)
+
+ # -------------------------- 4. 标准化肌肉/执行器激活值 --------------------------
+ # data.act:所有执行器的当前激活值(范围通常[0,1]),映射到[-1,1]
+ act = (data.act.copy() - 0.5) * 2
+ # 生物力学模型中平滑后的电机激活值,同样映射到[-1,1]
+ motor_act = (self._bm_model.motor_act.copy() - 0.5) * 2
+
+ # -------------------------- 5. 拼接所有感知特征 --------------------------
+ # 本体感知特征:关节角度+速度+加速度+末端位置+执行器激活+电机激活
+ proprioception = np.concatenate([qpos, qvel, qacc, ee_position, act, motor_act])
+
+ # 返回最终的观测向量(一维数组,可直接输入强化学习网络)
+ return proprioception
+
+ def _get_state(self, model, data):
+ """
+ 辅助方法:获取末端执行器的详细状态(用于日志/评估,非训练观测)
+ Args:
+ model: Mujoco模型实例
+ data: Mujoco数据实例
+ Returns:
+ dict: 键为"元素名称_xpos/xmat",值为对应的位置/旋转矩阵
+ """
+ state = {}
+ for pair in self._end_effector:
+ # 存储末端执行器的全局位置(xpos:3维坐标)
+ state[f"{pair[1]}_xpos"] = getattr(data, pair[0])(pair[1]).xpos.copy()
+ # 存储末端执行器的旋转矩阵(xmat:9维,描述空间姿态)
+ state[f"{pair[1]}_xmat"] = getattr(data, pair[0])(pair[1]).xmat.copy()
+ return state
\ No newline at end of file
From e806a68506f22efeeca1a7b16376e030b179d614 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Mon, 22 Dec 2025 14:25:56 +0800
Subject: [PATCH 08/14] =?UTF-8?q?=E4=B8=89=E5=85=B3=E8=8A=823d?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/jy.py | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 163 insertions(+)
create mode 100644 src/box/jy.py
diff --git a/src/box/jy.py b/src/box/jy.py
new file mode 100644
index 0000000000..71001e171f
--- /dev/null
+++ b/src/box/jy.py
@@ -0,0 +1,163 @@
+import sys
+import math
+import numpy as np
+from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
+ QHBoxLayout, QSlider, QLabel, QGroupBox, QGridLayout)
+from PyQt5.QtCore import Qt, QTimer
+import pyqtgraph.opengl as gl
+
+class ArmLink:
+ """机械臂连杆类,定义单个连杆的属性和绘制方法"""
+ def __init__(self, length, radius=0.1):
+ self.length = length # 连杆长度
+ self.radius = radius # 连杆半径
+ self.angle = 0 # 关节角度
+ self.pos = np.array([0, 0, 0]) # 连杆起始位置
+ self.vertices = None # 连杆顶点数据
+ self.color = [0.2, 0.6, 0.8, 1.0] # 连杆颜色(蓝绿色)
+
+ def update_position(self, parent_pos, parent_angle):
+ """根据父连杆的位置和角度更新当前连杆的位置"""
+ # 计算当前连杆的绝对角度
+ self.angle = parent_angle + self.angle
+
+ # 计算连杆末端位置
+ end_x = parent_pos[0] + self.length * math.cos(math.radians(self.angle))
+ end_y = parent_pos[1] + self.length * math.sin(math.radians(self.angle))
+ end_z = parent_pos[2]
+
+ # 生成连杆的圆柱顶点
+ self._create_cylinder(parent_pos, [end_x, end_y, end_z])
+
+ return np.array([end_x, end_y, end_z])
+
+ def _create_cylinder(self, start, end):
+ """创建圆柱几何体"""
+ # 生成圆柱的圆周点
+ theta = np.linspace(0, 2*np.pi, 20)
+ x = self.radius * np.cos(theta)
+ y = self.radius * np.sin(theta)
+
+ # 创建圆柱侧面
+ points = []
+ for i in range(len(theta)):
+ points.append([start[0] + x[i], start[1] + y[i], start[2]])
+ points.append([end[0] + x[i], end[1] + y[i], end[2]])
+
+ self.vertices = np.array(points)
+
+class RoboticArmSimulator(QMainWindow):
+ """机械臂仿真主窗口"""
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("3D机械臂仿真")
+ self.setGeometry(100, 100, 1200, 800)
+
+ # 创建机械臂连杆(3个关节的机械臂)
+ self.arm_links = [
+ ArmLink(length=1.0), # 第一连杆
+ ArmLink(length=0.8), # 第二连杆
+ ArmLink(length=0.6) # 第三连杆
+ ]
+
+ # 初始化UI
+ self._init_ui()
+
+ # 设置定时器更新仿真画面
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.update_arm)
+ self.timer.start(30) # 约30fps
+
+ # 动画参数
+ self.animation_angle = 0
+
+ def _init_ui(self):
+ """初始化用户界面"""
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+
+ # 创建布局
+ main_layout = QHBoxLayout(central_widget)
+
+ # 创建3D绘图区域
+ self.gl_widget = gl.GLViewWidget()
+ self.gl_widget.setCameraPosition(distance=5, elevation=30, azimuth=45)
+ main_layout.addWidget(self.gl_widget, 3)
+
+ # 添加坐标系
+ axis = gl.GLAxisItem()
+ axis.setSize(2, 2, 2)
+ self.gl_widget.addItem(axis)
+
+ # 创建控制面板
+ control_panel = QGroupBox("关节控制")
+ control_layout = QGridLayout(control_panel)
+
+ # 创建关节角度滑块
+ self.sliders = []
+ for i, link in enumerate(self.arm_links):
+ label = QLabel(f"关节 {i+1} 角度:")
+ slider = QSlider(Qt.Horizontal)
+ slider.setRange(-180, 180)
+ slider.setValue(0)
+ slider.valueChanged.connect(self.on_slider_changed)
+
+ value_label = QLabel("0°")
+ self.sliders.append((slider, value_label))
+
+ control_layout.addWidget(label, i, 0)
+ control_layout.addWidget(slider, i, 1)
+ control_layout.addWidget(value_label, i, 2)
+
+ main_layout.addWidget(control_panel, 1)
+
+ # 创建机械臂的3D绘制对象
+ self.arm_items = []
+ for link in self.arm_links:
+ item = gl.GLLinePlotItem(pos=link.vertices, color=link.color, width=2, antialias=True)
+ self.arm_items.append(item)
+ self.gl_widget.addItem(item)
+
+ # 添加机械臂末端点
+ self.end_effector = gl.GLScatterPlotItem(pos=np.array([[0,0,0]]), size=10, color=[1,0,0,1])
+ self.gl_widget.addItem(self.end_effector)
+
+ def on_slider_changed(self):
+ """滑块值变化时更新关节角度"""
+ for i, (slider, label) in enumerate(self.sliders):
+ angle = slider.value()
+ label.setText(f"{angle}°")
+ self.arm_links[i].angle = angle
+
+ def update_arm(self):
+ """更新机械臂的位置和显示"""
+ # 基础位置(机械臂底座)
+ current_pos = np.array([0, 0, 0])
+ current_angle = 0
+
+ # 如果滑块没有被手动调整,自动动画演示
+ if all(slider[0].value() == 0 for slider in self.sliders):
+ self.animation_angle += 1
+ for i, link in enumerate(self.arm_links):
+ link.angle = 45 * math.sin(math.radians(self.animation_angle + i * 120))
+ self.sliders[i][0].setValue(int(link.angle))
+ self.sliders[i][1].setText(f"{int(link.angle)}°")
+
+ # 更新每个连杆的位置
+ for i, (link, item) in enumerate(zip(self.arm_links, self.arm_items)):
+ current_pos = link.update_position(current_pos, current_angle)
+ current_angle = link.angle
+ item.setData(pos=link.vertices)
+
+ # 更新末端执行器位置
+ self.end_effector.setData(pos=np.array([current_pos]))
+
+if __name__ == "__main__":
+ # 设置高DPI支持(解决Windows下界面模糊问题)
+ QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
+ QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)
+
+ app = QApplication(sys.argv)
+ simulator = RoboticArmSimulator()
+ simulator.show()
+ sys.exit(app.exec_())
\ No newline at end of file
From 9c0de9b2fad44b9c4024c154b46a9e75007cae16 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Mon, 22 Dec 2025 19:50:14 +0800
Subject: [PATCH 09/14] =?UTF-8?q?=E7=89=A9=E7=90=86=E6=A8=A1=E5=9E=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../arm_simulation/MoblArmsIndex.py | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 src/box/simulators/arm_simulation/MoblArmsIndex.py
diff --git a/src/box/simulators/arm_simulation/MoblArmsIndex.py b/src/box/simulators/arm_simulation/MoblArmsIndex.py
new file mode 100644
index 0000000000..b9c8faafd5
--- /dev/null
+++ b/src/box/simulators/arm_simulation/MoblArmsIndex.py
@@ -0,0 +1,37 @@
+from ..base import BaseBMModel
+
+import numpy as np
+import mujoco
+
+
+class MoblArmsIndex(BaseBMModel):
+ """This model is based on the MoBL ARMS model, see https://simtk.org/frs/?group_id=657 for the original model in OpenSim,
+ and https://github.com/aikkala/O2MConverter for the MuJoCo converted model. This model is the same as the one in uitb/bm_models/mobl_arms, except
+ the index finger is flexed and it contains a force sensor. """
+
+ def __init__(self, model, data, **kwargs):
+ super().__init__(model, data, **kwargs)
+
+ # Set shoulder variant; use "none" as default, use "patch-v1" for a qualitatively more reasonable looking movements (not thoroughly tested)
+ self.shoulder_variant = kwargs.get("shoulder_variant", "none")
+
+ def _update(self, model, data):
+
+ # Update shoulder equality constraints
+ if self.shoulder_variant.startswith("patch"):
+ model.equality("shoulder1_r2_con").data[1] = \
+ -((np.pi - 2 * data.joint('shoulder_elv').qpos) / np.pi)
+
+ if self.shoulder_variant == "patch-v2":
+ data.joint('shoulder_rot').range[:] = \
+ np.array([-np.pi / 2, np.pi / 9]) - \
+ 2 * np.min((data.joint('shoulder_elv').qpos,
+ np.pi - data.joint('shoulder_elv').qpos)) / np.pi \
+ * data.joint('elv_angle').qpos
+
+ # Do a forward calculation
+ mujoco.mj_forward(model, data)
+
+ @classmethod
+ def _get_floor(cls):
+ return None
From 788b27805dcb14b0e78e610e5d2f365773ecfd04 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Mon, 22 Dec 2025 21:27:30 +0800
Subject: [PATCH 10/14] =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=8F=90=E4=BA=A4?=
=?UTF-8?q?=E5=8A=AA=E5=8A=9B=E6=A8=A1=E5=9E=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../arm_simulation/effort_models.py | 305 ++++++++++++++++++
1 file changed, 305 insertions(+)
create mode 100644 src/box/simulators/arm_simulation/effort_models.py
diff --git a/src/box/simulators/arm_simulation/effort_models.py b/src/box/simulators/arm_simulation/effort_models.py
new file mode 100644
index 0000000000..040a2040e5
--- /dev/null
+++ b/src/box/simulators/arm_simulation/effort_models.py
@@ -0,0 +1,305 @@
+from abc import ABC, abstractmethod
+import mujoco
+import numpy as np
+
+
+class BaseEffortModel(ABC):
+
+ def __init__(self, bm_model, **kwargs):
+ self._bm_model = bm_model
+
+ @abstractmethod
+ def cost(self, model, data):
+ pass
+
+ @abstractmethod
+ def reset(self, model, data):
+ pass
+
+ @abstractmethod
+ def update(self, model, data):
+ # If needed to e.g. reduce max force output
+ pass
+
+ def _get_state(self, model, data):
+ """ Return the state of the effort model. These states are used only for logging/evaluation, not for RL
+ training
+
+ Args:
+ model: Mujoco model instance of the simulator.
+ data: Mujoco data instance of the simulator.
+
+ Returns:
+ A dict where each key should have a float or a numpy vector as their value
+ """
+ return dict()
+
+
+class Composite(BaseEffortModel):
+
+ def __init__(self, bm_model, weight=1e-7, **kwargs):
+ super().__init__(bm_model)
+ self._weight = weight
+
+ def cost(self, model, data):
+ mujoco.cymj._mj_inverse(model, data) # TODO does this work with new mujoco python bindings?
+ angle_acceleration = np.sum(data.qacc[self._bm_model.independent_dofs] ** 2)
+ energy = np.sum(data.qvel[self._bm_model.independent_dofs] ** 2
+ * data.qfrc_inverse[self._bm_model.independent_dofs] ** 2)
+ return self._weight * (energy + 0.05 * angle_acceleration)
+
+ def reset(self, model, data):
+ pass
+
+ def update(self, model, data):
+ pass
+
+
+class CumulativeFatigue(BaseEffortModel):
+
+ # 3CC-r model, adapted from https://dl.acm.org/doi/pdf/10.1145/3313831.3376701 for muscles -- using control signals
+ # instead of torque or force
+ def __init__(self, bm_model, dt, **kwargs):
+ super().__init__(bm_model)
+ self._r = 7.5
+ self._F = 0.0146
+ self._R = 0.0022
+ self._LD = 10
+ self._LR = 10
+ self._MA = None
+ self._MR = None
+ self._weight = 0.01
+ self._dt = dt
+
+ def cost(self, model, data):
+
+ # Initialise MA if not yet initialised
+ if self._MA is None:
+ self._MA = np.zeros((model.na,))
+ self._MR = np.ones((model.na,))
+
+ # Get target load (actual activation, which might be reached only with some "effort", depending on how many muscles can be activated (fast enough) and how many are in fatigue state)
+ TL = data.act.copy()
+
+ # Calculate C(t)
+ C = np.zeros_like(self._MA)
+ idxs = (self._MA < TL) & (self._MR > (TL - self._MA))
+ C[idxs] = self._LD * (TL[idxs] - self._MA[idxs])
+ idxs = (self._MA < TL) & (self._MR <= (TL - self._MA))
+ C[idxs] = self._LD * self._MR[idxs]
+ idxs = self._MA >= TL
+ C[idxs] = self._LR * (TL[idxs] - self._MA[idxs])
+
+ # Calculate rR
+ rR = np.zeros_like(self._MA)
+ idxs = self._MA >= TL
+ rR[idxs] = self._r*self._R
+ idxs = self._MA < TL
+ rR[idxs] = self._R
+
+ # Calculate MA, MR
+ self._MA += (C - self._F*self._MA)*self._dt
+ self._MR += (-C + rR*self._MR)*self._dt
+
+ # Not sure if these are needed
+ self._MA = np.clip(self._MA, 0, 1)
+ self._MR = np.clip(self._MR, 0, 1)
+
+ # Calculate effort
+ effort = np.linalg.norm(self._MA - TL)
+
+ return self._weight*effort
+
+ def reset(self, model, data):
+ self._MA = None
+ self._MR = None
+
+ def update(self, model, data):
+ pass
+
+
+class CumulativeFatigue3CCr(BaseEffortModel):
+
+ # 3CC-r model, adapted from https://dl.acm.org/doi/pdf/10.1145/3313831.3376701 for muscles -- using control signals
+ # instead of torque or force
+ # v2: now with additional muscle fatigue state
+ def __init__(self, bm_model, dt, weight=0.01, **kwargs):
+ super().__init__(bm_model)
+ self._r = 7.5
+ self._F = 0.0146
+ self._R = 0.0022
+ self._LD = 10
+ self._LR = 10
+ self._MA = None
+ self._MR = None
+ self._MF = None
+ self._TL = None
+ self._dt = dt
+ self._weight = weight
+ self._effort_cost = None
+
+ def cost(self, model, data):
+ # Calculate effort
+ effort = np.linalg.norm(self._MA - self._TL)
+ self._effort_cost = self._weight*effort
+ return self._effort_cost
+
+ def reset(self, model, data):
+ self._MA = np.zeros((model.na,))
+ self._MR = np.ones((model.na,))
+ self._MF = np.zeros((model.na,))
+
+ def update(self, model, data):
+ # Get target load
+ TL = data.act.copy()
+ self._TL = TL
+
+ # Calculate C(t)
+ C = np.zeros_like(self._MA)
+ idxs = (self._MA < TL) & (self._MR > (TL - self._MA))
+ C[idxs] = self._LD * (TL[idxs] - self._MA[idxs])
+ idxs = (self._MA < TL) & (self._MR <= (TL - self._MA))
+ C[idxs] = self._LD * self._MR[idxs]
+ idxs = self._MA >= TL
+ C[idxs] = self._LR * (TL[idxs] - self._MA[idxs])
+
+ # Calculate rR
+ rR = np.zeros_like(self._MA)
+ idxs = self._MA >= TL
+ rR[idxs] = self._r*self._R
+ idxs = self._MA < TL
+ rR[idxs] = self._R
+
+ # Clip C(t) if needed, to ensure that MA, MR, and MF remain between 0 and 1
+ C = np.clip(C, np.maximum(-self._MA/self._dt + self._F*self._MA, (self._MR - 1)/self._dt + rR*self._MF),
+ np.minimum((1 - self._MA)/self._dt + self._F*self._MA, self._MR/self._dt + rR*self._MF))
+
+ # Update MA, MR, MF
+ dMA = (C - self._F*self._MA)*self._dt
+ dMR = (-C + rR*self._MF)*self._dt
+ dMF = (self._F*self._MA - rR*self._MF)*self._dt
+ self._MA += dMA
+ self._MR += dMR
+ self._MF += dMF
+
+ def _get_state(self, model, data):
+ state = {"3CCr_MA": self._MA,
+ "3CCr_MR": self._MR,
+ "3CCr_MF": self._MF,
+ "effort_cost": self._effort_cost}
+ return state
+
+
+class ConsumedEndurance(BaseEffortModel):
+
+ lifting_muscles = ["DELT1", "DELT2", "DELT3", "SUPSP", "INFSP", "SUBSC", "TMIN", "BIClong", "BICshort", "TRIlong", "TRIlat", "TRImed"]
+
+ # consumed endurance model, taken from https://dl.acm.org/doi/pdf/10.1145/2556288.2557130
+ def __init__(self, bm_model, dt, weight=0.01, **kwargs):
+ super().__init__(bm_model)
+ self._dt = dt
+ self._weight = weight
+ self._endurance = None
+ self._consumed_endurance = None
+ self._effort_cost = None
+
+ def get_endurance(self, model, data):
+ #applied_shoulder_torque = np.linalg.norm(data.qfrc_inverse[:])
+ #applied_shoulder_torque = np.linalg.norm(data.qfrc_actuator[:])
+ lifting_indices = [model.actuator(_i).id for _i in self.lifting_muscles]
+ applied_shoulder_torques = data.actuator_force[lifting_indices]
+ maximum_shoulder_torques = model.actuator_gainprm[lifting_indices, 2]
+ #assert np.all(applied_shoulder_torque <= 0), "Expected only negative values in data.actuator_force."
+ #strength = np.mean((applied_shoulder_torques/maximum_shoulder_torques)**2)
+ strength = np.abs(applied_shoulder_torques/maximum_shoulder_torques) #compute strength per muscle
+ # assert np.all(strength <= 1), f"Applied torque is larger than maximum torque! strength:{strength}, applied:{applied_shoulder_torques}, max:{maximum_shoulder_torques}"
+ strength = strength.clip(0, 1)
+
+ # if strength > 0.15:
+ # endurance = (1236.5/((strength*100 - 15)**0.618)) - 72.5
+ # else:
+ # endurance = np.inf
+
+ endurance = np.inf * np.ones_like(strength)
+ endurance[strength > 0.15] = (1236.5/((strength[strength > 0.15]*100 - 15)**0.618)) - 72.5
+
+ minimum_endurance = np.min(endurance)
+ # TODO: take minimum of each muscle synergy, and then apply sum/mean
+
+ return minimum_endurance
+
+ def cost(self, model, data):
+ # Calculate consumed endurance
+ self._endurance = self.get_endurance(model, data)
+ #total_time = data.time
+ consumed_time = self._dt
+
+ if self._endurance < np.inf:
+ self._consumed_endurance = (consumed_time/self._endurance)*100
+ else:
+ self._consumed_endurance = 0.0
+
+ self._effort_cost = self._weight*self._consumed_endurance
+ return self._effort_cost
+
+ def reset(self, model, data):
+ #WARNING: bm_model.reset() should reset simulation time (i.e., data.time==0 before the next costs are calculated)
+ pass
+
+ def update(self, model, data):
+ pass
+
+ def _get_state(self, model, data):
+ state = {"consumed_endurance": self._consumed_endurance,
+ "effort_cost": self._effort_cost}
+ return state
+
+
+class MuscleState(BaseEffortModel):
+
+ def __init__(self, bm_model, weight=1e-4, **kwargs):
+ super().__init__(bm_model)
+ self._weight = weight
+
+ def cost(self, model, data):
+ return self._weight * np.sum(data.act ** 2)
+
+ def reset(self, model, data):
+ pass
+
+ def update(self, model, data):
+ pass
+
+
+class Neural(BaseEffortModel):
+
+ def __init__(self, bm_model, weight=1e-4, **kwargs):
+ super().__init__(bm_model)
+ self._weight = weight
+ self._effort_cost = None
+
+ def cost(self, model, data):
+ self._effort_cost = self._weight * np.sum(data.ctrl ** 2)
+ return self._effort_cost
+
+ def reset(self, model, data):
+ pass
+
+ def update(self, model, data):
+ pass
+
+ def _get_state(self, model, data):
+ state = {"effort_cost": self._effort_cost}
+ return state
+
+
+class Zero(BaseEffortModel):
+
+ def cost(self, model, data):
+ return 0
+
+ def reset(self, model, data):
+ pass
+
+ def update(self, model, data):
+ pass
\ No newline at end of file
From 82213a8d7add9585ac0e4686aa15efaf9b77d7b8 Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Wed, 24 Dec 2025 10:47:17 +0800
Subject: [PATCH 11/14] =?UTF-8?q?=E6=B7=BB=E5=8A=A0README.md?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/ros/README.md | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 src/box/ros/README.md
diff --git a/src/box/ros/README.md b/src/box/ros/README.md
new file mode 100644
index 0000000000..e69de29bb2
From 363985e29357c77e0c700573cd0f4a5fe27e9f0f Mon Sep 17 00:00:00 2001
From: Xinyue-cao <648343923@qq.com>
Date: Fri, 26 Dec 2025 22:01:05 +0800
Subject: [PATCH 12/14] =?UTF-8?q?=E7=BC=96=E5=86=99=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=A8=A1=E5=9D=97=E4=BB=A3=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/box/README.md | 62 +++++++++++++++++++++++++++++++++
src/box/ros/acquisition_node.py | 61 ++++++++++++++++++++++++++++++++
2 files changed, 123 insertions(+)
create mode 100644 src/box/ros/acquisition_node.py
diff --git a/src/box/README.md b/src/box/README.md
index 81ecd8e5a8..342f85b47e 100644
--- a/src/box/README.md
+++ b/src/box/README.md
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
# box — 仿真与强化学习实验箱
## 概述
@@ -82,3 +83,64 @@ python tests/test_simulator.py
- 查看目录下的具体脚本与模块顶部注释,通常包含使用示例与参数说明;
- 若需要,我可以为 `src/box` 中的主要文件生成更详细的文档或示例运行脚本。
+=======
+**box — 仿真与强化学习实验箱**
+
+简介
+- `src/box` 目录包含基于 Gymnasium 和 MuJoCo 的仿真环境与相关辅助脚本,用于开发和测试生物力学/机器人仿真、感知模块与强化学习任务。
+
+目录结构(示例)
+- `simulator.py`:仿真环境核心(通常继承 `gym.Env`)。
+- `test_simulator.py`:示例运行脚本,用于启动仿真并可视化。
+- `main.py`:辅助脚本(例如证书或配置检查)。
+- `README.md`:本文件,说明目录用途与快速上手指南。
+
+快速上手
+1. 创建并激活虚拟环境(以 Windows 为例):
+
+```powershell
+cd <项目根目录>
+python -m venv venv --python=3.9
+.\\venv\\Scripts\\Activate.ps1
+```
+
+2. 安装依赖(建议使用清华镜像加速):
+
+```powershell
+pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+```
+
+如果仓库没有完整的 `requirements.txt`,可参考下列核心库:
+
+```text
+gymnasium
+mujoco
+stable-baselines3
+pygame
+opencv-python
+numpy
+scipy
+matplotlib
+ruamel.yaml
+certifi
+```
+
+运行示例
+- 启动仿真:
+
+```powershell
+python test_simulator.py
+```
+
+运行后应弹出可视化窗口(若使用 Pygame/SDL),并在终端输出仿真日志。
+
+贡献与问题反馈
+- 若需添加说明或示例,请提交 Pull Request。
+- 遇到环境或依赖问题,请在 Issue 中描述操作系统、Python 版本与错误日志。
+
+更多信息
+- 若目录中包含更详细的子模块文档,请参阅相应文件(如 `simulator.py` 顶部注释或同目录下的文档)。
+
+---
+(此 README 为目录概览,具体实现与文件名以代码库为准)
+>>>>>>> ffff1b2d (更新README.md)
diff --git a/src/box/ros/acquisition_node.py b/src/box/ros/acquisition_node.py
new file mode 100644
index 0000000000..b6a421011c
--- /dev/null
+++ b/src/box/ros/acquisition_node.py
@@ -0,0 +1,61 @@
+# 导入ROS 2核心库
+import rclpy
+from rclpy.node import Node
+# 导入关节消息类型
+from sensor_msgs.msg import JointState
+# 导入文件操作相关库
+import csv
+from datetime import datetime
+import os
+
+class ArmDataAcquisitionNode(Node):
+ """数据获取模块节点:订阅关节数据并保存为CSV"""
+ def __init__(self):
+ super().__init__('arm_data_acquisition_node')
+
+ # 创建数据保存目录(带时间戳,避免重名)
+ self.save_dir = os.path.expanduser(f"~/ros2_arm_data/{datetime.now().strftime('%Y%m%d_%H%M%S')}")
+ os.makedirs(self.save_dir, exist_ok=True)
+
+ # 创建CSV文件并写入表头
+ self.csv_file_path = os.path.join(self.save_dir, 'arm_joint_data.csv')
+ with open(self.csv_file_path, 'w', newline='') as f:
+ writer = csv.writer(f)
+ writer.writerow(['时间戳(秒)', '关节名', '关节角度(弧度)'])
+
+ # 创建订阅者:订阅/arm/joint_states话题,回调函数处理数据
+ self.joint_subscriber = self.create_subscription(
+ JointState,
+ '/arm/joint_states',
+ self.joint_data_callback,
+ 10 # 队列大小
+ )
+
+ # 日志提示:节点启动成功
+ self.get_logger().info(f"数据获取模块已启动!数据保存路径:{self.csv_file_path}")
+
+ def joint_data_callback(self, msg):
+ """订阅回调函数:处理接收到的关节数据"""
+ # 计算时间戳(秒,精确到小数点后2位)
+ timestamp = msg.header.stamp.sec + msg.header.stamp.nanosec / 1e9
+ timestamp = round(timestamp, 2)
+
+ # 将每个关节的角度写入CSV文件
+ with open(self.csv_file_path, 'a', newline='') as f:
+ writer = csv.writer(f)
+ for joint_name, joint_angle in zip(msg.name, msg.position):
+ writer.writerow([timestamp, joint_name, round(joint_angle, 2)])
+
+ # 日志输出:确认数据保存
+ self.get_logger().info(f"保存数据:时间戳={timestamp},joint2角度={round(msg.position[1], 2)}")
+
+def main(args=None):
+ """主函数:启动数据获取节点"""
+ rclpy.init(args=args)
+ node = ArmDataAcquisitionNode()
+ rclpy.spin(node)
+ node.destroy_node()
+ rclpy.shutdown()
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file
From 2d7d7a08cb090bad88a36c6a620f24a4fba86f03 Mon Sep 17 00:00:00 2001
From: 0219 <648343923@qq.com>
Date: Sat, 27 Dec 2025 16:49:03 +0800
Subject: [PATCH 13/14] Update README.md
---
src/box/README.md | 123 +++++++++++++++++++++++-----------------------
1 file changed, 61 insertions(+), 62 deletions(-)
diff --git a/src/box/README.md b/src/box/README.md
index 342f85b47e..5ef91e7335 100644
--- a/src/box/README.md
+++ b/src/box/README.md
@@ -1,4 +1,4 @@
-<<<<<<< HEAD
+
# box — 仿真与强化学习实验箱
## 概述
@@ -83,64 +83,63 @@ python tests/test_simulator.py
- 查看目录下的具体脚本与模块顶部注释,通常包含使用示例与参数说明;
- 若需要,我可以为 `src/box` 中的主要文件生成更详细的文档或示例运行脚本。
-=======
-**box — 仿真与强化学习实验箱**
-
-简介
-- `src/box` 目录包含基于 Gymnasium 和 MuJoCo 的仿真环境与相关辅助脚本,用于开发和测试生物力学/机器人仿真、感知模块与强化学习任务。
-
-目录结构(示例)
-- `simulator.py`:仿真环境核心(通常继承 `gym.Env`)。
-- `test_simulator.py`:示例运行脚本,用于启动仿真并可视化。
-- `main.py`:辅助脚本(例如证书或配置检查)。
-- `README.md`:本文件,说明目录用途与快速上手指南。
-
-快速上手
-1. 创建并激活虚拟环境(以 Windows 为例):
-
-```powershell
-cd <项目根目录>
-python -m venv venv --python=3.9
-.\\venv\\Scripts\\Activate.ps1
-```
-
-2. 安装依赖(建议使用清华镜像加速):
-
-```powershell
-pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
-```
-
-如果仓库没有完整的 `requirements.txt`,可参考下列核心库:
-
-```text
-gymnasium
-mujoco
-stable-baselines3
-pygame
-opencv-python
-numpy
-scipy
-matplotlib
-ruamel.yaml
-certifi
-```
-
-运行示例
-- 启动仿真:
-
-```powershell
-python test_simulator.py
-```
-
-运行后应弹出可视化窗口(若使用 Pygame/SDL),并在终端输出仿真日志。
-
-贡献与问题反馈
-- 若需添加说明或示例,请提交 Pull Request。
-- 遇到环境或依赖问题,请在 Issue 中描述操作系统、Python 版本与错误日志。
-
-更多信息
-- 若目录中包含更详细的子模块文档,请参阅相应文件(如 `simulator.py` 顶部注释或同目录下的文档)。
-
----
-(此 README 为目录概览,具体实现与文件名以代码库为准)
->>>>>>> ffff1b2d (更新README.md)
+
+**box — 仿真与强化学习实验箱**
+
+简介
+- `src/box` 目录包含基于 Gymnasium 和 MuJoCo 的仿真环境与相关辅助脚本,用于开发和测试生物力学/机器人仿真、感知模块与强化学习任务。
+
+目录结构(示例)
+- `simulator.py`:仿真环境核心(通常继承 `gym.Env`)。
+- `test_simulator.py`:示例运行脚本,用于启动仿真并可视化。
+- `main.py`:辅助脚本(例如证书或配置检查)。
+- `README.md`:本文件,说明目录用途与快速上手指南。
+
+快速上手
+1. 创建并激活虚拟环境(以 Windows 为例):
+
+```powershell
+cd <项目根目录>
+python -m venv venv --python=3.9
+.\\venv\\Scripts\\Activate.ps1
+```
+
+2. 安装依赖(建议使用清华镜像加速):
+
+```powershell
+pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+```
+
+如果仓库没有完整的 `requirements.txt`,可参考下列核心库:
+
+```text
+gymnasium
+mujoco
+stable-baselines3
+pygame
+opencv-python
+numpy
+scipy
+matplotlib
+ruamel.yaml
+certifi
+```
+
+运行示例
+- 启动仿真:
+
+```powershell
+python test_simulator.py
+```
+
+运行后应弹出可视化窗口(若使用 Pygame/SDL),并在终端输出仿真日志。
+
+贡献与问题反馈
+- 若需添加说明或示例,请提交 Pull Request。
+- 遇到环境或依赖问题,请在 Issue 中描述操作系统、Python 版本与错误日志。
+
+更多信息
+- 若目录中包含更详细的子模块文档,请参阅相应文件(如 `simulator.py` 顶部注释或同目录下的文档)。
+
+---
+
From e1b2a57d204628ff207e7ce7d4010e1d21df0803 Mon Sep 17 00:00:00 2001
From: 0219 <648343923@qq.com>
Date: Sat, 27 Dec 2025 16:54:41 +0800
Subject: [PATCH 14/14] Delete src/box/ros/README.md
---
src/box/ros/README.md | 0
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 src/box/ros/README.md
diff --git a/src/box/ros/README.md b/src/box/ros/README.md
deleted file mode 100644
index e69de29bb2..0000000000