Browse Source

增加mujoco下的强化学习训练代码

corvin_zhang 2 weeks ago
parent
commit
1ba068568f

+ 9 - 0
README.md

@@ -89,6 +89,15 @@ ros2 launch guguji_ros2 gazebo.launch.py gui:=false pause:=true
 
 分开管理,后续会更容易调试。
 
+现在 `guguji_rl/` 里已经同时支持:
+
+- Gazebo Fortress 训练后端
+- MuJoCo 训练后端
+
+如果你准备走 MuJoCo 路线,建议直接阅读:
+
+- `docs/guguji_mujoco_rl_guide.md`
+
 ## 真实机器人部署工程
 
 仓库根目录下新增了 `guguji_real_robot/`,用于把训练后的策略部署到真实双足机器人:

+ 220 - 0
docs/guguji_mujoco_rl_guide.md

@@ -0,0 +1,220 @@
+# guguji MuJoCo 强化学习指南
+
+这份文档对应的是仓库里新加入的 MuJoCo 训练后端。
+
+目标是让你可以在不启动 Gazebo 的情况下,直接在 MuJoCo 里完成:
+
+- 模型加载
+- 环境自检
+- 平衡训练
+- walking 课程训练
+- 策略回放
+
+## 1. 代码放在哪里
+
+MuJoCo 训练代码继续放在 `guguji_rl/` 目录,而不是单独再开一个同级仓库,原因是:
+
+- `guguji_rl/` 本来就是强化学习工程
+- Gazebo 和 MuJoCo 可以共用大部分 PPO、奖励函数和评估脚本
+- 你后面切换后端时,只需要换配置文件,不需要换一套完全不同的工程
+
+这次新增的关键文件有:
+
+- `guguji_rl/guguji_rl/mujoco_model.py`
+  从现有 URDF 动态生成 MuJoCo 用的 MJCF
+- `guguji_rl/guguji_rl/envs/mujoco_biped_env.py`
+  MuJoCo 训练环境
+- `guguji_rl/configs/mujoco_balance_ppo.yaml`
+  MuJoCo 平衡训练配置
+- `guguji_rl/configs/mujoco_walk_ppo.yaml`
+  MuJoCo walking 课程训练配置
+- `guguji_rl/scripts/export_mujoco_xml.py`
+  把动态生成的 MJCF 导出到文件,方便你逐行查看
+
+## 2. 安装步骤
+
+在 Ubuntu 22.04 下,建议直接复用 `guguji_rl` 的虚拟环境:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -U pip
+pip install -r requirements.txt
+```
+
+现在 `requirements.txt` 里已经包含:
+
+- `mujoco`
+- `gymnasium`
+- `stable-baselines3`
+- `torch`
+
+如果你只是跑 MuJoCo 环境本身,`mujoco` 用 CPU 就可以。
+如果你要加速 PPO 训练,还是主要依赖 `torch` 的 GPU。
+
+## 3. 先导出一份 MJCF 看结构
+
+MuJoCo 版不是手写死一个 XML,而是从当前 URDF 动态生成 MJCF。
+如果你想看最终送进 MuJoCo 的模型,可以先导出:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+source .venv/bin/activate
+python scripts/export_mujoco_xml.py \
+  --config configs/mujoco_balance_ppo.yaml \
+  --output generated/guguji_mujoco.xml
+```
+
+导出后的 `generated/guguji_mujoco.xml` 很适合你后面自己调:
+
+- 关节 range
+- actuator kp
+- floor friction
+- 根 body 结构
+
+## 4. 环境自检
+
+先不要急着训练,先确认 MuJoCo 环境能 reset 和 step:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+source .venv/bin/activate
+python scripts/check_env.py --config configs/mujoco_balance_ppo.yaml --steps 8
+```
+
+如果这一步正常,你会看到:
+
+- `reset ok`
+- `observation shape`
+- 每一步的 `reward / vx / base_z`
+
+如果你要检查 walking 配置里的参考步态,也可以运行:
+
+```bash
+python scripts/check_env.py --config configs/mujoco_walk_ppo.yaml --steps 8
+```
+
+## 5. 第一步先训平衡
+
+第一次在 MuJoCo 上训练,建议还是先从平衡开始:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+source .venv/bin/activate
+python scripts/train.py --config configs/mujoco_balance_ppo.yaml --device cpu
+```
+
+如果你想用 GPU 加速 PPO:
+
+```bash
+python scripts/train.py --config configs/mujoco_balance_ppo.yaml --device cuda
+```
+
+说明:
+
+- MuJoCo 物理本身不吃 CUDA
+- GPU 主要加速的是 `torch` 的策略网络训练
+
+## 6. walking 训练怎么做
+
+`configs/mujoco_walk_ppo.yaml` 已经内置了三段速度课程,不建议一上来就直接追高速度:
+
+- `0.18 m/s`
+- `0.22 m/s`
+- `0.26 m/s`
+
+推荐先用已经训好的 MuJoCo 平衡模型继续训练:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+source .venv/bin/activate
+python scripts/train.py \
+  --config configs/mujoco_walk_ppo.yaml \
+  --init-model outputs/<你的_mujoco_balance_实验目录>/final_model.zip \
+  --device auto
+```
+
+训练脚本会自动按三段课程顺序继续训练,不需要你手动分三次运行。
+
+## 7. 怎么评估是不是更会往前走
+
+训练结束后,`train.py` 会自动输出:
+
+- `delta_x`
+- `mean_vx`
+
+你也可以手动单独评估:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+source .venv/bin/activate
+python scripts/evaluate_forward_progress.py \
+  --config configs/mujoco_walk_ppo.yaml \
+  --model outputs/<你的实验目录>/final_model.zip
+```
+
+这样你后面调:
+
+- 参考步态振幅
+- 目标速度
+- 摩擦参数
+- actuator kp
+
+都可以直接看量化指标,而不是只靠肉眼猜。
+
+## 8. 策略回放
+
+如果你想在 MuJoCo 里回放训练好的策略:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+source .venv/bin/activate
+python scripts/run_policy.py \
+  --config configs/mujoco_walk_ppo.yaml \
+  --model outputs/<你的实验目录>/final_model.zip \
+  --deterministic \
+  --max-episodes 1
+```
+
+如果你想打开 MuJoCo 窗口看画面:
+
+```bash
+python scripts/run_policy.py \
+  --config configs/mujoco_walk_ppo.yaml \
+  --model outputs/<你的实验目录>/final_model.zip \
+  --deterministic \
+  --render-human
+```
+
+## 9. MuJoCo 版和 Gazebo 版有什么关系
+
+这两套后端现在是并行存在的:
+
+- Gazebo 版更接近 ROS 2 / 真实系统接口
+- MuJoCo 版更适合快速做强化学习迭代
+
+建议你后面这样使用:
+
+1. 先在 MuJoCo 里快速调奖励、课程和参考步态
+2. 再把比较靠谱的策略思路迁回 Gazebo
+3. 最后再往真实机器人部署链路上收敛
+
+## 10. 你后面最常改的地方
+
+如果你后面要自己继续调,我建议优先看这些文件:
+
+- `guguji_rl/guguji_rl/mujoco_model.py`
+  看 MuJoCo 模型是怎么从 URDF 生成出来的
+- `guguji_rl/guguji_rl/envs/mujoco_biped_env.py`
+  看动作映射、reset、参考步态和观测
+- `guguji_rl/configs/mujoco_balance_ppo.yaml`
+  调平衡训练参数
+- `guguji_rl/configs/mujoco_walk_ppo.yaml`
+  调 walking 课程、目标速度和参考步态
+
+如果后面你愿意继续推进,最自然的下一步一般是:
+
+1. 先在 MuJoCo 里把 balance 训练跑稳
+2. 再把 MuJoCo walking 课程跑起来
+3. 最后把 MuJoCo 学到的 walking 参数往 Gazebo 版迁移一轮

+ 39 - 0
guguji_rl/README.md

@@ -15,11 +15,14 @@
 - `configs/`:训练配置,支持 CPU / GPU 切换
 - `guguji_rl/ros2_interface.py`:ROS 2 与 Gazebo 的训练接口
 - `guguji_rl/envs/gazebo_biped_env.py`:Gymnasium 环境封装
+- `guguji_rl/envs/mujoco_biped_env.py`:MuJoCo 训练环境
+- `guguji_rl/mujoco_model.py`:从当前 URDF 动态生成 MuJoCo MJCF
 - `guguji_rl/rewards.py`:奖励函数
 - `scripts/train.py`:PPO 训练入口
 - `scripts/evaluate_forward_progress.py`:评估模型的 `delta_x / mean_vx`
 - `scripts/run_policy.py`:把训练好的策略作为在线控制程序持续运行
 - `scripts/check_env.py`:训练前自检脚本
+- `scripts/export_mujoco_xml.py`:导出动态生成的 MuJoCo XML
 
 ## 建议工作流
 
@@ -46,6 +49,40 @@ pip install -r requirements.txt
 
 如果要用 GPU,请先确认 `torch.cuda.is_available()` 为 `True`,然后在配置文件里把 `training.device` 设为 `cuda` 或 `auto`。
 
+现在 `requirements.txt` 已经包含 `mujoco`,所以你可以直接按同一套依赖把 MuJoCo 后端也装好。
+
+## MuJoCo 快速开始
+
+如果你想直接在 MuJoCo 里做强化学习,不需要先启动 Gazebo。
+
+先做模型导出和环境自检:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_rl
+source .venv/bin/activate
+python scripts/export_mujoco_xml.py --config configs/mujoco_balance_ppo.yaml
+python scripts/check_env.py --config configs/mujoco_balance_ppo.yaml --steps 8
+```
+
+然后训练第一轮 MuJoCo 平衡模型:
+
+```bash
+python scripts/train.py --config configs/mujoco_balance_ppo.yaml --device auto
+```
+
+平衡模型出来后,再继续 MuJoCo walking 课程:
+
+```bash
+python scripts/train.py \
+  --config configs/mujoco_walk_ppo.yaml \
+  --init-model outputs/<你的_mujoco_balance_实验目录>/final_model.zip \
+  --device auto
+```
+
+MuJoCo 版的详细步骤建议直接看:
+
+- `/home/corvin/Project/guguji_simulation/docs/guguji_mujoco_rl_guide.md`
+
 ## 训练时推荐的 Gazebo 启动方式
 
 如果你准备正式开始训练,建议这样启动 Gazebo:
