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文件 - 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文件 + 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