@@ -116,3 +153,5 @@ python3 scripts/train.py --config configs/walk_ppo.yaml
 - `0.18 m/s`
 - `0.22 m/s`
 - `0.26 m/s`
+
+MuJoCo 版的 `mujoco_walk_ppo.yaml` 也采用同样的三段课程思路。

+ 98 - 0
guguji_rl/configs/mujoco_balance_ppo.yaml

@@ -0,0 +1,98 @@
+experiment:
+  name: mujoco_balance_ppo
+
+robot:
+  model_name: guguji
+  urdf_path: guguji_ros2_ws/src/guguji_ros2/urdf/guguji.urdf
+  joint_names:
+    - left_hip_pitch_joint
+    - left_knee_pitch_joint
+    - left_ankle_pitch_joint
+    - left_ankle_joint
+    - right_hip_pitch_joint
+    - right_knee_pitch_joint
+    - right_ankle_pitch_joint
+    - right_ankle_joint
+  # MuJoCo 版不再走 ROS 话题,但保留这组 joint 顺序,方便和 Gazebo / 真机版统一理解。
+  nominal_joint_positions:
+    left_hip_pitch_joint: 0.04
+    left_knee_pitch_joint: 0.18
+    left_ankle_pitch_joint: -0.10
+    left_ankle_joint: 0.0
+    right_hip_pitch_joint: 0.04
+    right_knee_pitch_joint: 0.18
+    right_ankle_pitch_joint: -0.10
+    right_ankle_joint: 0.0
+  action_scale: 0.10
+  action_smoothing: 0.82
+
+sim:
+  backend: mujoco
+  control_dt: 0.05
+
+mujoco:
+  timestep: 0.005
+  frame_skip: 10
+  root_body_height: 0.36
+  joint_damping: 0.50
+  joint_armature: 0.015
+  actuator_kp: 38.0
+  solver_iterations: 80
+  solver_ls_iterations: 16
+  floor_friction: [1.5, 0.06, 0.01]
+  default_friction: [0.9, 0.04, 0.01]
+  foot_friction: [1.9, 0.06, 0.01]
+  contact_margin: 0.002
+  contact_gap: 0.0005
+  render_mode: none
+  render_width: 1280
+  render_height: 720
+  reset_base_xy_noise_scale: 0.004
+  reset_base_height_noise_scale: 0.002
+  reset_joint_noise_scale: 0.01
+  reset_hold_steps: 12
+
+task:
+  target_forward_velocity: 0.0
+  target_base_height: null
+  max_roll_rad: 0.95
+  max_pitch_rad: 0.95
+  min_base_height: 0.20
+  termination_grace_steps: 10
+
+rewards:
+  alive_bonus: 1.8
+  velocity_tracking_scale: 1.4
+  velocity_tracking_sigma: 0.30
+  upright_scale: 2.2
+  height_scale: 1.1
+  action_rate_penalty_scale: 0.01
+  joint_limit_penalty_scale: 0.04
+  lateral_velocity_penalty_scale: 0.08
+  fall_penalty: -8.0
+
+training:
+  algorithm: ppo
+  total_timesteps: 200000
+  max_episode_steps: 400
+  seed: 42
+  device: auto
+  learning_rate: 0.0002
+  n_steps: 1024
+  batch_size: 256
+  gamma: 0.99
+  gae_lambda: 0.95
+  clip_range: 0.2
+  ent_coef: 0.0
+  vf_coef: 0.5
+  policy_net_arch: [256, 256]
+  checkpoint_freq: 20000
+  output_root: guguji_rl/outputs
+
+evaluation:
+  episodes: 3
+  deterministic: true
+  auto_forward_progress: true
+  forward_progress_episodes: 2
+  forward_progress_max_steps: 400
+  forward_progress_deterministic: true

+ 137 - 0
guguji_rl/configs/mujoco_walk_ppo.yaml

@@ -0,0 +1,137 @@
+experiment:
+  name: mujoco_walk_ppo
+
+robot:
+  model_name: guguji
+  urdf_path: guguji_ros2_ws/src/guguji_ros2/urdf/guguji.urdf
+  joint_names:
+    - left_hip_pitch_joint
+    - left_knee_pitch_joint
+    - left_ankle_pitch_joint
+    - left_ankle_joint
+    - right_hip_pitch_joint
+    - right_knee_pitch_joint
+    - right_ankle_pitch_joint
+    - right_ankle_joint
+  nominal_joint_positions:
+    left_hip_pitch_joint: 0.04
+    left_knee_pitch_joint: 0.18
+    left_ankle_pitch_joint: -0.10
+    left_ankle_joint: 0.0
+    right_hip_pitch_joint: 0.04
+    right_knee_pitch_joint: 0.18
+    right_ankle_pitch_joint: -0.10
+    right_ankle_joint: 0.0
+  # MuJoCo 版 walking 同样采用“参考步态 + 残差动作”。
+  reference_gait:
+    enabled: true
+    period: 0.72
+    stance_ratio: 0.60
+    hip_pitch_amplitude: 0.34
+    hip_pitch_bias: 0.04
+    knee_pitch_amplitude: 0.46
+    knee_pitch_bias: 0.12
+    swing_knee_scale: 1.10
+    ankle_pitch_amplitude: 0.22
+    ankle_pitch_bias: -0.05
+    push_off_ankle_scale: 0.22
+  action_scale: 0.08
+  action_smoothing: 0.82
+
+sim:
+  backend: mujoco
+  control_dt: 0.05
+
+mujoco:
+  timestep: 0.005
+  frame_skip: 10
+  root_body_height: 0.36
+  joint_damping: 0.55
+  joint_armature: 0.02
+  actuator_kp: 42.0
+  solver_iterations: 90
+  solver_ls_iterations: 18
+  floor_friction: [1.6, 0.06, 0.01]
+  default_friction: [0.95, 0.04, 0.01]
+  foot_friction: [2.1, 0.06, 0.01]
+  contact_margin: 0.002
+  contact_gap: 0.0005
+  render_mode: none
+  render_width: 1280
+  render_height: 720
+  reset_base_xy_noise_scale: 0.004
+  reset_base_height_noise_scale: 0.002
+  reset_joint_noise_scale: 0.008
+  reset_hold_steps: 12
+
+task:
+  # 这里保留课程最后一段的目标速度;实际训练时会由 curriculum_stages 逐段爬升。
+  target_forward_velocity: 0.26
+  target_base_height: null
+  max_roll_rad: 0.95
+  max_pitch_rad: 0.95
+  min_base_height: 0.19
+  termination_grace_steps: 18
+
+rewards:
+  alive_bonus: 0.8
+  velocity_tracking_scale: 5.0
+  velocity_tracking_sigma: 0.10
+  forward_progress_scale: 6.0
+  hip_alternation_scale: 0.5
+  hip_target_separation: 0.36
+  hip_antiphase_sigma: 0.18
+  knee_flexion_scale: 0.35
+  knee_target: 0.28
+  knee_flexion_sigma: 0.15
+  upright_scale: 1.6
+  height_scale: 0.9
+  action_rate_penalty_scale: 0.004
+  joint_limit_penalty_scale: 0.05
+  lateral_velocity_penalty_scale: 0.08
+  backward_velocity_penalty_scale: 2.8
+  stall_penalty_scale: 4.6
+  stall_velocity_threshold: 0.10
+  fall_penalty: -15.0
+
+training:
+  algorithm: ppo
+  total_timesteps: 10000
+  max_episode_steps: 500
+  seed: 42
+  device: auto
+  # MuJoCo walking 建议优先从 MuJoCo balance 模型继续训。
+  init_model_path: null
+  initial_log_std: -2.2
+  curriculum_stages:
+    - name: walk_v018
+      target_forward_velocity: 0.18
+      total_timesteps: 15000
+      initial_log_std: -2.30
+    - name: walk_v022
+      target_forward_velocity: 0.22
+      total_timesteps: 15000
+      initial_log_std: -2.25
+    - name: walk_v026
+      target_forward_velocity: 0.26
+      total_timesteps: 20000
+      initial_log_std: -2.20
+  learning_rate: 0.0001
+  n_steps: 1024
+  batch_size: 256
+  gamma: 0.99
+  gae_lambda: 0.95
+  clip_range: 0.15
+  ent_coef: 0.0
+  vf_coef: 0.5
+  policy_net_arch: [256, 256]
+  checkpoint_freq: 10000
+  output_root: guguji_rl/outputs
+
+evaluation:
+  episodes: 3
+  deterministic: true
+  auto_forward_progress: true
+  forward_progress_episodes: 2
+  forward_progress_max_steps: 500
+  forward_progress_deterministic: true

+ 37 - 12
guguji_rl/guguji_rl/config.py

@@ -40,6 +40,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
         'world_control_service': '/world/default/control',
     },
     'sim': {
+        'backend': 'gazebo',
         'world_name': 'default',
         'step_mode': 'realtime',
         'control_dt': 0.05,
@@ -54,6 +55,30 @@ DEFAULT_CONFIG: dict[str, Any] = {
         'spawn_pitch': 0.0,
         'spawn_yaw': 0.0,
     },
+    'mujoco': {
+        'timestep': 0.005,
+        'frame_skip': 10,
+        'root_body_height': 0.36,
+        'joint_damping': 0.4,
+        'joint_armature': 0.01,
+        'actuator_kp': 35.0,
+        'solver_iterations': 60,
+        'solver_ls_iterations': 12,
+        'floor_friction': [1.4, 0.05, 0.01],
+        'default_friction': [0.9, 0.04, 0.01],
+        'foot_friction': [1.8, 0.05, 0.01],
+        'contact_margin': 0.002,
+        'contact_gap': 0.0005,
+        'render_mode': 'none',
+        'render_width': 1280,
+        'render_height': 720,
+        'camera_distance': 1.4,
+        'camera_elevation': -18.0,
+        'reset_base_xy_noise_scale': 0.005,
+        'reset_base_height_noise_scale': 0.002,
+        'reset_joint_noise_scale': 0.01,
+        'reset_hold_steps': 8,
+    },
     'task': {
         'target_forward_velocity': 0.0,
         'target_base_height': None,
@@ -82,18 +107,18 @@ DEFAULT_CONFIG: dict[str, Any] = {
         'stall_velocity_threshold': 0.0,
         'fall_penalty': -10.0,
     },
-        'training': {
-            'algorithm': 'ppo',
-            'total_timesteps': 200000,
-            'max_episode_steps': 400,
-            'seed': 42,
-            'device': 'auto',
-            'init_model_path': None,
-            'initial_log_std': None,
-            'curriculum_stages': [],
-            'learning_rate': 3e-4,
-            'n_steps': 1024,
-            'batch_size': 256,
+    'training': {
+        'algorithm': 'ppo',
+        'total_timesteps': 200000,
+        'max_episode_steps': 400,
+        'seed': 42,
+        'device': 'auto',
+        'init_model_path': None,
+        'initial_log_std': None,
+        'curriculum_stages': [],
+        'learning_rate': 3e-4,
+        'n_steps': 1024,
+        'batch_size': 256,
         'gamma': 0.99,
         'gae_lambda': 0.95,
         'clip_range': 0.2,

+ 20 - 2
guguji_rl/guguji_rl/envs/__init__.py

@@ -1,3 +1,21 @@
-from .gazebo_biped_env import GazeboBipedEnv
+from __future__ import annotations
 
-__all__ = ['GazeboBipedEnv']
+from typing import Any
+
+
+def build_env_from_config(config: dict[str, Any]):
+    """根据配置自动选择 Gazebo 或 MuJoCo 训练环境。"""
+    backend = str(config.get('sim', {}).get('backend', 'gazebo')).lower()
+    if backend == 'gazebo':
+        from .gazebo_biped_env import GazeboBipedEnv
+
+        return GazeboBipedEnv(config)
+    if backend == 'mujoco':
+        from .mujoco_biped_env import MujocoBipedEnv
+
+        return MujocoBipedEnv(config)
+
+    raise ValueError(f'不支持的仿真后端: {backend}')
+
+
+__all__ = ['build_env_from_config']

+ 2 - 1
guguji_rl/guguji_rl/envs/gazebo_biped_env.py

@@ -10,7 +10,8 @@ import numpy as np
 from ..config import resolve_project_path
 from ..math_utils import quaternion_xyzw_to_euler
 from ..rewards import BipedRewardCalculator, RewardContext
-from ..ros2_interface import GugujiRos2Interface, RobotStateSnapshot
+from ..ros2_interface import GugujiRos2Interface
+from ..state_types import RobotStateSnapshot
 from ..urdf_utils import JointLimit, parse_joint_limits
 
 

+ 395 - 0
guguji_rl/guguji_rl/envs/mujoco_biped_env.py

@@ -0,0 +1,395 @@
+from __future__ import annotations
+
+import math
+from typing import Any
+
+import gymnasium as gym
+import mujoco
+import numpy as np
+
+from ..config import resolve_project_path
+from ..math_utils import quaternion_xyzw_to_euler
+from ..mujoco_model import build_mujoco_model_spec
+from ..rewards import BipedRewardCalculator, RewardContext
+from ..state_types import RobotStateSnapshot
+from ..urdf_utils import JointLimit, parse_joint_limits
+
+
+class MujocoBipedEnv(gym.Env):
+    """把 guguji 机器人封装成 MuJoCo + Gymnasium 训练环境。"""
+
+    metadata = {'render_modes': ['human', 'rgb_array'], 'render_fps': 20}
+
+    def __init__(self, config: dict[str, Any]) -> None:
+        super().__init__()
+        self.config = config
+        self.robot_config = config['robot']
+        self.sim_config = config['sim']
+        self.task_config = config['task']
+        self.training_config = config['training']
+        self.mujoco_config = config.get('mujoco', {})
+        self.configured_target_base_height = self.task_config['target_base_height']
+        self.reference_gait_config = self.robot_config.get('reference_gait', {})
+
+        urdf_path = resolve_project_path(config, self.robot_config['urdf_path'])
+        self.joint_limits: list[JointLimit] = parse_joint_limits(urdf_path, self.robot_config['joint_names'])
+        self.joint_names = [joint_limit.name for joint_limit in self.joint_limits]
+        self.joint_lower = np.array([joint.lower for joint in self.joint_limits], dtype=np.float32)
+        self.joint_upper = np.array([joint.upper for joint in self.joint_limits], dtype=np.float32)
+        self.joint_mid = np.array([joint.midpoint for joint in self.joint_limits], dtype=np.float32)
+        self.joint_half_range = np.array([joint.half_range for joint in self.joint_limits], dtype=np.float32)
+
+        nominal_joint_config = self.robot_config.get('nominal_joint_positions', {})
+        self.nominal_joint_targets = np.array(
+            [float(nominal_joint_config.get(joint_name, 0.0)) for joint_name in self.joint_names],
+            dtype=np.float32,
+        )
+        self.action_scale = float(self.robot_config.get('action_scale', 1.0))
+        self.action_smoothing = float(np.clip(self.robot_config.get('action_smoothing', 0.0), 0.0, 0.99))
+        self.reference_gait_enabled = bool(self.reference_gait_config.get('enabled', False))
+        self.reference_gait_period = max(
+            float(self.reference_gait_config.get('period', self.sim_config.get('control_dt', 0.05))),
+            1e-4,
+        )
+        self.termination_grace_steps = int(self.task_config.get('termination_grace_steps', 0))
+
+        self.control_dt = float(self.sim_config.get('control_dt', 0.05))
+        self.frame_skip = int(self.mujoco_config.get('frame_skip', max(int(round(self.control_dt / 0.005)), 1)))
+        self.render_mode = str(self.mujoco_config.get('render_mode', 'none')).lower()
+        if self.render_mode == 'none':
+            self.render_mode = None
+
+        spec = build_mujoco_model_spec(config)
+        self.model = mujoco.MjModel.from_xml_string(spec.xml, spec.assets)
+        self.data = mujoco.MjData(self.model)
+        self.root_body_name = spec.root_link_name
+        self.root_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, self.root_body_name)
+
+        self.joint_qpos_indices = np.array(
+            [
+                int(self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, joint_name)])
+                for joint_name in self.joint_names
+            ],
+            dtype=np.int32,
+        )
+        self.joint_qvel_indices = np.array(
+            [
+                int(self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, joint_name)])
+                for joint_name in self.joint_names
+            ],
+            dtype=np.int32,
+        )
+
+        self.reward_calculator = BipedRewardCalculator(config['rewards'])
+        self.action_space = gym.spaces.Box(
+            low=-1.0,
+            high=1.0,
+            shape=(len(self.joint_names),),
+            dtype=np.float32,
+        )
+        self.observation_space = gym.spaces.Box(
+            low=-np.inf,
+            high=np.inf,
+            shape=(len(self.joint_names) * 3 + 5,),
+            dtype=np.float32,
+        )
+
+        self.base_height = float(self.mujoco_config.get('root_body_height', 0.36))
+        self.base_xy_noise_scale = float(self.mujoco_config.get('reset_base_xy_noise_scale', 0.005))
+        self.base_height_noise_scale = float(self.mujoco_config.get('reset_base_height_noise_scale', 0.002))
+        self.joint_reset_noise_scale = float(self.mujoco_config.get('reset_joint_noise_scale', 0.01))
+        self.reset_hold_steps = int(self.mujoco_config.get('reset_hold_steps', 8))
+        self.render_width = int(self.mujoco_config.get('render_width', 1280))
+        self.render_height = int(self.mujoco_config.get('render_height', 720))
+
+        self.previous_action = np.zeros(len(self.joint_names), dtype=np.float32)
+        self.previous_snapshot: RobotStateSnapshot | None = None
+        self.current_snapshot: RobotStateSnapshot | None = None
+        self.current_joint_target = self.nominal_joint_targets.copy()
+        self.current_action_residual = np.zeros(len(self.joint_names), dtype=np.float32)
+        self.target_base_height = self.configured_target_base_height
+        self.step_count = 0
+        self.gait_phase = 0.0
+
+        self._rgb_renderer: mujoco.Renderer | None = None
+        self._viewer = None
+
+        # 提前构造一个默认起始姿态,后面 reset 时在这个基础上再叠加少量噪声。
+        self.initial_qpos = self.data.qpos.copy()
+        self.initial_qvel = np.zeros_like(self.data.qvel)
+        self.initial_qpos[0:3] = np.array([0.0, 0.0, self.base_height], dtype=np.float64)
+        self.initial_qpos[3:7] = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
+        self.initial_qpos[self.joint_qpos_indices] = self.nominal_joint_targets.astype(np.float64)
+        self.data.ctrl[:] = self.nominal_joint_targets.astype(np.float64)
+        mujoco.mj_forward(self.model, self.data)
+
+    def _normalized_joint_position(self, joint_position: np.ndarray) -> np.ndarray:
+        return (joint_position - self.joint_mid) / self.joint_half_range
+
+    def _extract_snapshot(self) -> RobotStateSnapshot:
+        joint_position = self.data.qpos[self.joint_qpos_indices].astype(np.float32).copy()
+        joint_velocity = self.data.qvel[self.joint_qvel_indices].astype(np.float32).copy()
+        base_position = self.data.qpos[0:3].astype(np.float32).copy()
+        base_quat_wxyz = self.data.qpos[3:7].astype(np.float32).copy()
+        # MuJoCo 的自由关节四元数是 wxyz,而整个 RL 工程里统一使用 xyzw。
+        base_quaternion = np.array(
+            [base_quat_wxyz[1], base_quat_wxyz[2], base_quat_wxyz[3], base_quat_wxyz[0]],
+            dtype=np.float32,
+        )
+        return RobotStateSnapshot(
+            sim_time=float(self.data.time),
+            joint_position=joint_position,
+            joint_velocity=joint_velocity,
+            base_position=base_position,
+            base_quaternion=base_quaternion,
+        )
+
+    def _reference_gait_offsets(self) -> np.ndarray:
+        """生成一个和 Gazebo 版相同思路的参考步态。"""
+        offsets = np.zeros(len(self.joint_names), dtype=np.float32)
+        if not self.reference_gait_enabled:
+            return offsets
+
+        hip_amplitude = float(self.reference_gait_config.get('hip_pitch_amplitude', 0.0))
+        hip_bias = float(self.reference_gait_config.get('hip_pitch_bias', 0.0))
+        knee_amplitude = float(self.reference_gait_config.get('knee_pitch_amplitude', 0.0))
+        knee_bias = float(self.reference_gait_config.get('knee_pitch_bias', 0.0))
+        swing_knee_scale = float(self.reference_gait_config.get('swing_knee_scale', 1.0))
+        ankle_amplitude = float(self.reference_gait_config.get('ankle_pitch_amplitude', 0.0))
+        ankle_bias = float(self.reference_gait_config.get('ankle_pitch_bias', 0.0))
+        push_off_ankle_scale = float(self.reference_gait_config.get('push_off_ankle_scale', 0.0))
+        stance_ratio = float(np.clip(self.reference_gait_config.get('stance_ratio', 0.62), 0.05, 0.95))
+
+        def gait_profile(phase: float) -> tuple[float, float, float]:
+            phase = phase % 1.0
+            if phase < stance_ratio:
+                stance_progress = phase / stance_ratio
+                hip_profile = 1.0 - 2.0 * stance_progress
+                knee_profile = 0.18 * math.sin(math.pi * stance_progress)
+                push_off_progress = max((stance_progress - 0.60) / 0.40, 0.0)
+                ankle_profile = -0.70 * hip_profile + push_off_ankle_scale * push_off_progress
+            else:
+                swing_progress = (phase - stance_ratio) / (1.0 - stance_ratio)
+                hip_profile = -1.0 + 2.0 * swing_progress
+                knee_profile = swing_knee_scale * math.sin(math.pi * swing_progress)
+                ankle_profile = -0.45 * hip_profile - 0.25 * math.sin(math.pi * swing_progress)
+            return hip_profile, knee_profile, ankle_profile
+
+        left_hip_profile, left_knee_profile, left_ankle_profile = gait_profile(self.gait_phase)
+        right_hip_profile, right_knee_profile, right_ankle_profile = gait_profile(self.gait_phase + 0.5)
+
+        for index, joint_name in enumerate(self.joint_names):
+            if joint_name == 'left_hip_pitch_joint':
+                offsets[index] = hip_bias + hip_amplitude * left_hip_profile
+            elif joint_name == 'right_hip_pitch_joint':
+                offsets[index] = hip_bias + hip_amplitude * right_hip_profile
+            elif joint_name == 'left_knee_pitch_joint':
+                offsets[index] = knee_bias + knee_amplitude * left_knee_profile
+            elif joint_name == 'right_knee_pitch_joint':
+                offsets[index] = knee_bias + knee_amplitude * right_knee_profile
+            elif joint_name == 'left_ankle_pitch_joint':
+                offsets[index] = ankle_bias + ankle_amplitude * left_ankle_profile
+            elif joint_name == 'right_ankle_pitch_joint':
+                offsets[index] = ankle_bias + ankle_amplitude * right_ankle_profile
+
+        return offsets
+
+    def _advance_gait_phase(self) -> None:
+        if not self.reference_gait_enabled:
+            return
+        self.gait_phase = (self.gait_phase + self.control_dt / self.reference_gait_period) % 1.0
+
+    def _action_to_joint_targets(self, action: np.ndarray) -> np.ndarray:
+        clipped_action = np.clip(action, -1.0, 1.0)
+        reference_targets = self.nominal_joint_targets + self._reference_gait_offsets()
+        desired_residual = clipped_action * self.joint_half_range * self.action_scale
+
+        if self.action_smoothing > 0.0:
+            smoothed_residual = (
+                self.action_smoothing * self.current_action_residual
+                + (1.0 - self.action_smoothing) * desired_residual
+            )
+        else:
+            smoothed_residual = desired_residual
+
+        self.current_action_residual = smoothed_residual.astype(np.float32)
+        joint_targets = reference_targets + self.current_action_residual
+        return np.clip(joint_targets, self.joint_lower, self.joint_upper).astype(np.float32)
+
+    def _get_target_base_height(self, snapshot: RobotStateSnapshot) -> float:
+        if self.target_base_height is None:
+            self.target_base_height = float(snapshot.base_position[2])
+        return float(self.target_base_height)
+
+    def _build_observation(
+        self,
+        snapshot: RobotStateSnapshot,
+        previous_snapshot: RobotStateSnapshot,
+        previous_action: np.ndarray,
+    ) -> np.ndarray:
+        dt = max(snapshot.sim_time - previous_snapshot.sim_time, self.control_dt, 1e-3)
+        velocity = (snapshot.base_position - previous_snapshot.base_position) / dt
+        roll, pitch, _ = quaternion_xyzw_to_euler(snapshot.base_quaternion)
+        return np.concatenate(
+            [
+                self._normalized_joint_position(snapshot.joint_position),
+                snapshot.joint_velocity,
+                previous_action,
+                np.array(
+                    [
+                        snapshot.base_position[2],
+                        roll,
+                        pitch,
+                        velocity[0],
+                        self.task_config['target_forward_velocity'],
+                    ],
+                    dtype=np.float32,
+                ),
+            ],
+            dtype=np.float32,
+        )
+
+    def _advance_simulation(self) -> None:
+        for _ in range(self.frame_skip):
+            mujoco.mj_step(self.model, self.data)
+
+    def _terminated(self, snapshot: RobotStateSnapshot) -> bool:
+        if self.step_count <= self.termination_grace_steps:
+            return False
+
+        roll, pitch, _ = quaternion_xyzw_to_euler(snapshot.base_quaternion)
+        if abs(roll) > float(self.task_config['max_roll_rad']):
+            return True
+        if abs(pitch) > float(self.task_config['max_pitch_rad']):
+            return True
+        if float(snapshot.base_position[2]) < float(self.task_config['min_base_height']):
+            return True
+        return False
+
+    def _reset_qpos_with_noise(self) -> None:
+        self.data.qpos[:] = self.initial_qpos
+        self.data.qvel[:] = self.initial_qvel
+
+        if self.base_xy_noise_scale > 0.0:
+            self.data.qpos[0] += float(self.np_random.uniform(-self.base_xy_noise_scale, self.base_xy_noise_scale))
+            self.data.qpos[1] += float(self.np_random.uniform(-self.base_xy_noise_scale, self.base_xy_noise_scale))
+        if self.base_height_noise_scale > 0.0:
+            self.data.qpos[2] += float(
+                self.np_random.uniform(-self.base_height_noise_scale, self.base_height_noise_scale)
+            )
+        if self.joint_reset_noise_scale > 0.0:
+            joint_noise = self.np_random.uniform(
+                low=-self.joint_reset_noise_scale,
+                high=self.joint_reset_noise_scale,
+                size=len(self.joint_qpos_indices),
+            )
+            self.data.qpos[self.joint_qpos_indices] += joint_noise
+
+        self.data.ctrl[:] = self.nominal_joint_targets.astype(np.float64)
+        mujoco.mj_forward(self.model, self.data)
+
+    def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None):
+        super().reset(seed=seed)
+        self._reset_qpos_with_noise()
+        self.current_joint_target = self.nominal_joint_targets.copy()
+        self.current_action_residual = np.zeros(len(self.joint_names), dtype=np.float32)
+        self.gait_phase = 0.0
+
+        # MuJoCo 版 reset 也保留几拍站姿保持,让 position actuator 先把姿态拉回稳定区间。
+        for _ in range(max(self.reset_hold_steps, 0)):
+            self.data.ctrl[:] = self.current_joint_target.astype(np.float64)
+            self._advance_simulation()
+
+        snapshot = self._extract_snapshot()
+        self.target_base_height = self.configured_target_base_height
+        self._get_target_base_height(snapshot)
+        self.current_snapshot = snapshot
+        self.previous_snapshot = snapshot
+        self.previous_action = np.zeros(len(self.joint_names), dtype=np.float32)
+        self.step_count = 0
+
+        if self.render_mode == 'human':
+            self.render()
+
+        observation = self._build_observation(snapshot, snapshot, self.previous_action)
+        info = {
+            'reset': True,
+            'target_base_height': float(self.target_base_height),
+            'target_forward_velocity': float(self.task_config['target_forward_velocity']),
+        }
+        return observation, info
+
+    def step(self, action: np.ndarray):
+        if self.current_snapshot is None or self.previous_snapshot is None:
+            raise RuntimeError('环境尚未 reset,不能直接 step。')
+
+        self.step_count += 1
+        action = np.asarray(action, dtype=np.float32)
+        joint_targets = self._action_to_joint_targets(action)
+        self.current_joint_target = joint_targets.copy()
+        self.data.ctrl[:] = joint_targets.astype(np.float64)
+        self._advance_simulation()
+        self._advance_gait_phase()
+
+        self.previous_snapshot = self.current_snapshot
+        self.current_snapshot = self._extract_snapshot()
+        terminated = self._terminated(self.current_snapshot)
+        truncated = self.step_count >= int(self.training_config['max_episode_steps'])
+
+        reward, reward_terms = self.reward_calculator.compute(
+            RewardContext(
+                current=self.current_snapshot,
+                previous=self.previous_snapshot,
+                action=action,
+                previous_action=self.previous_action,
+                joint_limits=self.joint_limits,
+                target_forward_velocity=float(self.task_config['target_forward_velocity']),
+                target_base_height=float(self.target_base_height),
+                control_dt=self.control_dt,
+                terminated=terminated,
+            )
+        )
+        observation = self._build_observation(
+            self.current_snapshot,
+            self.previous_snapshot,
+            self.previous_action,
+        )
+        self.previous_action = action.copy()
+
+        if self.render_mode == 'human':
+            self.render()
+
+        info = {
+            'reward_terms': reward_terms,
+            'joint_targets': joint_targets,
+        }
+        return observation, reward, terminated, truncated, info
+
+    def render(self):
+        if self.render_mode == 'rgb_array':
+            if self._rgb_renderer is None:
+                self._rgb_renderer = mujoco.Renderer(self.model, height=self.render_height, width=self.render_width)
+            self._rgb_renderer.update_scene(self.data, camera='overview')
+            return self._rgb_renderer.render()
+
+        if self.render_mode == 'human':
+            if self._viewer is None:
+                from mujoco import viewer
+
+                self._viewer = viewer.launch_passive(self.model, self.data)
+            if self._viewer is not None:
+                self._viewer.sync()
+            return None
+
+        return None
+
+    def close(self) -> None:
+        if self._rgb_renderer is not None:
+            self._rgb_renderer.close()
+            self._rgb_renderer = None
+
+        if self._viewer is not None:
+            close_method = getattr(self._viewer, 'close', None)
+            if callable(close_method):
+                close_method()
+            self._viewer = None

+ 2 - 2
guguji_rl/guguji_rl/evaluation.py

@@ -70,9 +70,9 @@ def evaluate_forward_progress(
     except ImportError as error:
         raise RuntimeError('缺少 stable-baselines3,无法执行策略评估。') from error
 
-    from guguji_rl.envs import GazeboBipedEnv
+    from guguji_rl.envs import build_env_from_config
 
-    env = GazeboBipedEnv(config)
+    env = build_env_from_config(config)
     summary_episodes: list[ForwardEpisodeMetrics] = []
 
     try:

+ 26 - 0
guguji_rl/guguji_rl/math_utils.py

@@ -26,5 +26,31 @@ def quaternion_xyzw_to_euler(quaternion: Iterable[float]) -> tuple[float, float,
     return roll, pitch, yaw
 
 
+def euler_to_quaternion_xyzw(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
+    """把欧拉角转换成 xyzw 顺序四元数。"""
+    half_roll = roll * 0.5
+    half_pitch = pitch * 0.5
+    half_yaw = yaw * 0.5
+
+    cr = math.cos(half_roll)
+    sr = math.sin(half_roll)
+    cp = math.cos(half_pitch)
+    sp = math.sin(half_pitch)
+    cy = math.cos(half_yaw)
+    sy = math.sin(half_yaw)
+
+    x = sr * cp * cy - cr * sp * sy
+    y = cr * sp * cy + sr * cp * sy
+    z = cr * cp * sy - sr * sp * cy
+    w = cr * cp * cy + sr * sp * sy
+    return x, y, z, w
+
+
+def euler_to_quaternion_wxyz(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
+    """MuJoCo XML 里使用 wxyz 顺序,所以这里单独提供一个辅助函数。"""
+    x, y, z, w = euler_to_quaternion_xyzw(roll, pitch, yaw)
+    return w, x, y, z
+
+
 def safe_array(values: Iterable[float], dtype=np.float32) -> np.ndarray:
     return np.asarray(list(values), dtype=dtype)

+ 443 - 0
guguji_rl/guguji_rl/mujoco_model.py

@@ -0,0 +1,443 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+from xml.etree import ElementTree as ET
+
+from .config import resolve_project_path
+from .math_utils import euler_to_quaternion_wxyz
+
+
+@dataclass(frozen=True)
+class UrdfInertial:
+    xyz: tuple[float, float, float]
+    rpy: tuple[float, float, float]
+    mass: float
+    inertia: tuple[float, float, float, float, float, float]
+
+
+@dataclass(frozen=True)
+class UrdfMesh:
+    filename: str
+    xyz: tuple[float, float, float]
+    rpy: tuple[float, float, float]
+    rgba: tuple[float, float, float, float]
+
+
+@dataclass(frozen=True)
+class UrdfLink:
+    name: str
+    inertial: UrdfInertial | None
+    mesh: UrdfMesh | None
+
+
+@dataclass(frozen=True)
+class UrdfJoint:
+    name: str
+    joint_type: str
+    parent: str
+    child: str
+    xyz: tuple[float, float, float]
+    rpy: tuple[float, float, float]
+    axis: tuple[float, float, float]
+    lower: float
+    upper: float
+    effort: float
+
+
+@dataclass(frozen=True)
+class MujocoModelSpec:
+    xml: str
+    assets: dict[str, bytes]
+    root_link_name: str
+
+
+def _parse_xyz(text: str | None) -> tuple[float, float, float]:
+    if not text:
+        return (0.0, 0.0, 0.0)
+    values = [float(value) for value in text.split()]
+    if len(values) != 3:
+        raise ValueError(f'期望 xyz/rpy 里有 3 个数值,实际得到: {text}')
+    return values[0], values[1], values[2]
+
+
+def _parse_rgba(text: str | None) -> tuple[float, float, float, float]:
+    if not text:
+        return (0.75, 0.78, 0.88, 1.0)
+    values = [float(value) for value in text.split()]
+    if len(values) != 4:
+        raise ValueError(f'期望 rgba 里有 4 个数值,实际得到: {text}')
+    return values[0], values[1], values[2], values[3]
+
+
+def _float_text(values: tuple[float, ...] | list[float]) -> str:
+    return ' '.join(f'{value:.8g}' for value in values)
+
+
+def _quat_text_from_rpy(rpy: tuple[float, float, float]) -> str:
+    return _float_text(list(euler_to_quaternion_wxyz(*rpy)))
+
+
+def _mesh_path_from_package_uri(urdf_path: Path, filename: str) -> Path:
+    if filename.startswith('package://guguji_ros2/meshes/'):
+        return urdf_path.parents[1] / 'meshes' / filename.split('/')[-1]
+    if filename.startswith('file://'):
+        return Path(filename.removeprefix('file://'))
+    path = Path(filename)
+    if path.is_absolute():
+        return path
+    return urdf_path.parent / path
+
+
+def _safe_mesh_name(link_name: str) -> str:
+    return link_name.replace('-', '_')
+
+
+def _parse_link(link_element: ET.Element) -> UrdfLink:
+    name = link_element.attrib['name']
+
+    inertial_element = link_element.find('inertial')
+    inertial = None
+    if inertial_element is not None:
+        origin_element = inertial_element.find('origin')
+        mass_element = inertial_element.find('mass')
+        inertia_element = inertial_element.find('inertia')
+        if mass_element is not None and inertia_element is not None:
+            inertial = UrdfInertial(
+                xyz=_parse_xyz(origin_element.attrib.get('xyz') if origin_element is not None else None),
+                rpy=_parse_xyz(origin_element.attrib.get('rpy') if origin_element is not None else None),
+                mass=float(mass_element.attrib['value']),
+                inertia=(
+                    float(inertia_element.attrib['ixx']),
+                    float(inertia_element.attrib['iyy']),
+                    float(inertia_element.attrib['izz']),
+                    float(inertia_element.attrib.get('ixy', '0.0')),
+                    float(inertia_element.attrib.get('ixz', '0.0')),
+                    float(inertia_element.attrib.get('iyz', '0.0')),
+                ),
+            )
+
+    mesh = None
+    visual_element = link_element.find('visual')
+    if visual_element is not None:
+        origin_element = visual_element.find('origin')
+        geometry_element = visual_element.find('geometry')
+        material_element = visual_element.find('material')
+        rgba = (0.75, 0.78, 0.88, 1.0)
+        if material_element is not None:
+            color_element = material_element.find('color')
+            if color_element is not None:
+                rgba = _parse_rgba(color_element.attrib.get('rgba'))
+
+        if geometry_element is not None:
+            mesh_element = geometry_element.find('mesh')
+            if mesh_element is not None:
+                mesh = UrdfMesh(
+                    filename=mesh_element.attrib['filename'],
+                    xyz=_parse_xyz(origin_element.attrib.get('xyz') if origin_element is not None else None),
+                    rpy=_parse_xyz(origin_element.attrib.get('rpy') if origin_element is not None else None),
+                    rgba=rgba,
+                )
+
+    return UrdfLink(name=name, inertial=inertial, mesh=mesh)
+
+
+def _parse_joint(joint_element: ET.Element) -> UrdfJoint | None:
+    joint_type = joint_element.attrib.get('type', '')
+    if joint_type not in {'revolute', 'continuous'}:
+        return None
+
+    origin_element = joint_element.find('origin')
+    parent_element = joint_element.find('parent')
+    child_element = joint_element.find('child')
+    axis_element = joint_element.find('axis')
+    limit_element = joint_element.find('limit')
+
+    if parent_element is None or child_element is None:
+        raise ValueError(f'URDF 关节缺少 parent/child: {joint_element.attrib}')
+
+    lower = -3.1415926 if joint_type == 'continuous' else 0.0
+    upper = 3.1415926 if joint_type == 'continuous' else 0.0
+    effort = 0.0
+    if limit_element is not None:
+        lower = float(limit_element.attrib.get('lower', lower))
+        upper = float(limit_element.attrib.get('upper', upper))
+        effort = float(limit_element.attrib.get('effort', '0.0'))
+
+    return UrdfJoint(
+        name=joint_element.attrib['name'],
+        joint_type=joint_type,
+        parent=parent_element.attrib['link'],
+        child=child_element.attrib['link'],
+        xyz=_parse_xyz(origin_element.attrib.get('xyz') if origin_element is not None else None),
+        rpy=_parse_xyz(origin_element.attrib.get('rpy') if origin_element is not None else None),
+        axis=_parse_xyz(axis_element.attrib.get('xyz') if axis_element is not None else None),
+        lower=lower,
+        upper=upper,
+        effort=max(effort, 1.0),
+    )
+
+
+def _parse_urdf_robot(urdf_path: Path) -> tuple[dict[str, UrdfLink], list[UrdfJoint], str]:
+    root = ET.parse(urdf_path).getroot()
+    links = {
+        link_element.attrib['name']: _parse_link(link_element)
+        for link_element in root.findall('link')
+    }
+    joints = [joint for joint in (_parse_joint(element) for element in root.findall('joint')) if joint is not None]
+
+    child_links = {joint.child for joint in joints}
+    root_links = [link_name for link_name in links if link_name not in child_links]
+    if len(root_links) != 1:
+        raise ValueError(f'URDF 根链接数量异常,期望 1 个,实际得到: {root_links}')
+
+    return links, joints, root_links[0]
+
+
+def build_mujoco_model_spec(config: dict[str, Any]) -> MujocoModelSpec:
+    """根据当前 URDF 动态生成 MuJoCo 所需的 MJCF 字符串和 mesh 资源。"""
+    urdf_path = resolve_project_path(config, config['robot']['urdf_path'])
+    links, joints, root_link_name = _parse_urdf_robot(urdf_path)
+    child_joints: dict[str, list[UrdfJoint]] = {}
+    for joint in joints:
+        child_joints.setdefault(joint.parent, []).append(joint)
+
+    mujoco_config = config.get('mujoco', {})
+    timestep = float(mujoco_config.get('timestep', 0.005))
+    joint_damping = float(mujoco_config.get('joint_damping', 0.4))
+    joint_armature = float(mujoco_config.get('joint_armature', 0.01))
+    actuator_kp = float(mujoco_config.get('actuator_kp', 35.0))
+    solver_iterations = int(mujoco_config.get('solver_iterations', 60))
+    solver_ls_iterations = int(mujoco_config.get('solver_ls_iterations', 12))
+    floor_friction = tuple(float(value) for value in mujoco_config.get('floor_friction', [1.4, 0.05, 0.01]))
+    default_friction = tuple(float(value) for value in mujoco_config.get('default_friction', [0.9, 0.04, 0.01]))
+    foot_friction = tuple(float(value) for value in mujoco_config.get('foot_friction', [1.8, 0.05, 0.01]))
+    contact_margin = float(mujoco_config.get('contact_margin', 0.002))
+    contact_gap = float(mujoco_config.get('contact_gap', 0.0005))
+    camera_distance = float(mujoco_config.get('camera_distance', 1.4))
+    camera_elevation = float(mujoco_config.get('camera_elevation', -18.0))
+
+    assets: dict[str, bytes] = {}
+    root = ET.Element('mujoco', attrib={'model': f'{config["robot"]["model_name"]}_mujoco'})
+    ET.SubElement(
+        root,
+        'compiler',
+        attrib={
+            'angle': 'radian',
+            'coordinate': 'local',
+            'inertiafromgeom': 'false',
+            'meshdir': '.',
+        },
+    )
+    ET.SubElement(
+        root,
+        'option',
+        attrib={
+            'timestep': f'{timestep:.8g}',
+            'gravity': '0 0 -9.81',
+            'iterations': str(solver_iterations),
+            'ls_iterations': str(solver_ls_iterations),
+            'integrator': 'implicitfast',
+        },
+    )
+    ET.SubElement(root, 'size', attrib={'njmax': '512', 'nconmax': '256'})
+
+    default_element = ET.SubElement(root, 'default')
+    ET.SubElement(
+        default_element,
+        'joint',
+        attrib={
+            'limited': 'true',
+            'damping': f'{joint_damping:.8g}',
+            'armature': f'{joint_armature:.8g}',
+        },
+    )
+    ET.SubElement(
+        default_element,
+        'geom',
+        attrib={
+            'contype': '1',
+            'conaffinity': '1',
+            'margin': f'{contact_margin:.8g}',
+            'gap': f'{contact_gap:.8g}',
+            'friction': _float_text(list(default_friction)),
+            'solref': '0.004 1',
+            'solimp': '0.95 0.99 0.001',
+        },
+    )
+
+    asset_element = ET.SubElement(root, 'asset')
+
+    for link in links.values():
+        if link.mesh is None:
+            continue
+        mesh_path = _mesh_path_from_package_uri(urdf_path, link.mesh.filename)
+        if not mesh_path.exists():
+            raise FileNotFoundError(f'MuJoCo 需要的 mesh 文件不存在: {mesh_path}')
+
+        mesh_file_name = mesh_path.name
+        assets[mesh_file_name] = mesh_path.read_bytes()
+        ET.SubElement(
+            asset_element,
+            'mesh',
+            attrib={
+                'name': _safe_mesh_name(link.name),
+                'file': mesh_file_name,
+            },
+        )
+
+    worldbody_element = ET.SubElement(root, 'worldbody')
+    ET.SubElement(
+        worldbody_element,
+        'geom',
+        attrib={
+            'name': 'floor',
+            'type': 'plane',
+            'size': '3 3 0.1',
+            'pos': '0 0 0',
+            'rgba': '0.95 0.95 0.96 1',
+            'friction': _float_text(list(floor_friction)),
+        },
+    )
+    ET.SubElement(
+        worldbody_element,
+        'light',
+        attrib={
+            'name': 'sun',
+            'pos': '0 0 2.5',
+            'dir': '0 0 -1',
+            'diffuse': '1 1 1',
+            'specular': '0.2 0.2 0.2',
+        },
+    )
+    ET.SubElement(
+        worldbody_element,
+        'camera',
+        attrib={
+            'name': 'overview',
+            'mode': 'trackcom',
+            'pos': f'{camera_distance:.8g} 0 0.8',
+            'xyaxes': '0 1 0 -0.25 0 1',
+            'fovy': '45',
+        },
+    )
+
+    root_body_element = ET.SubElement(
+        worldbody_element,
+        'body',
+        attrib={
+            'name': root_link_name,
+            # 根 body 固定放在世界原点,真正的初始高度由 freejoint 的 qpos 在 reset 时设置。
+            'pos': '0 0 0',
+            'quat': '1 0 0 0',
+        },
+    )
+    ET.SubElement(root_body_element, 'freejoint', attrib={'name': 'base_free'})
+
+    def add_link_contents(body_element: ET.Element, link: UrdfLink) -> None:
+        if link.inertial is not None:
+            inertial_attributes = {
+                'pos': _float_text(list(link.inertial.xyz)),
+                'mass': f'{link.inertial.mass:.8g}',
+                'fullinertia': _float_text(list(link.inertial.inertia)),
+            }
+            # MuJoCo 的 fullinertia 不能和 quat 同时使用。
+            # 当前 URDF 导出的惯量坐标系都是零姿态,所以这里直接省略 quat;
+            # 如果后面你改出了非零 inertial rpy,就需要先把惯量矩阵旋转回 link 坐标系再写入。
+            ET.SubElement(body_element, 'inertial', attrib=inertial_attributes)
+
+        if link.mesh is None:
+            return
+
+        friction = foot_friction if 'foot' in link.name else default_friction
+        ET.SubElement(
+            body_element,
+            'geom',
+            attrib={
+                'name': f'{link.name}_geom',
+                'type': 'mesh',
+                'mesh': _safe_mesh_name(link.name),
+                'pos': _float_text(list(link.mesh.xyz)),
+                'quat': _quat_text_from_rpy(link.mesh.rpy),
+                'rgba': _float_text(list(link.mesh.rgba)),
+                'friction': _float_text(list(friction)),
+                # 这里显式依赖 URDF 里的惯量参数,不再让 MuJoCo 从 geom 反推质量。
+                'density': '0',
+            },
+        )
+
+    def add_child_bodies(parent_body_element: ET.Element, parent_link_name: str) -> None:
+        for joint in child_joints.get(parent_link_name, []):
+            child_link = links[joint.child]
+            child_body = ET.SubElement(
+                parent_body_element,
+                'body',
+                attrib={
+                    'name': child_link.name,
+                    'pos': _float_text(list(joint.xyz)),
+                    'quat': _quat_text_from_rpy(joint.rpy),
+                },
+            )
+            ET.SubElement(
+                child_body,
+                'joint',
+                attrib={
+                    'name': joint.name,
+                    'type': 'hinge',
+                    'axis': _float_text(list(joint.axis)),
+                    'range': f'{joint.lower:.8g} {joint.upper:.8g}',
+                    'damping': f'{joint_damping:.8g}',
+                    'armature': f'{joint_armature:.8g}',
+                },
+            )
+            add_link_contents(child_body, child_link)
+            add_child_bodies(child_body, child_link.name)
+
+    add_link_contents(root_body_element, links[root_link_name])
+    add_child_bodies(root_body_element, root_link_name)
+
+    actuator_element = ET.SubElement(root, 'actuator')
+    for joint_name in config['robot']['joint_names']:
+        joint = next(item for item in joints if item.name == joint_name)
+        ET.SubElement(
+            actuator_element,
+            'position',
+            attrib={
+                'name': f'{joint.name}_position',
+                'joint': joint.name,
+                'kp': f'{actuator_kp:.8g}',
+                'ctrlrange': f'{joint.lower:.8g} {joint.upper:.8g}',
+                'forcerange': f'{-joint.effort:.8g} {joint.effort:.8g}',
+            },
+        )
+
+    visual_element = ET.SubElement(root, 'visual')
+    ET.SubElement(
+        visual_element,
+        'headlight',
+        attrib={'ambient': '0.35 0.35 0.35', 'diffuse': '0.75 0.75 0.75', 'specular': '0.15 0.15 0.15'},
+    )
+    ET.SubElement(
+        visual_element,
+        'global',
+        attrib={
+            'azimuth': '90',
+            'elevation': f'{camera_elevation:.8g}',
+            'offwidth': str(int(mujoco_config.get('render_width', 1280))),
+            'offheight': str(int(mujoco_config.get('render_height', 720))),
+        },
+    )
+
+    ET.indent(root, space='  ')
+    xml_text = ET.tostring(root, encoding='unicode')
+    return MujocoModelSpec(xml=xml_text, assets=assets, root_link_name=root_link_name)
+
+
+def export_mujoco_xml(config: dict[str, Any], output_path: str | Path) -> Path:
+    """导出当前配置对应的 MJCF,方便你打开 XML 逐行调试。"""
+    spec = build_mujoco_model_spec(config)
+    output_path = Path(output_path)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(spec.xml, encoding='utf-8')
+    return output_path

+ 1 - 1
guguji_rl/guguji_rl/rewards.py

@@ -5,7 +5,7 @@ from dataclasses import dataclass
 import numpy as np
 
 from .math_utils import quaternion_xyzw_to_euler
-from .ros2_interface import RobotStateSnapshot
+from .state_types import RobotStateSnapshot
 from .urdf_utils import JointLimit
 
 

+ 1 - 9
guguji_rl/guguji_rl/ros2_interface.py

@@ -4,7 +4,6 @@ import threading
 import time
 import math
 import subprocess
-from dataclasses import dataclass
 from pathlib import Path
 from typing import Iterable
 
@@ -19,14 +18,7 @@ from sensor_msgs.msg import JointState
 from std_msgs.msg import Float64
 from tf2_msgs.msg import TFMessage
 
-
-@dataclass
-class RobotStateSnapshot:
-    sim_time: float
-    joint_position: np.ndarray
-    joint_velocity: np.ndarray
-    base_position: np.ndarray
-    base_quaternion: np.ndarray
+from .state_types import RobotStateSnapshot
 
 
 class _GugujiInterfaceNode(Node):

+ 20 - 0
guguji_rl/guguji_rl/state_types.py

@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass
+class RobotStateSnapshot:
+    """统一描述机器人在某一仿真时刻的基础状态。
+
+    Gazebo / ROS 2 和 MuJoCo 都会产出同样结构的数据,
+    这样奖励函数、评估脚本和策略回放逻辑就可以共用。
+    """
+
+    sim_time: float
+    joint_position: np.ndarray
+    joint_velocity: np.ndarray
+    base_position: np.ndarray
+    base_quaternion: np.ndarray

+ 1 - 0
guguji_rl/pyproject.toml

@@ -10,6 +10,7 @@ readme = "README.md"
 requires-python = ">=3.10"
 dependencies = [
   "gymnasium>=0.29",
+  "mujoco>=3.2.6",
   "numpy>=1.26",
   "PyYAML>=6.0",
   "rich>=13.7",

+ 1 - 0
guguji_rl/requirements.txt

@@ -2,6 +2,7 @@
 # 1. 这里不强行固定 torch 版本,方便你根据 CPU / CUDA 环境自行安装。
 # 2. 如果你要使用 GPU,请先按 PyTorch 官方方式安装带 CUDA 的 torch。
 gymnasium>=0.29
+mujoco>=3.2.6
 numpy>=1.26
 PyYAML>=6.0
 rich>=13.7

+ 3 - 3
guguji_rl/scripts/check_env.py

@@ -14,7 +14,7 @@ from guguji_rl.config import load_config
 
 
 def parse_args() -> argparse.Namespace:
-    parser = argparse.ArgumentParser(description='Sanity check for the Gazebo RL environment.')
+    parser = argparse.ArgumentParser(description='Sanity check for the configured RL environment.')
     parser.add_argument('--config', default='configs/balance_ppo.yaml', help='配置文件路径')
     parser.add_argument('--steps', type=int, default=5, help='检查时执行多少个 step')
     return parser.parse_args()
@@ -31,13 +31,13 @@ def main() -> int:
     args = parse_args()
 
     try:
-        from guguji_rl.envs import GazeboBipedEnv
+        from guguji_rl.envs import build_env_from_config
     except ImportError:
         print('缺少训练环境依赖,请先进入 guguji_rl 目录安装 requirements.txt', file=sys.stderr)
         return 1
 
     config = load_config(resolve_input_path(args.config))
-    env = GazeboBipedEnv(config)
+    env = build_env_from_config(config)
 
     observation, info = env.reset()
     print('reset ok')

+ 3 - 3
guguji_rl/scripts/evaluate.py

@@ -12,7 +12,7 @@ from guguji_rl.config import load_config
 
 
 def parse_args() -> argparse.Namespace:
-    parser = argparse.ArgumentParser(description='Evaluate trained PPO policy in Gazebo.')
+    parser = argparse.ArgumentParser(description='Evaluate trained PPO policy in the configured simulator.')
     parser.add_argument('--config', default='configs/walk_ppo.yaml', help='配置文件路径')
     parser.add_argument('--model', required=True, help='训练好的模型路径,例如 outputs/.../final_model.zip')
     parser.add_argument('--episodes', type=int, default=None, help='可选覆盖配置中的评估轮数')
@@ -35,13 +35,13 @@ def main() -> int:
         print('缺少 stable-baselines3,请先安装 requirements.txt', file=sys.stderr)
         return 1
 
-    from guguji_rl.envs import GazeboBipedEnv
+    from guguji_rl.envs import build_env_from_config
 
     config = load_config(resolve_input_path(args.config))
     if args.episodes is not None:
         config['evaluation']['episodes'] = args.episodes
 
-    env = GazeboBipedEnv(config)
+    env = build_env_from_config(config)
     model = PPO.load(resolve_input_path(args.model))
 
     for episode_index in range(int(config['evaluation']['episodes'])):

+ 36 - 0
guguji_rl/scripts/export_mujoco_xml.py

@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+SCRIPT_ROOT = Path(__file__).resolve().parents[1]
+if str(SCRIPT_ROOT) not in sys.path:
+    sys.path.insert(0, str(SCRIPT_ROOT))
+
+from guguji_rl.config import load_config
+from guguji_rl.evaluation import resolve_input_path
+from guguji_rl.mujoco_model import export_mujoco_xml
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description='Export the generated MuJoCo MJCF XML for inspection.')
+    parser.add_argument('--config', default='configs/mujoco_balance_ppo.yaml', help='配置文件路径')
+    parser.add_argument(
+        '--output',
+        default='guguji_rl/generated/guguji_mujoco.xml',
+        help='导出的 MJCF 文件路径',
+    )
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    config = load_config(resolve_input_path(SCRIPT_ROOT, args.config))
+    output_path = export_mujoco_xml(config, resolve_input_path(SCRIPT_ROOT, args.output))
+    print(f'已导出 MuJoCo XML: {output_path}')
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 14 - 3
guguji_rl/scripts/run_policy.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import argparse
+import copy
 import sys
 from pathlib import Path
 
@@ -12,7 +13,7 @@ from guguji_rl.config import load_config
 
 
 def parse_args() -> argparse.Namespace:
-    parser = argparse.ArgumentParser(description='Run a trained PPO policy online in Gazebo / ROS 2.')
+    parser = argparse.ArgumentParser(description='Run a trained PPO policy in Gazebo or MuJoCo.')
     parser.add_argument('--config', default='configs/walk_ppo.yaml', help='配置文件路径')
     parser.add_argument('--model', required=True, help='训练好的模型路径,例如 outputs/.../final_model.zip')
     parser.add_argument(
@@ -26,6 +27,11 @@ def parse_args() -> argparse.Namespace:
         default=0,
         help='最多运行多少个 episode,0 表示一直循环运行',
     )
+    parser.add_argument(
+        '--render-human',
+        action='store_true',
+        help='如果当前后端是 MuJoCo,则以交互窗口方式显示策略回放',
+    )
     return parser.parse_args()
 
 
@@ -45,11 +51,16 @@ def main() -> int:
         print('缺少 stable-baselines3,请先安装 requirements.txt', file=sys.stderr)
         return 1
 
-    from guguji_rl.envs import GazeboBipedEnv
+    from guguji_rl.envs import build_env_from_config
 
     config = load_config(resolve_input_path(args.config))
+    if args.render_human:
+        # 训练通常不渲染,但策略回放时可以临时把 MuJoCo 切到 human 模式。
+        config = copy.deepcopy(config)
+        config.setdefault('mujoco', {})
+        config['mujoco']['render_mode'] = 'human'
 
-    env = GazeboBipedEnv(config)
+    env = build_env_from_config(config)
     model = PPO.load(resolve_input_path(args.model))
 
     episode_index = 0

+ 13 - 2
guguji_rl/scripts/train.py

@@ -53,6 +53,11 @@ def parse_args() -> argparse.Namespace:
         action='store_true',
         help='训练完成后跳过自动前进评估',
     )
+    parser.add_argument(
+        '--render-human',
+        action='store_true',
+        help='如果当前后端是 MuJoCo,则在训练时同步打开 GUI 画面',
+    )
     return parser.parse_args()
 
 
@@ -134,7 +139,7 @@ def main() -> int:
         )
         return 1
 
-    from guguji_rl.envs import GazeboBipedEnv
+    from guguji_rl.envs import build_env_from_config
 
     config = load_config(resolve_input_path(args.config))
     if args.device is not None:
@@ -143,6 +148,12 @@ def main() -> int:
         config['training']['total_timesteps'] = args.total_timesteps
     if args.init_model is not None:
         config['training']['init_model_path'] = str(resolve_input_path(args.init_model))
+    if args.render_human:
+        # MuJoCo 训练默认关闭渲染以保证速度。
+        # 当你想一边训练一边看画面时,可以通过命令行临时打开 human 渲染。
+        config = copy.deepcopy(config)
+        config.setdefault('mujoco', {})
+        config['mujoco']['render_mode'] = 'human'
 
     # 这里统一解析训练设备,方便你只改 YAML 就切换 CPU / GPU。
     config['training']['device'] = resolve_device(config['training']['device'])
@@ -174,7 +185,7 @@ def main() -> int:
                 f'timesteps={int(stage_config["training"]["total_timesteps"])})'
             )
 
-        env = Monitor(GazeboBipedEnv(stage_config))
+        env = Monitor(build_env_from_config(stage_config))
         checkpoint_callback = CheckpointCallback(
             save_freq=max(int(stage_config['training']['checkpoint_freq']), 1),
             save_path=str(stage_dir / 'checkpoints'),