diff --git a/docs/dreamwaq_replication.md b/docs/dreamwaq_replication.md new file mode 100644 index 0000000..b40252c --- /dev/null +++ b/docs/dreamwaq_replication.md @@ -0,0 +1,57 @@ +# DreamWaQ 复刻进度 + +## 总览 + +| Phase | 状态 | 完成时间 | +|-------|------|----------| +| Phase 1: 环境差距补齐 | ✅ | 2026-06-30 | +| Phase 2: 地形生成 10×20 | ✅ | 2026-06-30 | +| Phase 3: CENet 网络集成 | ✅ | 2026-06-30 | +| Phase 4: 训练 Pipeline | ✅ | 2026-06-30 | +| Phase 5: 验证 | ⏳ | - | + +## Phase 3+4: CENet + 训练 + +**新增文件**: +- `motrix_rl/src/motrix_rl/rslrl/torch/models/cenet_actor.py` — CENetVAE + CENetActorModel +- `motrix_rl/src/motrix_rl/rslrl/torch/train/dreamwaq_ppo.py` — DreamWaQPPO(PPO + VAE loss) + +**修改文件**: +- `wrap_vec_env.py` — 多键 TensorDict(policy + obs_history + privileged_obs) +- `cfg.py` — 添加 vae_beta/cenet_in_dim/cenet_out_dim +- `go1.py` — go1-dreamwaq-walk 训练配置 + +**冒烟测试**:1 iteration 通过,VAE 损失 ≈ 2.85 + +### 数据流 + +``` +DreamWaQTask.update_observation() + → state.info["obs_history"] (N, 5, 45) + → state.info["privileged_obs"] (N, 247) + +RslrlNpEnvWrap._build_obs_dict() + → TensorDict({ + "policy": (N, 45), + "obs_history": (N, 225), + "privileged_obs": (N, 247), + }) + +CENetActorModel.get_latent() + → VAE(obs_history) → code(19) + → cat(code, policy) → latent(64) + → MLP[512,256,128] → action(12) + +DreamWaQPPO.update() + → + VAE loss(速度估计 + 重建 + KL) +``` + +## 训练命令 + +```bash +# 平坦地形 +uv run scripts/train.py --env go1-dreamwaq-walk --rllib rslrl + +# 金字塔地形 +DREAMWAQ_TERRAIN=pyramid uv run scripts/train.py --env go1-dreamwaq-walk --rllib rslrl +``` diff --git a/docs/dreamwaq_usage.md b/docs/dreamwaq_usage.md new file mode 100644 index 0000000..d2047af --- /dev/null +++ b/docs/dreamwaq_usage.md @@ -0,0 +1,78 @@ +# DreamWaQ 使用手册 + +## 环境切换 + +通过环境变量 `DREAMWAQ_TERRAIN` 选择地形场景: + +| 值 | 场景 | 描述 | +|----|------|------| +| `flat` (默认) | 无限平面 | 学习基础行走 | +| `pyramid` | 10×20 混合地形 | hfield + mesh 楼梯 | +| `flat_stairs` | 2 级 flat+stairs | 简易楼梯测试 | +| `stairs` | 纯楼梯 | stair box 场景 | + +```bash +# 金字塔地形训练 +DREAMWAQ_TERRAIN=pyramid uv run scripts/train.py --env go1-dreamwaq-walk --rllib rslrl + +# 平坦地形训练 +uv run scripts/train.py --env go1-dreamwaq-walk --rllib rslrl +``` + +## 地形生成 + +```bash +# 完整 10×20 地形(生成 PNG + OBJ + XML) +uv run python3 scripts/gen_dreamwaq_terrain.py + +# 仅前 N 个难度级别(测试用) +uv run python3 scripts/gen_dreamwaq_terrain.py --max-level 3 + +# 仅平坦(不生成楼梯) +uv run python3 scripts/gen_dreamwaq_terrain.py --flat-only +``` + +## 可视化 + +```bash +# 查看金字塔地形(随机动作) +uv run scripts/view_dreamwaq.py + +# 平坦地形 + 单机器人 + 固定难度 +uv run scripts/view_dreamwaq.py --flat --num-envs 1 --level 5 + +# 持续前进(不站立) +uv run scripts/view_dreamwaq.py --no-stand --vx 0.8 +``` + +## 训练 + +```bash +# 启动训练 +uv run scripts/train.py --env go1-dreamwaq-walk --rllib rslrl + +# 指定环境数量 +uv run scripts/train.py --env go1-dreamwaq-walk --rllib rslrl --num-envs 4096 +``` + +训练结果保存在 `runs/go1-dreamwaq-walk/rslrl/` 下,TensorBoard 日志自动记录: +- `value_loss`, `surrogate_loss` — 标准 PPO 损失 +- `autoenc_loss` — CENet VAE 自编码器损失 + +```bash +# 查看训练曲线 +uv run tensorboard --logdir runs/go1-dreamwaq-walk +``` + +## Play(评估) + +```bash +# 自动发现最新 checkpoint +uv run scripts/play_dreamwaq_rsl.py + +# 指定 checkpoint + 命令 +uv run scripts/play_dreamwaq_rsl.py --checkpoint runs/go1-dreamwaq-walk/rslrl/.../model_1000.pt --vx 0.5 + +# 固定地形级别查看 +uv run scripts/play_dreamwaq_rsl.py --terrain --level 5 --num-envs 1 +``` diff --git a/motrix_envs/src/motrix_envs/locomotion/go1/dreamwaq.py b/motrix_envs/src/motrix_envs/locomotion/go1/dreamwaq.py new file mode 100644 index 0000000..1cdac59 --- /dev/null +++ b/motrix_envs/src/motrix_envs/locomotion/go1/dreamwaq.py @@ -0,0 +1,625 @@ +# Copyright (C) 2020-2025 Motphys Technology Co., Ltd. All Rights Reserved. +"""DreamWaQ environment for MotrixLab — 对齐 Manaro-Alpha/DreamWaQ。 + +架构: + CENet (VAE): history(225) → [128,64] → latent(16) + vel_est(3) = code(19) + Actor: code(19) + obs(45) = 64 → [512,256,128] → action(12) + Critic: privileged_obs(247) → [512,256,128] → value(1) + +观测 (45-dim, 上游顺序): + ang_vel(3) + gravity(3) + commands(3) + joint_pos(12) + joint_vel(12) + actions(12) + +特权观测 (247-dim): + obs(45) + base_vel(3) + 足部接触力(12) + heights(187) + 注意:上游为 286(含全部 17 body × 3-axis 接触力),MotrixSim 仅用四足接触力 + +注册: "go1-dreamwaq-walk" +""" +import gymnasium as gym +import motrixsim as mtx +import numpy as np +from dataclasses import dataclass, field +import os + +from motrix_envs import registry +from motrix_envs.locomotion.go1.cfg import Go1WalkNpEnvCfg +from motrix_envs.locomotion.go1.walk_np import Go1WalkTask +from motrix_envs.math import quaternion + + +_SCENE_PRINTED = False + + +def _scene_file(): + """选择地形场景。DREAMWAQ_TERRAIN=flat|pyramid|flat_stairs。默认 FLAT。""" + global _SCENE_PRINTED + terrain = os.environ.get("DREAMWAQ_TERRAIN", "flat").lower() + if terrain in ("flat_stairs", "flatstairs"): + fname = "scene_flat_stairs.xml" + elif terrain in ("pyramid", "terrain", "1"): + fname = "scene_dreamwaq_terrain.xml" + elif terrain in ("stairs", "stairs_terrain"): + fname = "scene_stairs_terrain.xml" + else: + fname = "scene_dreamwaq_flat.xml" + if not _SCENE_PRINTED: + print(f"[DreamWaQ] scene = {fname} [set DREAMWAQ_TERRAIN to switch]") + _SCENE_PRINTED = True + return os.path.join(os.path.dirname(__file__), "xmls", fname) + + +# ═══════════════════════════════════════════════════════════════════════ +# Config +# ═══════════════════════════════════════════════════════════════════════ + +@registry.envcfg("go1-dreamwaq-walk") +@dataclass +class DreamWaQCfg(Go1WalkNpEnvCfg): + """DreamWaQ 配置 — 10×20 地形网格,5 种类型,10 个难度级别。""" + + model_file: str = field(default_factory=_scene_file) + + # CENet 参数 + num_latent: int = 16 + cenet_out_dim: int = 19 # code = vel(3) + latent(16) + num_history: int = 5 + + # 特权观测维度:obs(45) + base_vel(3) + 足部接触力(12) + heights(187) = 247 + num_privileged_obs: int = 247 + + max_episode_steps: int = 1000 # 与上游一致: 20s / 0.02s ctrl_dt + sim_dt: float = 0.005 + ctrl_dt: float = 0.02 + + # 高度测量网格(与上游一致: 17×11) + height_points_x: tuple = tuple(np.linspace(-0.8, 0.8, 17).tolist()) + height_points_y: tuple = tuple(np.linspace(-0.5, 0.5, 11).tolist()) + + # 地形网格 + terrain_rows: int = 10 + terrain_cols: int = 20 # 上游 10×20 网格:5 类型 × 10 难度 + cell_size: float = 8.0 + border_size: float = 5.0 + + # 命令范围(上游: [-1,1] 对称) + @dataclass + class Commands: + vel_limit = [[-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]] + + commands: Commands = field(default_factory=Commands) + + def __init__(self): + super().__init__() + self.model_file = _scene_file() + self.num_latent = 16 + self.cenet_out_dim = 19 + self.num_history = 5 + self.num_privileged_obs = 247 + self.sim_dt = 0.005 + self.ctrl_dt = 0.02 + if "flat_stairs" in self.model_file: + self.terrain_rows = 2 + else: + self.terrain_rows = 10 + self.terrain_cols = 20 + self.cell_size = 8.0 + self.border_size = 5.0 + self.height_points_x = tuple(np.linspace(-0.8, 0.8, 17).tolist()) + self.height_points_y = tuple(np.linspace(-0.5, 0.5, 11).tolist()) + self.commands = DreamWaQCfg.Commands() + self.control_config.stiffness = 28.0 + self.control_config.damping = 0.7 + self._apply_dreamwaq() + + def _apply_dreamwaq(self): + """DreamWaQ 奖励尺度 — 与上游 Go1RoughCfg 一致(上游会在 _prepare 中 × dt)。""" + r = self.reward_config.scales + r.clear() + r.update({ + "tracking_lin_vel": 1.0, + "tracking_ang_vel": 0.5, + "lin_vel_z": -2.0, + "ang_vel_xy": -0.05, + "orientation": -0.2, + "dof_acc": -2.5e-7, + "base_height": -1.0, + "feet_air_time": 0.1, + "action_rate": -0.01, + "joint_power": -2e-5, + "smoothness": -0.01, + "power_distribution": -10e-6, + # 注意:stand_still 在上游被注释掉 + }) + self.reward_config.only_positive_rewards = True + self.reward_config.tracking_sigma = 0.25 + + +# ═══════════════════════════════════════════════════════════════════════ +# DreamWaQ 环境 +# ═══════════════════════════════════════════════════════════════════════ + +@registry.env("go1-dreamwaq-walk", sim_backend="np") +class DreamWaQTask(Go1WalkTask): + """DreamWaQ 环境:不对称观测、历史缓冲区、高度测量。""" + + _cfg: DreamWaQCfg + + def __init__(self, cfg: DreamWaQCfg = None, num_envs=1): + if cfg is None: + cfg = DreamWaQCfg() + super().__init__(cfg, num_envs) + + # 覆盖观测空间(DreamWaQ 45-dim,比基类少 linvel 3-dim) + self._observation_space = gym.spaces.Box( + low=-np.inf, high=np.inf, shape=(45,), dtype=np.float32) + self._num_observation = 45 + + self._hx = np.array(cfg.height_points_x, dtype=np.float32) + self._hy = np.array(cfg.height_points_y, dtype=np.float32) + self._num_rows = cfg.terrain_rows + self._num_cols = cfg.terrain_cols + self._cell_size = cfg.cell_size + self._border = cfg.border_size + self._init_done = False + self._all_terrain_types = np.tile( + np.arange(self._num_cols), num_envs // self._num_cols + 1)[:num_envs] + np.random.shuffle(self._all_terrain_types) + self._hf_cache = None + self._hm_cache = None + + # ── 命令:全范围,无课程(匹配上游)── + + def resample_commands(self, num_envs: int) -> np.ndarray: + lim = np.array(self.cfg.commands.vel_limit, dtype=np.float32) + cmds = np.random.uniform(lim[0], lim[1], size=(num_envs, 3)).astype(np.float32) + small = np.linalg.norm(cmds[:, :2], axis=1) < 0.2 + cmds[small, :2] = 0.0 + return cmds + + # ── 动作裁剪 + 力矩计算 ── + + def apply_action(self, actions, state): + """裁剪动作防止奖励计算溢出(上游 clip_actions=100)。""" + actions = np.clip(actions, -100.0, 100.0) + return super().apply_action(actions, state) + + def _compute_torques(self, actions, data): + """PD 控制器 + 域随机化。力矩裁剪防溢出。""" + state = getattr(self, '_state', None) + if state is not None: + motor_strength = state.info.get("motor_strength", + np.ones(self._num_envs, dtype=np.float32)) + kp_factor = state.info.get("kp_factor", + np.ones(self._num_envs, dtype=np.float32)) + kd_factor = state.info.get("kd_factor", + np.ones(self._num_envs, dtype=np.float32)) + else: + motor_strength = np.ones(1, dtype=np.float32) + kp_factor = np.ones(1, dtype=np.float32) + kd_factor = np.ones(1, dtype=np.float32) + + actions_scaled = actions * self.cfg.control_config.action_scale * motor_strength[:, np.newaxis] + torques = (self.kps * kp_factor[:, np.newaxis]) * ( + actions_scaled + self.default_angles - self.get_dof_pos(data) + ) - (self.kds * kd_factor[:, np.newaxis]) * self.get_dof_vel(data) + return np.clip(torques, -80.0, 80.0) + + # ── 观测 ── + + def _get_obs(self, data: mtx.SceneData, info: dict) -> np.ndarray: + """45-dim: ang_vel + gravity + commands + joint_pos + joint_vel + actions。""" + gyro = self.get_gyro(data) + pose = self._body.get_pose(data) + base_quat = pose[:, 3:7] + gravity = quaternion.rotate_inverse(base_quat, self.gravity_vec) + commands = info["commands"] * self.commands_scale + joint_pos = (self.get_dof_pos(data) - self.default_angles) * self.cfg.normalization.dof_pos + joint_vel = self.get_dof_vel(data) * self.cfg.normalization.dof_vel + noisy_gyro = gyro * self.cfg.normalization.ang_vel + actions = info["current_actions"] + obs = np.concatenate([ + noisy_gyro, gravity, commands, joint_pos, joint_vel, actions + ], axis=-1) + # 观测噪声 + nc = self.cfg.noise_config + noise_scale_vec = np.zeros(45, dtype=np.float32) + noise_scale_vec[0:3] = nc.scale_gyro + noise_scale_vec[3:6] = nc.scale_gravity + noise_scale_vec[6:9] = 0.0 + noise_scale_vec[9:21] = nc.scale_joint_angle + noise_scale_vec[21:33] = nc.scale_joint_vel + noise_scale_vec[33:45] = 0.0 + noise = (2.0 * np.random.rand(*obs.shape).astype(np.float32) - 1.0) * nc.level + obs = obs + noise * noise_scale_vec[np.newaxis, :] + return obs + + # ── 高度测量 ── + + def _get_heights(self, data: mtx.SceneData) -> np.ndarray: + n = data.shape[0] + nx, ny = len(self._hx), len(self._hy) + pose = self._body.get_pose(data) + base_pos = pose[:, 0:3] + yaw = quaternion.get_yaw(pose[:, 3:7]) + cos_yaw, sin_yaw = np.cos(yaw), np.sin(yaw) + try: + if self._hm_cache is None: + hf = self._model.get_hfield(0) + self._hf_cache = hf + hm = hf.height_matrix + self._hm_cache = hm + nr, nc = hm.shape + b = hf.bound + self._h_xmin, self._h_ymin = b[0], b[1] + self._h_xmax, self._h_ymax = b[3], b[4] + self._h_nr, self._h_nc = nr, nc + cfg_base = getattr(self.cfg, "hfield_z_base", None) + self._h_zbase = float(cfg_base) if cfg_base is not None else float(-hm[0, 0]) + hm, nr, nc = self._hm_cache, self._h_nr, self._h_nc + xmin, ymin, xmax, ymax = self._h_xmin, self._h_ymin, self._h_xmax, self._h_ymax + z_base = self._h_zbase + w, h = xmax - xmin, ymax - ymin + except Exception: + return np.zeros((n, nx * ny), dtype=np.float32) + + heights = np.zeros((n, nx * ny), dtype=np.float32) + idx = 0 + for iy in range(ny): + gy = self._hy[iy] + for ix in range(nx): + gx = self._hx[ix] + wx = base_pos[:, 0] + cos_yaw * gx - sin_yaw * gy + wy = base_pos[:, 1] + sin_yaw * gx + cos_yaw * gy + col_f = (wx - xmin) / max(w, 1e-6) * (nc - 1) + row_f = (ymax - wy) / max(h, 1e-6) * (nr - 1) + col = np.clip(np.nan_to_num(col_f, nan=0).astype(np.int32), 0, nc - 1) + row = np.clip(np.nan_to_num(row_f, nan=0).astype(np.int32), 0, nr - 1) + heights[:, idx] = hm[row, col] + z_base + idx += 1 + return heights + + def _sample_terrain_height(self, xy: np.ndarray, radius: float = 0.0) -> np.ndarray: + n = xy.shape[0] + try: + hm, nr, nc = self._hm_cache, self._h_nr, self._h_nc + xmin, ymin, xmax, ymax = self._h_xmin, self._h_ymin, self._h_xmax, self._h_ymax + z_base = self._h_zbase + w, h = xmax - xmin, ymax - ymin + except Exception: + return np.zeros(n, dtype=np.float32) + col = np.clip(np.nan_to_num((xy[:, 0] - xmin) / max(w, 1e-6) * (nc - 1), nan=0).astype(np.int32), 0, nc - 1) + row = np.clip(np.nan_to_num((ymax - xy[:, 1]) / max(h, 1e-6) * (nr - 1), nan=0).astype(np.int32), 0, nr - 1) + if radius <= 0: + return (hm[row, col] + z_base).astype(np.float32) + dc = max(1, int(radius / max(w, 1e-6) * (nc - 1))) + dr = max(1, int(radius / max(h, 1e-6) * (nr - 1))) + out = np.empty(n, dtype=np.float32) + for i in range(n): + r, c = int(row[i]), int(col[i]) + out[i] = hm[max(0, r - dr):r + dr + 1, max(0, c - dc):c + dc + 1].max() + return out + z_base + + # ── 足部接触力 ── + + def _read_contact_forces(self, data: mtx.SceneData) -> np.ndarray: + """读取四足接触力(body frame)→ (N, 12)。""" + pose = self._body.get_pose(data) + base_quat = pose[:, 3:7] + forces = [] + for foot in ["FR", "FL", "RR", "RL"]: + v = self._model.get_sensor_value(foot + "_foot_contact", data) + v_body = quaternion.rotate_inverse(base_quat, v) + forces.append(v_body) + return np.concatenate(forces, axis=1) + + def _get_privileged_obs(self, data: mtx.SceneData, obs: np.ndarray) -> np.ndarray: + base_vel = self.get_local_linvel(data) * self.cfg.normalization.lin_vel + heights = self._get_heights(data) + contact_forces = self._read_contact_forces(data) + return np.concatenate([obs, base_vel, contact_forces, heights], axis=-1) + + # ── update_observation ── + + def update_observation(self, state): + data = state.data + obs = self._get_obs(data, state.info) + + # 更新历史缓冲区 + old_history = state.info.get("obs_history", + np.zeros((self._num_envs, self._cfg.num_history, 45), dtype=np.float32)) + new_history = np.concatenate([old_history[:, 1:, :], obs[:, np.newaxis, :]], axis=1) + state.info["obs_history"] = new_history + + current_step = state.info.get("steps", np.zeros(self._num_envs, dtype=np.int32)) + + # 中期 command 重采样(每 10 秒) + resample_steps = int(10.0 / self.cfg.ctrl_dt) + do_resample = (current_step % resample_steps) == 0 + if do_resample.any(): + n_resample = int(do_resample.sum()) + state.info["commands"][do_resample] = self.resample_commands(n_resample) + + # 周期性域随机化(每 4s) + rand_interval = int(4.0 / self.cfg.ctrl_dt) + last_rand = state.info.get("last_rand_step", np.zeros(self._num_envs, dtype=np.int32)) + do_rand = (current_step - last_rand) >= rand_interval + if do_rand.any(): + n_rand = int(do_rand.sum()) + state.info["motor_strength"][do_rand] = np.random.uniform(0.9, 1.1, size=n_rand).astype(np.float32) + state.info["kp_factor"][do_rand] = np.random.uniform(0.9, 1.1, size=n_rand).astype(np.float32) + state.info["kd_factor"][do_rand] = np.random.uniform(0.9, 1.1, size=n_rand).astype(np.float32) + state.info["friction_coeff"][do_rand] = np.random.uniform(0.2, 1.25, size=n_rand).astype(np.float32) + state.info["added_mass"][do_rand] = np.random.uniform(-1.0, 2.0, size=n_rand).astype(np.float32) + state.info["com_displacement"][do_rand] = np.random.uniform(-0.05, 0.05, size=(n_rand, 3)).astype(np.float32) + state.info["last_rand_step"][do_rand] = current_step[do_rand] + + # 特权观测 + state.info["privileged_obs"] = self._get_privileged_obs(data, obs) + state.info["base_vel"] = self.get_local_linvel(data) + + # 足部接触 + cquerys = self._model.get_contact_query(data) + state.info["contacts"] = cquerys.is_colliding(self.foot_check).reshape( + (self._num_envs, self.foot_check_num)) + state.info["feet_air_time"] = self.update_feet_air_time(state.info) + + # 累计命令距离和跟踪 + cmd_speed = np.linalg.norm(state.info["commands"][:, :2], axis=1) + state.info["ep_cmd_distance"] = state.info.get("ep_cmd_distance", + np.zeros(self._num_envs, dtype=np.float32)) + cmd_speed * self.cfg.ctrl_dt + state.info["ep_steps"] = state.info.get("ep_steps", np.zeros(self._num_envs, dtype=np.int32)) + 1 + local_vel = self.get_local_linvel(data)[:, :2] + cmd_vel = state.info["commands"][:, :2] + vel_error = np.sum(np.square(cmd_vel - local_vel), axis=1) + tracking = np.exp(-vel_error / self.cfg.reward_config.tracking_sigma) + state.info["ep_tracking_sum"] = state.info.get("ep_tracking_sum", + np.zeros(self._num_envs, dtype=np.float32)) + tracking + + return state.replace(obs=obs) + + # ── 出生点 ── + + def _make_origins(self, levels, indices): + n = len(levels) + half_x = self._border + self._num_cols * self._cell_size / 2.0 + half_y = self._border + self._num_rows * self._cell_size / 2.0 + origins = np.zeros((n, 2), dtype=np.float32) + for i in range(n): + row = levels[i] + col = self._all_terrain_types[int(indices[i]) % len(self._all_terrain_types)] + cx = -half_x + self._border + col * self._cell_size + self._cell_size / 2 + cy = half_y - self._border - row * self._cell_size - self._cell_size / 2 + origins[i, 0] = cx + origins[i, 1] = cy + return origins + + # ── Reset ── + + def reset(self, data) -> tuple[np.ndarray, dict]: + num_reset = data.shape[0] + state = getattr(self, '_state', None) + if state is not None and hasattr(state, 'done'): + done = state.done + done_idx = np.where(done)[0] + else: + done_idx = np.arange(num_reset) + + # 游戏启发式地形课程(per-env,与上游一致) + if not hasattr(self, '_terrain_origins'): + all_levels = np.repeat(np.arange(self._num_rows), self._num_cols) + all_indices = np.tile(np.arange(self._num_cols), self._num_rows) + all_origins = self._make_origins(all_levels, all_indices) + self._terrain_origins = all_origins.reshape(self._num_rows, self._num_cols, 2) + self._max_init_level = 5 + + if num_reset > 0 and self._init_done and state is not None and hasattr(state, 'info'): + old_info = state.info + old_origins = old_info.get("env_origins", np.zeros((self._num_envs, 2), dtype=np.float32))[done] + old_levels = old_info.get("terrain_level", np.zeros(self._num_envs, dtype=np.int32))[done] + old_commands = old_info.get("commands", np.zeros((self._num_envs, 3), dtype=np.float32))[done] + base_pose = self._body.get_pose(state.data) + base_pos = base_pose[done, :2] + distance = np.linalg.norm(base_pos - old_origins, axis=1) + move_up = distance > (self._cell_size / 2.0) + cmd_speed = np.linalg.norm(old_commands[:, :2], axis=1) + required_dist = cmd_speed * (self.cfg.max_episode_steps * self.cfg.ctrl_dt) * 0.5 + move_down = (distance < required_dist) & ~move_up + new_levels = np.where(move_up, old_levels + 1, old_levels) + new_levels = np.where(move_down, new_levels - 1, new_levels) + at_max = new_levels >= self._num_rows + if at_max.any(): + new_levels[at_max] = np.random.randint(0, self._num_rows, size=int(at_max.sum())) + new_levels = np.clip(new_levels, 0, self._num_rows - 1) + else: + new_levels = np.random.randint(0, self._max_init_level + 1, size=num_reset, dtype=np.int32) + self._init_done = True + + _force = getattr(self, "_force_level", None) + if _force is not None: + new_levels = np.full(num_reset, int(_force), dtype=np.int32) + + if "scene_stairs_terrain" in self.cfg.model_file: + height_list = np.array([-1.0, 0.5, 1.5], dtype=np.float32) + offset_h = [[2, 0, 2, 1, 1], [2, 2, 1, 0, 0], [1, 1, 2, 1, 2], + [0, 1, 0, 2, 0], [0, 1, 1, 0, 2]] + offsets = [] + for i in range(5): + for j in range(5): + offsets.append([(i-2)*8.0, (j-2)*8.0, height_list[offset_h[j][i]]]) + offset_arr = np.array(offsets, dtype=np.float32) + idx = np.random.choice(len(offsets), size=num_reset) + new_origins = offset_arr[idx, :2] + self._stairs_z_offsets = offset_arr[idx, 2] + else: + indices = done_idx + if hasattr(self, '_terrain_origins'): + new_origins = np.zeros((num_reset, 2), dtype=np.float32) + for i in range(num_reset): + row = int(new_levels[i]) + col = self._all_terrain_types[int(indices[i]) % len(self._all_terrain_types)] + new_origins[i] = self._terrain_origins[row, col] + else: + new_origins = self._make_origins(new_levels, indices) + self._stairs_z_offsets = np.zeros(num_reset, dtype=np.float32) + + data.reset(self._model) + + init_dof_pos = np.tile(self._init_dof_pos, (num_reset, 1)) + init_dof_pos[:, 0] = new_origins[:, 0] + init_dof_pos[:, 1] = new_origins[:, 1] + if hasattr(self, '_stairs_z_offsets') and self._stairs_z_offsets.any(): + init_dof_pos[:, 2] = self._stairs_z_offsets + else: + terrain_z = self._sample_terrain_height(new_origins, radius=0.35) + spawn_abs = getattr(self, "_spawn_absolute", None) + if spawn_abs is not None: + init_dof_pos[:, 2] = float(spawn_abs) + else: + init_dof_pos[:, 2] = terrain_z + getattr(self, "_spawn_clearance", 0.45) + yaw = np.random.uniform(-np.pi, np.pi, size=num_reset) + init_dof_pos[:, 3] = 0.0 + init_dof_pos[:, 4] = 0.0 + init_dof_pos[:, 5] = np.sin(yaw / 2) + init_dof_pos[:, 6] = np.cos(yaw / 2) + + init_dof_vel = np.tile(self._init_dof_vel, (num_reset, 1)) + data.set_dof_vel(init_dof_vel) + data.set_dof_pos(init_dof_pos, self._model) + self._model.forward_kinematic(data) + + info = { + "current_actions": np.zeros((num_reset, self._num_action), dtype=np.float32), + "last_actions": np.zeros((num_reset, self._num_action), dtype=np.float32), + "last_last_actions": np.zeros((num_reset, self._num_action), dtype=np.float32), + "commands": self.resample_commands(num_reset), + "last_dof_vel": np.zeros((num_reset, self._num_action), dtype=np.float32), + "feet_air_time": np.zeros((num_reset, self.foot_check_num), dtype=np.float32), + "contacts": np.zeros((num_reset, self.foot_check_num), dtype=np.bool), + "last_contacts": np.zeros((num_reset, self.foot_check_num), dtype=np.bool), + "motor_strength": np.random.uniform(0.9, 1.1, size=num_reset).astype(np.float32), + "kp_factor": np.random.uniform(0.9, 1.1, size=num_reset).astype(np.float32), + "kd_factor": np.random.uniform(0.9, 1.1, size=num_reset).astype(np.float32), + "friction_coeff": np.random.uniform(0.2, 1.25, size=num_reset).astype(np.float32), + "added_mass": np.random.uniform(-1.0, 2.0, size=num_reset).astype(np.float32), + "com_displacement": np.random.uniform(-0.05, 0.05, size=(num_reset, 3)).astype(np.float32), + "last_rand_step": np.zeros(num_reset, dtype=np.int32), + "ep_cmd_distance": np.zeros(num_reset, dtype=np.float32), + "ep_steps": np.zeros(num_reset, dtype=np.int32), + "ep_tracking_sum": np.zeros(num_reset, dtype=np.float32), + } + obs = self._get_obs(data, info) + + hist = np.zeros((num_reset, self._cfg.num_history, 45), dtype=np.float32) + hist[:, -1, :] = obs + info["obs_history"] = hist + + info["privileged_obs"] = self._get_privileged_obs(data, obs) + info["base_vel"] = self.get_local_linvel(data) + info["terrain_level"] = new_levels + info["env_origins"] = new_origins + + # 清除 ep_ 奖励累计器(跳过 ep_report 它是 dict) + if state is not None: + for k in list(state.info.keys()): + if k.startswith("ep_") and k != "ep_report" and k not in info: + info[k] = np.zeros(num_reset, dtype=np.float32) + + return obs, info + + # ── 奖励(与上游对齐,update_reward 中 × dt)── + + def _get_reward(self, data: mtx.SceneData, info: dict) -> dict[str, np.ndarray]: + """DreamWaQ 奖励项——与上游 Manaro-Alpha 对齐。""" + commands = info["commands"] + return { + "tracking_lin_vel": self._reward_tracking_lin_vel(data, commands), + "tracking_ang_vel": self._reward_tracking_ang_vel(data, commands), + "lin_vel_z": self._reward_lin_vel_z(data), + "ang_vel_xy": self._reward_ang_vel_xy(data), + "orientation": self._reward_orientation(data), + "dof_acc": self._reward_dof_acc(data, info), + "base_height": self._reward_base_height(data), + "feet_air_time": self._reward_feet_air_time(commands, info), + "action_rate": self._reward_action_rate(info), + "joint_power": self._reward_joint_power(data), + "smoothness": self._reward_smoothness(info), + "power_distribution": self._reward_power_distribution(data), + # stand_still 在上游被注释掉 + } + + def _reward_feet_air_time(self, commands, info): + """足部腾空时间奖励——与上游 legged_robot.py 公式一致。""" + first_contact = info.get("first_contact") + air_time = info.get("air_time_at_contact") + if first_contact is None or air_time is None: + return np.zeros(self._num_envs, dtype=np.float32) + rew = np.sum((air_time - 0.5) * first_contact, axis=1) + rew *= np.linalg.norm(commands[:, :2], axis=1) > 0.1 + return rew + + def update_reward(self, state): + """存储各项奖励到 TensorBoard + 累计 episode 总和。""" + reward_dict = self._get_reward(state.data, state.info) + # 乘系数 + dt(与上游 _prepare_reward_function 对齐) + scales = self._cfg.reward_config.scales + dt = self._cfg.ctrl_dt + scaled_terms = { + k: v * scales.get(k, 0.0) * dt + for k, v in reward_dict.items() + } + state.info["reward_terms"] = {k: float(np.mean(v)) for k, v in scaled_terms.items()} + # 累计 episode 总和 + for k, v in scaled_terms.items(): + ek = f"ep_{k}" + state.info[ek] = state.info.get(ek, np.zeros(self._num_envs, dtype=np.float32)) + v + + # 对即将结束的 env 存 ep_report + steps = state.info.get("steps", np.zeros(self._num_envs, dtype=np.int32)) + will_end = state.terminated | (steps >= self._cfg.max_episode_steps - 1) + if will_end.any(): + ep_report = state.info.get("ep_report", {}) + for k in scaled_terms: + ek = f"ep_{k}" + vals = state.info.get(ek, np.zeros(self._num_envs, dtype=np.float32))[will_end] + ep_report[f"rew_{k}"] = float(np.mean(vals)) + tl = state.info.get("terrain_level", np.zeros(self._num_envs, dtype=np.int32))[will_end] + ep_report["terrain_level"] = float(np.mean(tl)) + state.info["ep_report"] = ep_report + + state = super().update_reward(state) + if self._cfg.reward_config.only_positive_rewards: + state = state.replace(reward=np.maximum(state.reward, 0.0)) + return state + + # ── 额外奖励函数 ── + + def _reward_base_height(self, data): + """惩罚偏离目标基础高度——与上游 Go1RoughCfg.base_height_target=0.30 一致。""" + pose = self._body.get_pose(data) + base_z = pose[:, 2] + heights = self._get_heights(data) + ground_level = np.mean(heights, axis=1) + target = 0.30 + return np.square(base_z - ground_level - target) + + def _reward_joint_power(self, data): + torque = np.clip(data.actuator_ctrls, -100, 100) + vel = np.clip(self.get_dof_vel(data), -100, 100) + return np.sum(np.abs(torque * vel), axis=1) + + def _reward_smoothness(self, info): + scale = self.cfg.control_config.action_scale + da = self.default_angles + a0 = info["current_actions"] * scale + da + a1 = info["last_actions"] * scale + da + a2 = info.get("last_last_actions", info["last_actions"]) * scale + da + diff = np.square(np.clip(a0 - 2.0 * a1 + a2, -1e4, 1e4)) + diff = diff * (info["last_actions"] != 0) + diff = diff * (info.get("last_last_actions", info["last_actions"]) != 0) + return np.sum(diff, axis=1) + + def _reward_power_distribution(self, data): + torque = np.clip(data.actuator_ctrls, -100, 100) + vel = np.clip(self.get_dof_vel(data), -100, 100) + power = torque * vel + return np.var(np.abs(power), axis=1) diff --git a/motrix_envs/src/motrix_envs/locomotion/go1/xmls/assets/dreamwaq_terrain.png b/motrix_envs/src/motrix_envs/locomotion/go1/xmls/assets/dreamwaq_terrain.png new file mode 100644 index 0000000..c6ed826 --- /dev/null +++ b/motrix_envs/src/motrix_envs/locomotion/go1/xmls/assets/dreamwaq_terrain.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fe0435e385f736b46a910b53d30135d7f6280c0d8daa2bd2f64b7df7962d998 +size 1439313 diff --git a/motrix_envs/src/motrix_envs/locomotion/go1/xmls/scene_dreamwaq_flat.xml b/motrix_envs/src/motrix_envs/locomotion/go1/xmls/scene_dreamwaq_flat.xml new file mode 100644 index 0000000..ce79e45 --- /dev/null +++ b/motrix_envs/src/motrix_envs/locomotion/go1/xmls/scene_dreamwaq_flat.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/motrix_envs/src/motrix_envs/locomotion/go1/xmls/scene_dreamwaq_terrain.xml b/motrix_envs/src/motrix_envs/locomotion/go1/xmls/scene_dreamwaq_terrain.xml new file mode 100644 index 0000000..310efa6 --- /dev/null +++ b/motrix_envs/src/motrix_envs/locomotion/go1/xmls/scene_dreamwaq_terrain.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/motrix_rl/src/motrix_rl/rslrl/cfg.py b/motrix_rl/src/motrix_rl/rslrl/cfg.py index 3b3910a..cdc5649 100644 --- a/motrix_rl/src/motrix_rl/rslrl/cfg.py +++ b/motrix_rl/src/motrix_rl/rslrl/cfg.py @@ -74,6 +74,10 @@ class RslRlPpoAlgorithmCfg: max_grad_norm: float = 1.0 normalize_advantage_per_mini_batch: bool = False rnd_cfg: dict | None = None + # DreamWaQ CENet 参数 + vae_beta: float = 1.0 # VAE KL 散度权重 + cenet_in_dim: int = 225 # 观测历史维度 (num_history × obs_dim) + cenet_out_dim: int = 19 # CENet code 维度 (vel_est 3 + latent 16) symmetry_cfg: dict | None = None diff --git a/motrix_rl/src/motrix_rl/rslrl/torch/models/__init__.py b/motrix_rl/src/motrix_rl/rslrl/torch/models/__init__.py new file mode 100644 index 0000000..5654ccc --- /dev/null +++ b/motrix_rl/src/motrix_rl/rslrl/torch/models/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2020-2025 Motphys Technology Co., Ltd. All Rights Reserved. +"""RSLRL 自定义模型模块。""" diff --git a/motrix_rl/src/motrix_rl/rslrl/torch/models/cenet_actor.py b/motrix_rl/src/motrix_rl/rslrl/torch/models/cenet_actor.py new file mode 100644 index 0000000..ce310e5 --- /dev/null +++ b/motrix_rl/src/motrix_rl/rslrl/torch/models/cenet_actor.py @@ -0,0 +1,162 @@ +# Copyright (C) 2020-2025 Motphys Technology Co., Ltd. All Rights Reserved. +"""CENet Actor 模型——DreamWaQ 的 VAE 编码器 + MLP Actor。 + +架构: + CENetVAE: obs_history(225) → encoder[128,64] → latent(16) + vel_est(3) = code(19) + code(19) → decoder[64,128] → 重建 obs(45) + CENetActorModel (继承 MLPModel): + code(19) + obs(45) = 64 → MLP[512,256,128] → action(12) + +用法: + class_name = "motrix_rl.rslrl.torch.models.cenet_actor:CENetActorModel" + obs_groups = {"actor": ["policy", "obs_history"], "critic": ["privileged_obs"]} +""" + +import torch +import torch.nn as nn +from tensordict import TensorDict + +from rsl_rl.models.mlp_model import MLPModel +from rsl_rl.modules import EmpiricalNormalization, HiddenState + + +# ═══ CENet VAE ═══ + +class CENetVAE(nn.Module): + """CENet VAE 编码器-解码器。 + + obs_history(225) → encoder → latent(16) + vel_est(3) = code(19) + code(19) → decoder → 重建 obs(45) + """ + + def __init__(self, cenet_in_dim: int = 225, cenet_out_dim: int = 19, + activation: str = "elu"): + super().__init__() + act = _get_activation(activation) + self.cenet_in_dim = cenet_in_dim + self.cenet_out_dim = cenet_out_dim + + # 编码器:225 → 128 → 64 + self.encoder = nn.Sequential( + nn.Linear(cenet_in_dim, 128), act, + nn.Linear(128, 64), act, + ) + # 潜变量头:64 → 16 (mean + logvar) + self.encode_mean_latent = nn.Linear(64, cenet_out_dim - 3) + self.encode_logvar_latent = nn.Linear(64, cenet_out_dim - 3) + # 速度估计头:64 → 3 (mean + logvar) + self.encode_mean_vel = nn.Linear(64, 3) + self.encode_logvar_vel = nn.Linear(64, 3) + # 解码器:19 → 64 → 128 → 45 + self.decoder = nn.Sequential( + nn.Linear(cenet_out_dim, 64), act, + nn.Linear(64, 128), act, + nn.Linear(128, 45), + ) + + def reparameterise(self, mean: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor: + """重参数化技巧:从 N(mean, exp(logvar/2)) 采样。""" + std = torch.exp(logvar * 0.5) + eps = torch.randn_like(std) + return mean + std * eps + + def forward(self, obs_history: torch.Tensor): + """前向传播。 + + Returns: + code: (N, 19) 潜变量 [vel_sample(3) + latent_sample(16)] + code_vel: (N, 3) 速度估计采样 + decode: (N, 45) 重建观测 + mean_vel: (N, 3) 速度估计均值 + logvar_vel: (N, 3) 速度估计对数方差 + mean_latent: (N, 16) 潜变量均值 + logvar_latent: (N, 16) 潜变量对数方差 + """ + h = self.encoder(obs_history) + mean_latent = self.encode_mean_latent(h) + logvar_latent = self.encode_logvar_latent(h) + mean_vel = self.encode_mean_vel(h) + logvar_vel = self.encode_logvar_vel(h) + code_latent = self.reparameterise(mean_latent, logvar_latent) + code_vel = self.reparameterise(mean_vel, logvar_vel) + code = torch.cat((code_vel, code_latent), dim=-1) + decode = self.decoder(code) + return code, code_vel, decode, mean_vel, logvar_vel, mean_latent, logvar_latent + + def deterministic_code(self, obs_history: torch.Tensor) -> torch.Tensor: + """推理模式:使用均值而非采样,产生确定性 code(19)。""" + h = self.encoder(obs_history) + mean_latent = self.encode_mean_latent(h) + mean_vel = self.encode_mean_vel(h) + return torch.cat((mean_vel, mean_latent), dim=-1) + + +# ═══ CENet Actor Model ═══ + +class CENetActorModel(MLPModel): + """CENet Actor:继承 MLPModel,在 get_latent() 中注入 VAE code。 + + class_name = "motrix_rl.rslrl.torch.models.cenet_actor:CENetActorModel" + """ + + def __init__(self, obs: TensorDict, obs_groups: dict[str, list[str]], + obs_set: str, output_dim: int, + cenet_in_dim: int = 225, cenet_out_dim: int = 19, + activation: str = "elu", **kwargs): + # 必须在 super().__init__ 之前设置,因为 _get_latent_dim() 会被父类构造函数调用 + self._history_dim = cenet_in_dim + self._code_dim = cenet_out_dim + # 禁用观测归一化(VAE 输出已是归一化后的 code,维度也不匹配) + kwargs["obs_normalization"] = False + + super().__init__(obs, obs_groups, obs_set, output_dim, **kwargs) + + # 在 nn.Module.__init__ 之后创建 VAE 子模块 + self.vae = CENetVAE(cenet_in_dim, cenet_out_dim, activation) + self._last_cenet_output = None + + def _update_distribution(self, obs: torch.Tensor) -> None: + """覆盖父类 — 确保 std 始终为正,防止 NaN。""" + super()._update_distribution(obs) + # 如果 std 因数值问题变负,clamp 到最小值 + if self.stochastic and not self.state_dependent_std: + with torch.no_grad(): + if self.noise_std_type == "scalar": + self.std.clamp_(min=1e-6) + elif self.noise_std_type == "log": + self.log_std.clamp_(min=-20.0, max=10.0) + + def _get_latent_dim(self) -> int: + """Actor 实际输入:code(19) + policy(45) = 64。""" + return self.obs_dim - self._history_dim + self._code_dim + + def get_latent(self, obs: TensorDict, masks: torch.Tensor | None = None, + hidden_state: HiddenState = None) -> torch.Tensor: + """提取观测 → VAE 编码 → 拼接 code + policy → 返回 latent(64)。""" + policy_obs = obs["policy"] # (N, 45) + obs_history = obs["obs_history"] # (N, 225) + + out = self.vae(obs_history) + self._last_cenet_output = out + code, code_vel, decode, mean_vel, logvar_vel, mean_latent, logvar_latent = out + + latent = torch.cat([code, policy_obs], dim=-1) # (N, 64) + return latent + + def update_normalization(self, obs: TensorDict) -> None: + """CENetActor 使用 obs_normalization=False,此方法为空。""" + pass + + +# ═══ 工具函数 ═══ + +def _get_activation(act_name: str) -> nn.Module: + """解析激活函数名称。""" + _map = { + "elu": nn.ELU, "selu": nn.SELU, "relu": nn.ReLU, + "lrelu": nn.LeakyReLU, "tanh": nn.Tanh, "sigmoid": nn.Sigmoid, + } + if act_name in _map: + return _map[act_name]() + + raise ValueError(f"未知激活函数: {act_name}。可选: {list(_map.keys())}") diff --git a/motrix_rl/src/motrix_rl/rslrl/torch/train/dreamwaq_ppo.py b/motrix_rl/src/motrix_rl/rslrl/torch/train/dreamwaq_ppo.py new file mode 100644 index 0000000..2cb8f05 --- /dev/null +++ b/motrix_rl/src/motrix_rl/rslrl/torch/train/dreamwaq_ppo.py @@ -0,0 +1,205 @@ +# Copyright (C) 2020-2025 Motphys Technology Co., Ltd. All Rights Reserved. +"""DreamWaQ PPO——标准 PPO + CENet VAE 自编码器损失。 + +在 PPO.update() 的每个 mini-batch 中,额外计算: + - 速度估计损失:MSE(code_vel, 真实 base_vel)(来自 privileged_obs) + - 观测重建损失:MSE(decode, 当前 policy obs) + - KL 散度损失:beta * KL(N(mean, var) || N(0, 1)) + +class_name = "motrix_rl.rslrl.torch.train.dreamwaq_ppo:DreamWaQPPO" +""" + +import torch +import torch.nn as nn +from tensordict import TensorDict + +from rsl_rl.algorithms import PPO +from rsl_rl.storage import RolloutStorage +from rsl_rl.utils import resolve_callable, resolve_obs_groups + + +class DreamWaQPPO(PPO): + """PPO + Beta-VAE 自编码器损失——DreamWaQ CENet 训练。""" + + # VAE 损失权重(与上游 beta=1.0 一致) + vae_beta: float = 1.0 + + @staticmethod + def construct_algorithm(obs: TensorDict, env, cfg: dict, device: str) -> "DreamWaQPPO": + """构造 DreamWaQ PPO 算法——创建 CENetActor + MLP Critic。 + + 与父类 PPO.construct_algorithm 的区别: + - actor 使用 CENetActorModel(含 VAE) + - obs_groups 中 actor=["policy", "obs_history"], critic=["privileged_obs"] + """ + # 提取 DreamWaQ 特有参数 + vae_beta = cfg.pop("vae_beta", 1.0) + + # 解析 actor / critic 类 + actor_class = resolve_callable(cfg["actor"].pop("class_name")) + critic_class = resolve_callable(cfg["critic"].pop("class_name")) + + # 解析观测分组 + obs_groups = resolve_obs_groups(obs, cfg["obs_groups"], ["actor", "critic"]) + + # 创建 actor(CENetActorModel) + actor = actor_class(obs, obs_groups, "actor", env.num_actions, **cfg["actor"]).to(device) + + # 创建 critic(标准 MLPModel,输入 privileged_obs) + critic = critic_class(obs, obs_groups, "critic", 1, **cfg["critic"]).to(device) + + # 初始化 rollout 存储 + storage = RolloutStorage( + "rl", env.num_envs, cfg["num_steps_per_env"], obs, [env.num_actions], device + ) + + # 提取算法参数(移除 DreamWaQPPO 特有 key,剩余传给父类 PPO.__init__) + algo_cfg = dict(cfg["algorithm"]) + for dw_key in ("class_name", "vae_beta", "cenet_in_dim", "cenet_out_dim"): + algo_cfg.pop(dw_key, None) + + # 创建 DreamWaQPPO 实例 + alg = DreamWaQPPO(actor, critic, storage, device=device, **algo_cfg) + alg.vae_beta = vae_beta + return alg + + def update(self) -> dict[str, float]: + """标准 PPO update + VAE 自编码器损失。""" + mean_value_loss = 0.0 + mean_surrogate_loss = 0.0 + mean_autoenc_loss = 0.0 + + if self.actor.is_recurrent or self.critic.is_recurrent: + generator = self.storage.recurrent_mini_batch_generator( + self.num_mini_batches, self.num_learning_epochs) + else: + generator = self.storage.mini_batch_generator( + self.num_mini_batches, self.num_learning_epochs) + + for ( + obs_batch, actions_batch, target_values_batch, advantages_batch, + returns_batch, old_actions_log_prob_batch, old_mu_batch, + old_sigma_batch, hid_states_batch, masks_batch, + ) in generator: + # ── 标准 PPO 前向 ── + self.actor(obs_batch, masks=masks_batch, stochastic_output=True) + self.critic(obs_batch, masks=masks_batch) + + # ── 动作对数概率 ── + actions_log_prob_batch = self.actor.get_output_log_prob(actions_batch) + + # ── 价值损失 ── + value_batch = target_values_batch + if self.use_clipped_value_loss: + value_clipped = target_values_batch + (self.critic(obs_batch).detach() - target_values_batch).clamp( + -self.clip_param, self.clip_param) + value_losses = torch.square(self.critic(obs_batch) - returns_batch) + value_losses_clipped = torch.square(value_clipped - returns_batch) + value_loss = torch.max(value_losses, value_losses_clipped).mean() + else: + value_loss = torch.square(returns_batch - self.critic(obs_batch)).mean() + + # ── 代理损失 ── + surrogate_loss = self._compute_surrogate_loss( + actions_batch, actions_log_prob_batch, + old_actions_log_prob_batch, advantages_batch) + + # ── 熵 ── + entropy_batch = self.actor.output_entropy + entropy_loss = entropy_batch.mean() + + # ── VAE 自编码器损失 ── + autoenc_loss = self._compute_vae_loss(obs_batch) + + # ── 总损失 ── + loss = ( + surrogate_loss + + self.value_loss_coef * value_loss + - self.entropy_coef * entropy_loss + + autoenc_loss + ) + + # ── 梯度更新 ── + self.optimizer.zero_grad() + loss.backward() + if self.max_grad_norm is not None: + nn.utils.clip_grad_norm_(self.actor.parameters(), self.max_grad_norm) + nn.utils.clip_grad_norm_(self.critic.parameters(), self.max_grad_norm) + nn.utils.clip_grad_value_(self.actor.parameters(), 10.0) + self.optimizer.step() + # 每次更新后强制 std > 0,防止数值异常导致 NaN + if hasattr(self.actor, 'std') and self.actor.stochastic: + with torch.no_grad(): + self.actor.std.clamp_(min=1e-6) + + # ── 累计日志 ── + mean_value_loss += value_loss.item() + mean_surrogate_loss += surrogate_loss.item() + mean_autoenc_loss += autoenc_loss.item() + + num_updates = self.num_learning_epochs * self.num_mini_batches + mean_value_loss /= num_updates + mean_surrogate_loss /= num_updates + mean_autoenc_loss /= num_updates + + self.storage.clear() + + return { + "value_loss": mean_value_loss, + "surrogate_loss": mean_surrogate_loss, + "autoenc_loss": mean_autoenc_loss, + } + + def _compute_surrogate_loss( + self, actions_batch, actions_log_prob_batch, + old_actions_log_prob_batch, advantages_batch + ) -> torch.Tensor: + """计算 PPO 代理损失(从父类 PPO.update() 中提取)。""" + ratio = torch.exp(actions_log_prob_batch - old_actions_log_prob_batch) + surrogate = -advantages_batch * ratio + surrogate_clipped = -advantages_batch * torch.clamp( + ratio, 1.0 - self.clip_param, 1.0 + self.clip_param) + return torch.max(surrogate, surrogate_clipped).mean() + + def _compute_vae_loss(self, obs_batch: TensorDict) -> torch.Tensor: + """计算 CENet VAE 损失。 + + obs_batch 包含: + - "policy": 当前观测 (N, 45) = 重建目标 + - "obs_history": 观测历史 (N, 225) = VAE 编码器输入 + - "privileged_obs": 特权观测 (N, 247), 其中 [45:48] 是 base_vel + + 损失组成: + 1. 速度估计损失:MSE(code_vel, base_vel_gt) + 2. 观测重建损失:MSE(decoded_obs, policy_obs) + 3. KL 散度:beta * KL(q(z|history) || N(0,1)) + """ + # 从 actor 获取最近一次 CENet 前向输出 + cenet_out = getattr(self.actor, "_last_cenet_output", None) + if cenet_out is None: + return torch.tensor(0.0, device=obs_batch.device) + + code, code_vel, decode, mean_vel, logvar_vel, mean_latent, logvar_latent = cenet_out + + # 速度估计目标:privileged_obs 中的 base_vel(索引 45:48) + vel_target = obs_batch["privileged_obs"][:, 45:48] + # 观测重建目标:当前 policy obs + obs_target = obs_batch["policy"] + + mse = nn.functional.mse_loss + estimation_loss = mse(code_vel, vel_target) + reconstruction_loss = mse(decode, obs_target) + # KL 散度:-0.5 * sum(1 + logvar - mean^2 - exp(logvar)) + # clamp logvar 防止 exp 溢出 + logvar_latent = torch.clamp(logvar_latent, -20.0, 10.0) + kl_loss = -0.5 * torch.sum( + 1 + logvar_latent - mean_latent.pow(2) - logvar_latent.exp(), dim=-1 + ).mean() + + autoenc_loss = ( + estimation_loss + reconstruction_loss + self.vae_beta * kl_loss + ) + # 防止 NaN 传播 + if torch.isnan(autoenc_loss) or torch.isinf(autoenc_loss): + return torch.tensor(0.0, device=obs_batch.device) + return autoenc_loss diff --git a/motrix_rl/src/motrix_rl/rslrl/torch/train/ppo.py b/motrix_rl/src/motrix_rl/rslrl/torch/train/ppo.py index 0f7238a..25fa51d 100644 --- a/motrix_rl/src/motrix_rl/rslrl/torch/train/ppo.py +++ b/motrix_rl/src/motrix_rl/rslrl/torch/train/ppo.py @@ -16,6 +16,7 @@ """PPO Trainer for RSLRL integration.""" import logging +import os import torch from rsl_rl.runners import OnPolicyRunner @@ -48,6 +49,7 @@ class Trainer: sim_backend: str = None, enable_render: bool = False, cfg_override: dict = None, + env_cfg_override: dict = None, ) -> None: """Initialize the RSLRL PPO trainer. @@ -56,6 +58,7 @@ class Trainer: sim_backend: Simulation backend to use (e.g., "mujoco", "npcm") enable_render: Whether to enable rendering during training cfg_override: Optional configuration overrides + env_cfg_override: Optional env config overrides passed to make() """ rlcfg = rl_registry.default_rl_cfg(env_name, "rslrl", backend="torch") if cfg_override is not None: @@ -64,23 +67,29 @@ class Trainer: self._env_name = env_name self._sim_backend = sim_backend self._enable_render = enable_render + self._env_cfg_override = env_cfg_override - def train(self) -> None: + def train(self, checkpoint: str = None) -> None: """Start training the agent. Creates the environment, wraps it for RSLRL, and runs the training loop. + + Args: + checkpoint: Optional path to a checkpoint (.pt) to resume from. """ rlcfg = self._rlcfg # Create environment - env = env_registry.make(self._env_name, sim_backend=self._sim_backend, num_envs=rlcfg.num_envs) + env = env_registry.make(self._env_name, sim_backend=self._sim_backend, + num_envs=rlcfg.num_envs, env_cfg_override=self._env_cfg_override) # Set random seed if rlcfg.runner.seed is not None: torch.manual_seed(rlcfg.runner.seed) - # Determine device - device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + # Determine device(可通过 MOTRIX_DEVICE=cpu 强制 CPU 训练) + device_str = os.environ.get("MOTRIX_DEVICE", "cuda:0" if torch.cuda.is_available() else "cpu") + device = torch.device(device_str) logger.info(f"Using device: {device}") # Wrap environment for RSLRL @@ -94,6 +103,11 @@ class Trainer: vec_env, rslrl_cfg, log_dir=get_log_dir(self._env_name, rllib="rslrl", agent_name="PPO"), device=device ) + # Load checkpoint if specified + if checkpoint: + runner.load(checkpoint) + logger.info(f"Resumed from checkpoint: {checkpoint}") + # Start training logger.info(f"Starting training for {self._env_name}") logger.info(f"Number of environments: {rlcfg.num_envs}") @@ -117,14 +131,25 @@ class Trainer: rlcfg = self._rlcfg # Create environment with play_num_envs - env = env_registry.make(self._env_name, sim_backend=self._sim_backend, num_envs=rlcfg.play_num_envs) + # Enable play_mode if the env config supports it (for multi-terrain random spawn) + play_override = {"play_mode": True} + if self._env_cfg_override: + play_override.update(self._env_cfg_override) + try: + env = env_registry.make( + self._env_name, sim_backend=self._sim_backend, + num_envs=rlcfg.play_num_envs, env_cfg_override=play_override, + ) + except ValueError: + env = env_registry.make(self._env_name, sim_backend=self._sim_backend, num_envs=rlcfg.play_num_envs) # Set random seed if rlcfg.runner.seed is not None: torch.manual_seed(rlcfg.runner.seed) # Determine device - device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + device_str = os.environ.get("MOTRIX_DEVICE", "cuda:0" if torch.cuda.is_available() else "cpu") + device = torch.device(device_str) # Wrap environment for RSLRL vec_env = RslrlNpEnvWrap(env, device) diff --git a/motrix_rl/src/motrix_rl/rslrl/torch/wrap_vec_env.py b/motrix_rl/src/motrix_rl/rslrl/torch/wrap_vec_env.py index 919c2ef..25c7d12 100644 --- a/motrix_rl/src/motrix_rl/rslrl/torch/wrap_vec_env.py +++ b/motrix_rl/src/motrix_rl/rslrl/torch/wrap_vec_env.py @@ -83,6 +83,21 @@ class RslrlNpEnvWrap(VecEnv): """Return the unwrapped environment (self for this wrapper).""" return self + def _build_obs_dict(self, state) -> dict[str, torch.Tensor]: + """将 NpEnvState 中的观测字段组装为 TensorDict 字典。 + + 支持 env 通过 state.info 传递 obs_history 和 privileged_obs。 + """ + obs_dict = {"policy": torch.from_numpy(state.obs).to(self._device)} + if "obs_history" in state.info: + hist = state.info["obs_history"] # (N, num_history, obs_dim) + obs_dict["obs_history"] = torch.from_numpy(hist).reshape( + self._num_envs, -1).to(self._device) + if "privileged_obs" in state.info: + obs_dict["privileged_obs"] = torch.from_numpy( + state.info["privileged_obs"]).to(self._device) + return obs_dict + def step(self, actions: torch.Tensor) -> tuple[TensorDict, torch.Tensor, torch.Tensor, dict]: # Convert torch actions to numpy actions_np = actions.cpu().numpy() @@ -98,20 +113,24 @@ class RslrlNpEnvWrap(VecEnv): self.episode_length_buf[dones_np] = 0 # Convert to torch tensors - obs_tensor = torch.from_numpy(state.obs).to(self._device) rewards = torch.from_numpy(state.reward).to(self._device) # Merge terminated and truncated into dones dones = torch.from_numpy(state.done.astype(np.float32)).to(self._device) - # Create TensorDict for observations - obs = TensorDict({"policy": obs_tensor}, batch_size=[self._num_envs], device=self._device) + # 构建多键 TensorDict(policy + obs_history + privileged_obs) + obs = TensorDict(self._build_obs_dict(state), + batch_size=[self._num_envs], device=self._device) # Build extras dict (RSLRL calls it "extras" not "infos") extras = {} if "time_outs" in state.info: extras["time_outs"] = torch.from_numpy(state.info["time_outs"]).to(self._device) + # 将 episode 各项奖励传入 TensorBoard + if "ep_report" in state.info: + extras["episode"] = state.info["ep_report"] + return obs, rewards, dones, extras def reset(self) -> tuple[TensorDict, dict]: @@ -128,10 +147,9 @@ class RslrlNpEnvWrap(VecEnv): # Reset episode length buffer self.episode_length_buf.zero_() - obs_tensor = torch.from_numpy(state.obs).to(self._device) - - # Create TensorDict for observations - obs = TensorDict({"policy": obs_tensor}, batch_size=[self._num_envs], device=self._device) + # 构建多键 TensorDict + obs = TensorDict(self._build_obs_dict(state), + batch_size=[self._num_envs], device=self._device) # Build extras dict extras = {} @@ -139,17 +157,17 @@ class RslrlNpEnvWrap(VecEnv): return obs, extras def get_observations(self) -> TensorDict: - """Get current observations without stepping the environment. + """获取当前观测(不步进环境)。 Returns: - Current observations as TensorDict + 当前观测的 TensorDict(含 policy, obs_history, privileged_obs) """ if self._state is None: obs, _ = self.reset() return obs - obs_tensor = torch.from_numpy(self._state.obs).to(self._device) - obs = TensorDict({"policy": obs_tensor}, batch_size=[self._num_envs], device=self._device) + obs = TensorDict(self._build_obs_dict(self._state), + batch_size=[self._num_envs], device=self._device) return obs def render(self) -> None: diff --git a/motrix_rl/src/motrix_rl/tasks/go1.py b/motrix_rl/src/motrix_rl/tasks/go1.py index b100268..565cacb 100644 --- a/motrix_rl/src/motrix_rl/tasks/go1.py +++ b/motrix_rl/src/motrix_rl/tasks/go1.py @@ -66,6 +66,19 @@ class skrl: @dataclass class Go1WalkStairsPPO(Go1WalkRoughSkrlPpo): ... + @rlcfg("go1-stairs-terrain-walk-no-linevel") + @dataclass + class Go1WalkStairsNoLinvelSkrlPpo(Go1WalkRoughSkrlPpo): + """Go1 stairs terrain walk (no linear velocity obs) - SKRL PPO config. + + Uses [512, 256, 128] network from rough terrain config. + Increased timesteps for 4-phase curriculum. + """ + + def __post_init__(self): + super().__post_init__() + self.runner.trainer.timesteps = 60000 + class rslrl: @rlcfg("go1-flat-terrain-walk") @@ -93,6 +106,53 @@ class rslrl: algo.num_learning_epochs = 5 algo.num_mini_batches = 3 + @rlcfg("go1-dreamwaq-walk") + @dataclass + class Go1DreamWaQWalkRslrlPpo(RslrlCfg): + """Go1 DreamWaQ walk — CENet VAE + 不对称特权观测。""" + + num_envs: int = 1024 # 上游 4096,CPU/GPU 安全默认 + + def __post_init__(self): + runner = self.runner + + # Runner 设置(严格对齐上游 LeggedRobotCfgPPO + Go1RoughCfgPPO) + runner.seed = 5 # 上游 seed=5 + runner.max_iterations = 3000 + runner.num_steps_per_env = 24 + runner.experiment_name = "go1_dreamwaq_walk" + runner.save_interval = 50 + + # 算法:DreamWaQPPO(含 VAE loss)—— 严格对齐上游 + runner.algorithm.class_name = ( + "motrix_rl.rslrl.torch.train.dreamwaq_ppo:DreamWaQPPO") + runner.algorithm.learning_rate = 1e-3 # 上游 1.e-3 + runner.algorithm.num_learning_epochs = 5 + runner.algorithm.num_mini_batches = 4 + runner.algorithm.entropy_coef = 0.01 # 上游 Go1RoughCfgPPO + runner.algorithm.desired_kl = 0.01 # 上游 0.01 (默认 0.008) + runner.algorithm.clip_param = 0.2 + runner.algorithm.gamma = 0.99 + runner.algorithm.lam = 0.95 + runner.algorithm.max_grad_norm = 1.0 + runner.algorithm.vae_beta = 1.0 + + # Actor:CENetActorModel(code 替换 obs_history) + runner.actor.class_name = ( + "motrix_rl.rslrl.torch.models.cenet_actor:CENetActorModel") + runner.actor.hidden_dims = [512, 256, 128] + runner.actor.init_noise_std = 1.0 + + # Critic:标准 MLPModel,输入 privileged_obs + runner.critic.class_name = "MLPModel" + runner.critic.hidden_dims = [512, 256, 128] + + # 观测分组:actor 用 policy+history,critic 用 privileged_obs + runner.obs_groups = { + "actor": ["policy", "obs_history"], + "critic": ["privileged_obs"], + } + @rlcfg("go1-rough-terrain-walk") @dataclass class Go1WalkRoughRslrlPpo(Go1WalkFlatRslrlPpo): @@ -123,3 +183,18 @@ class rslrl: def __post_init__(self): super().__post_init__() self.runner.experiment_name = "go1_stairs_terrain_walk" + + @rlcfg("go1-stairs-terrain-walk-no-linevel") + @dataclass + class Go1WalkStairsNoLinvelRslrlPpo(Go1WalkRoughRslrlPpo): + """Go1 stairs terrain walk (no linear velocity obs) - RSLRL PPO config. + + Uses [512, 256, 128] network from rough terrain config. + Increased iterations for 4-phase curriculum. + """ + + def __post_init__(self): + super().__post_init__() + self.runner.experiment_name = "go1_stairs_terrain_walk_no_linevel" + self.runner.max_iterations = 2000 + self.runner.experiment_name = "go1_stairs_terrain_walk_no_linevel" diff --git a/scripts/gen_dreamwaq_terrain.py b/scripts/gen_dreamwaq_terrain.py new file mode 100644 index 0000000..1b89f9c --- /dev/null +++ b/scripts/gen_dreamwaq_terrain.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""生成 DreamWaQ 10×20 纯 hfield 地形——OpenCV 绘制。 + +5 种地形类型 × 10 难度,全部在单张 PNG 高度图中。 +楼梯用 1px riser 近垂直面(HS=0.05 时每像素 5cm)。 + +用法: + uv run python3 scripts/gen_dreamwaq_terrain.py +""" +import cv2 +import numpy as np +import os +import argparse + +# ═══ 参数 ═══ +HS = 0.05 # 水平分辨率 [m/px] +VS = 0.005 # 垂直分辨率 [m/unit] +CELL_M = 8.0 +NUM_ROWS = 10 +NUM_COLS = 20 +BORDER_M = 5.0 +PROPORTIONS = [0.1, 0.1, 0.35, 0.35, 0.1] +CUM = [sum(PROPORTIONS[:i + 1]) for i in range(len(PROPORTIONS))] +PLATFORM_M = 3.0 +_SLOPE_SCALE = 0.4 # 上游原值(已验证 z_scale 上限远超 0.54) + +CELL_PX = int(CELL_M / HS) # 160 +BORDER_PX = int(BORDER_M / HS) # 100 +PLATFORM_PX = int(PLATFORM_M / HS) # 60 +TOT_ROWS_PX = NUM_ROWS * CELL_PX + 2 * BORDER_PX # 1800 +TOT_COLS_PX = NUM_COLS * CELL_PX + 2 * BORDER_PX # 3400 +TOTAL_X = TOT_COLS_PX * HS +TOTAL_Y = TOT_ROWS_PX * HS + + +# ═══ 地形绘制 ═══ + +def draw_slope(canvas, x0, y0, difficulty, noise=False): + """平滑/粗糙斜坡——与上游 pyramid_sloped_terrain 对齐。 + + 上游逻辑:先建金字塔(中心高→边缘低),再用平台边缘高度 clip 整个 terrain, + 形成与周围地形齐平的平台(而非硬清零到 0)。 + """ + if difficulty <= 0: + return + slope = difficulty * _SLOPE_SCALE + max_h = int(slope * (1.0 / VS) * (CELL_M / 2.0)) + if max_h <= 0: + return + cx, cy = CELL_PX // 2, CELL_PX // 2 + x = np.arange(0, CELL_PX) + y = np.arange(0, CELL_PX) + xx, yy = np.meshgrid(x, y, sparse=True) + xx = (cx - np.abs(cx - xx)) / cx + yy = (cy - np.abs(cy - yy)) / cy + hf = (max_h * xx.reshape(CELL_PX, 1) * yy.reshape(1, CELL_PX)).astype(np.int32) + p2 = PLATFORM_PX // 2 + # 上游 clip: 取平台边缘高度作为上下界 + edge_h = int(hf[cx - p2, cy - p2]) + lo = min(edge_h, 0) + hi = max(edge_h, 0) + hf = np.clip(hf, lo, hi).astype(np.uint16) + if noise: + na = int(0.05 / VS) + n = np.random.randint(-na, na + 1, (CELL_PX, CELL_PX), dtype=np.int16) + # 噪声也只在平台外 + n[cx - p2:cx + p2, cy - p2:cy + p2] = 0 + hf = np.clip(hf.astype(np.int32) + n, 0, 65535).astype(np.uint16) + canvas[y0:y0 + CELL_PX, x0:x0 + CELL_PX] += hf + + +def draw_pyramid_stairs(canvas, x0, y0, difficulty, concave=False): + """金字塔楼梯——OpenCV 同心矩形(近垂直 riser)。 + + 每级台阶 2px 宽(10cm tread),高度缩放保持 z_scale < 0.54。 + """ + if difficulty <= 0: + return + # 上游公式:step_height = 0.05 + 0.18 * difficulty [m] + step_h_m = 0.05 + 0.18 * difficulty + step_h = max(1, int(step_h_m / VS)) + cx = x0 + CELL_PX // 2 + cy = y0 + CELL_PX // 2 + p2 = PLATFORM_PX // 2 + + # 上游踏面 31cm → 6px (HS=0.05), 最多约 8 级 + tread_px = max(1, int(0.31 / HS)) + n_steps = min(8, (CELL_PX // 2 - p2) // tread_px) + + if concave: + base_h = step_h * n_steps + cv2.rectangle(canvas, (x0, y0), (x0 + CELL_PX, y0 + CELL_PX), int(base_h), -1) + for i in range(n_steps + 1): + half = p2 + (n_steps - i) * tread_px + h = int(base_h - step_h * i) + cv2.rectangle(canvas, (cx - half, cy - half), (cx + half, cy + half), h, -1) + else: + for i in range(n_steps + 1): + half = p2 + (n_steps - i) * tread_px + h = int(step_h * i) + cv2.rectangle(canvas, (cx - half, cy - half), (cx + half, cy + half), h, -1) + + +def draw_obstacles(canvas, x0, y0, difficulty): + """离散障碍物(随机矩形块)。""" + if difficulty <= 0: + return + max_h = int((0.05 + 0.2 * difficulty) / VS) + if max_h <= 0: + return + p2 = PLATFORM_PX // 2 + # 上游: min_size=1.0m, max_size=2.0m, 20 个矩形 + min_sz = int(1.0 / HS); max_sz = int(2.0 / HS) + for _ in range(20): + w = np.random.randint(min_sz, max_sz + 1) + ln = np.random.randint(min_sz, max_sz + 1) + si = np.random.randint(0, CELL_PX - w) + sj = np.random.randint(0, CELL_PX - ln) + cv2.rectangle(canvas, (x0 + si, y0 + sj), + (x0 + si + w, y0 + sj + ln), + int(np.random.choice([max_h // 2, max_h])), -1) + cx, cy = x0 + CELL_PX // 2, y0 + CELL_PX // 2 + cv2.rectangle(canvas, (cx - p2, cy - p2), (cx + p2, cy + p2), 0, -1) + + +# ═══ 主流程 ═══ + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--flat-only", action="store_true") + p.add_argument("--max-level", type=int, default=None) + args = p.parse_args() + + max_row = NUM_ROWS if args.max_level is None else min(args.max_level + 1, NUM_ROWS) + print(f"DreamWaQ 纯 hfield ({max_row}×{NUM_COLS}) {TOT_COLS_PX}×{TOT_ROWS_PX}px") + + canvas = np.zeros((TOT_ROWS_PX, TOT_COLS_PX), dtype=np.uint16) + + for row in range(max_row): + difficulty = row / NUM_ROWS + for col in range(NUM_COLS): + if args.flat_only or difficulty == 0: + continue + x0 = BORDER_PX + col * CELL_PX + y0 = BORDER_PX + row * CELL_PX + choice = col / NUM_COLS + 0.001 + if choice < CUM[0]: + draw_slope(canvas, x0, y0, difficulty) + elif choice < CUM[1]: + draw_slope(canvas, x0, y0, difficulty, noise=True) + elif choice < CUM[2]: + draw_pyramid_stairs(canvas, x0, y0, difficulty, concave=True) + elif choice < CUM[3]: + draw_pyramid_stairs(canvas, x0, y0, difficulty, concave=False) + else: + draw_obstacles(canvas, x0, y0, difficulty) + + hf_m = canvas.astype(np.float32) * VS + z_min, z_max = float(hf_m.min()), float(hf_m.max()) + z_range = max(z_max - z_min, 0.001) + print(f" 高度范围: [{z_min:.3f}, {z_max:.3f}]m z_scale={z_range:.3f}") + + if z_range > 0.54: + print(f" ⚠ z_scale={z_range:.3f} > 0.54!") + + out_d = os.path.join(os.path.dirname(__file__), "..", + "motrix_envs", "src", "motrix_envs", + "locomotion", "go1", "xmls", "assets") + os.makedirs(out_d, exist_ok=True) + png = ((hf_m - z_min) / z_range * 65535.0).astype(np.uint16) + cv2.imwrite(os.path.join(out_d, "dreamwaq_terrain.png"), png) + + # XML + xml = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + xml_dir = os.path.join(os.path.dirname(__file__), "..", + "motrix_envs", "src", "motrix_envs", + "locomotion", "go1", "xmls") + with open(os.path.join(xml_dir, "scene_dreamwaq_terrain.xml"), "w") as f: + f.write(xml) + + half_x = TOTAL_X / 2 + half_y = TOTAL_Y / 2 + print(f" XML: size=\"{half_x:.1f} {half_y:.1f} {z_range:.3f} {max(z_min, 0.001):.3f}\"") + print(f" 楼梯: 1px tread (5cm), 1px riser → 近垂直面") + + +if __name__ == "__main__": + np.random.seed(42) + main() diff --git a/scripts/test_mesh_collision.py b/scripts/test_mesh_collision.py new file mode 100644 index 0000000..2ffa443 --- /dev/null +++ b/scripts/test_mesh_collision.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""最小 mesh 碰撞测试——验证 MotrixSim 的 OBJ mesh 是否支持碰撞。""" + +import os, numpy as np + +xml_dir = '/home/8x54zj-m/MotrixLab/motrix_envs/src/motrix_envs/locomotion/go1/xmls' +assets_dir = os.path.join(xml_dir, 'assets', 'tmp_test') +os.makedirs(assets_dir, exist_ok=True) +obj_path = os.path.join(assets_dir, 'test_box.obj') + +# 封闭 box OBJ (1x1x0.1m),带法线 +with open(obj_path, 'w') as f: + f.write("""# closed box +v -0.5 -0.5 0.0 +v 0.5 -0.5 0.0 +v 0.5 0.5 0.0 +v -0.5 0.5 0.0 +v -0.5 -0.5 0.1 +v 0.5 -0.5 0.1 +v 0.5 0.5 0.1 +v -0.5 0.5 0.1 +f 1 3 2 +f 1 4 3 +f 5 6 7 +f 5 7 8 +f 1 5 6 +f 1 6 2 +f 2 6 7 +f 2 7 3 +f 3 7 8 +f 3 8 4 +f 4 8 5 +f 4 5 1 +""") + +# 生成测试 XML +xml_path = os.path.join(xml_dir, 'scene_test_mesh.xml') +with open(xml_path, 'w') as f: + f.write(""" + + + + + + + + + + + + + + + +""") + +import motrixsim as mtx +model = mtx.load_model(xml_path) +print('加载成功') + +data = mtx.SceneData(model, batch=[1]) +data.reset(model) +body = model.get_body(0) + +init_pos = model.compute_init_dof_pos().reshape(1, -1) +init_pos[0, 0:2] = 0.0 # 在 box 正上方 +init_pos[0, 2] = 0.8 # 从 0.8m 自由落体 (box 顶面在 z=0.25) +data.set_dof_pos(init_pos, model) +model.forward_kinematic(data) + +print('自由落体到 box mesh (顶部 z=0.25):') +for i in range(80): + model.step(data) + bz = body.get_pose(data)[0, 2] + if i < 15 or i % 15 == 0: + print(f' 步{i+1}: base_z={bz:.4f}') + +bz_final = body.get_pose(data)[0, 2] +print(f'\n最终 base_z={bz_final:.4f}') +if bz_final > 0.45: + print('✅ mesh 碰撞正常!机器人站在 box 上') +elif bz_final < 0.10: + print('❌ mesh 碰撞不工作!机器人穿透 box 坠入深渊') +else: + print(f'⚠ 不确定: base_z={bz_final:.4f}') diff --git a/scripts/test_zscale_limit.py b/scripts/test_zscale_limit.py new file mode 100644 index 0000000..7c48212 --- /dev/null +++ b/scripts/test_zscale_limit.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""测试 MotrixSim hfield 的 z_scale 上限——自己动手测,不信文档。""" +import os, sys, numpy as np, time + +os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false") +os.environ.setdefault("JAX_PLATFORMS", "cpu") +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import motrix_envs.locomotion.go1.dreamwaq # noqa +from motrix_envs import registry as env_registry + +NUM_ENVS = 256 +TEST_STEPS = 100 +os.environ["DREAMWAQ_TERRAIN"] = "flat" # 用 flat 场景,手动覆盖 hfield 参数 + + +def _build_custom_hfield(x_radius, y_radius, z_scale, z_base=0.001): + """构建自定义 hfield 描述字符串,用于覆盖 XML 中的 hfield 参数。""" + import tempfile, cv2 + # 生成一个纯斜坡的 hfield PNG 用于测试 + nx, ny = 200, 200 + canvas = np.zeros((ny, nx), dtype=np.uint16) + # 从左上到右下的斜坡:高度从 0 到 z_scale + for i in range(ny): + for j in range(nx): + # 对角线斜坡,最高点在右下角 + h = int((i + j) / (nx + ny) * 65535) + canvas[i, j] = h + # 中间 1/3 区域做平台(平坦) + cx, cy = nx // 2, ny // 2 + p = nx // 6 + canvas[cy - p:cy + p, cx - p:cx + p] = 0 + + out_d = os.path.join(os.path.dirname(__file__), "..", + "motrix_envs", "src", "motrix_envs", + "locomotion", "go1", "xmls", "assets") + os.makedirs(out_d, exist_ok=True) + png_path = os.path.join(out_d, "zscale_test.png") + cv2.imwrite(png_path, canvas) + + return png_path, (x_radius, y_radius, z_scale, z_base) + + +def test_z_scale(z_scale, n_envs=NUM_ENVS, n_steps=TEST_STEPS): + """测试给定 z_scale 下的物理稳定性。""" + import tempfile, cv2 + + # 生成测试 hfield + out_d = os.path.join(os.path.dirname(__file__), "..", + "motrix_envs", "src", "motrix_envs", + "locomotion", "go1", "xmls", "assets") + os.makedirs(out_d, exist_ok=True) + + # 简单斜坡地形 + nx, ny = 40, 40 # 小尺寸快速生成 + canvas = np.zeros((ny, nx), dtype=np.uint16) + for i in range(ny): + for j in range(nx): + h = int((i / ny) * 65535) # y 方向斜坡 + canvas[i, j] = h + # 中央平台 + p = nx // 6 + canvas[ny // 2 - p:ny // 2 + p, nx // 2 - p:nx // 2 + p] = 0 + + png_path = os.path.join(out_d, "zscale_test.png") + cv2.imwrite(png_path, canvas) + + total_x = nx * 0.1 # HS=0.1, 粗略 + total_y = ny * 0.1 + + # 构建 scene XML + xml_path = os.path.join(os.path.dirname(__file__), "..", + "motrix_envs", "src", "motrix_envs", + "locomotion", "go1", "xmls", "scene_zscale_test.xml") + xml = f""" + + + + + + + + + + + + + + + + + + + + + +""" + with open(xml_path, "w") as f: + f.write(xml) + + import motrixsim as mtx + t0 = time.time() + + try: + model = mtx.load_model(xml_path) + data = mtx.SceneData(model, batch=[n_envs]) + data.reset(model) + body = model.get_body(0) + + # 随机初始位置(在平台上) + init_pos = model.compute_init_dof_pos() + init_pos = np.tile(init_pos, (n_envs, 1)) + init_pos[:, 0] += np.random.uniform(-0.5, 0.5, n_envs) + init_pos[:, 1] += np.random.uniform(-0.5, 0.5, n_envs) + init_pos[:, 2] = 0.5 # 从 0.5m 掉落 + data.set_dof_pos(init_pos.astype(np.float32), model) + model.forward_kinematic(data) + + heights = np.zeros((n_steps, n_envs), dtype=np.float32) + fall_count = 0 + for step in range(n_steps): + model.step(data) + h = body.get_pose(data)[:, 2] + heights[step] = h + # 摔倒检测:base_z < 0.15 (趴了) + fall_count += np.sum(h < 0.15) + + mean_h = float(np.mean(heights[-20:])) # 最后 20 步平均 + total_falls = fall_count + dt = time.time() - t0 + return mean_h, total_falls, dt + except Exception as e: + return None, str(e), 0 + + +if __name__ == "__main__": + print(f"{'z_scale':>8s} {'mean_base_z':>12s} {'falls':>8s} {'time':>8s} verdict") + print("-" * 65) + + for zs in [0.3, 0.5, 0.54, 0.8, 1.0, 1.5, 2.0, 3.0]: + mean_h, falls, dt = test_z_scale(zs, n_envs=64, n_steps=50) + if mean_h is None: + print(f"{zs:8.3f} {'ERROR':>12s} {str(falls)[:20]:>8s}") + continue + ok = "✅ 稳定" if mean_h > 0.25 and falls < 10 else "⚠ 不稳" if mean_h > 0.15 else "❌ 崩溃" + print(f"{zs:8.3f} {mean_h:12.4f} {falls:8d} {dt:7.1f}s {ok}") diff --git a/scripts/view_dreamwaq.py b/scripts/view_dreamwaq.py new file mode 100644 index 0000000..3354250 --- /dev/null +++ b/scripts/view_dreamwaq.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""DreamWaQ 地形可视化。 + +键盘: + R=重置 H=高度采样点 T=遍历出生点调试 Esc=退出 + +用法: + uv run scripts/view_dreamwaq.py # 金字塔地形 + uv run scripts/view_dreamwaq.py --flat --num-envs 1 +""" +import argparse, os, sys, time +import numpy as np + +os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false") +os.environ.setdefault("JAX_PLATFORMS", "cpu") + +if "--flat" in sys.argv: + os.environ["DREAMWAQ_TERRAIN"] = "flat" +elif "--flat-stairs" in sys.argv: + os.environ["DREAMWAQ_TERRAIN"] = "flat_stairs" +elif "--stairs" in sys.argv: + os.environ["DREAMWAQ_TERRAIN"] = "stairs" +else: + os.environ.setdefault("DREAMWAQ_TERRAIN", "pyramid") + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import motrix_envs.locomotion.go1.dreamwaq # noqa: F401 +from motrix_envs import registry as env_registry +from motrix_envs.np.renderer import NpRenderer +from motrix_envs.math import quaternion +from motrixsim.render import RenderClosedError + +# 地形类型名称(与 gen_dreamwaq_terrain.py 的 PROPORTIONS 对应) +PROPORTIONS = [0.1, 0.1, 0.35, 0.35, 0.1] +CUM = [sum(PROPORTIONS[:i + 1]) for i in range(len(PROPORTIONS))] +TYPE_NAMES = ["平滑斜坡", "粗糙斜坡", "下行楼梯", "上行楼梯", "离散障碍"] + + +def _cell_origin(row, col, border_m=5.0, cell_m=8.0, num_rows=10, num_cols=20): + """计算 cell (row, col) 的中心世界坐标。""" + half_x = border_m + num_cols * cell_m / 2.0 + half_y = border_m + num_rows * cell_m / 2.0 + cx = -half_x + border_m + col * cell_m + cell_m / 2 + cy = half_y - border_m - row * cell_m - cell_m / 2 + return cx, cy + + +def _type_name(col): + """根据列索引返回地形类型名称。""" + choice = col / 20 + 0.001 + for i, cum in enumerate(CUM): + if choice < cum: + return TYPE_NAMES[i] + return TYPE_NAMES[-1] + + +def _spawn_at(env, cx, cy, spawn_z=None): + """在指定世界坐标 spawn 单个机器人。""" + state = env._state + data = state.data + init_pos = env._init_dof_pos.copy().reshape(1, -1) + init_pos[0, 0] = cx + init_pos[0, 1] = cy + # 计算地形高度 + terrain_z = float(env._sample_terrain_height( + np.array([[cx, cy]], dtype=np.float32), radius=0.35)[0]) + if spawn_z is None: + spawn_z = terrain_z + 0.45 # 默认 clearance + init_pos[0, 2] = spawn_z + data.reset(env._model) + data.set_dof_pos(init_pos, env._model) + env._model.forward_kinematic(data) + # 重置 info + state.info["commands"][0] = np.array([0.0, 0.0, 0.0], dtype=np.float32) + state.info["steps"][0] = 0 + state.info["obs_history"][0] = 0.0 + return terrain_z, spawn_z + + +def main(): + p = argparse.ArgumentParser(description="DreamWaQ 地形可视化") + p.add_argument("--num-envs", type=int, default=1) + p.add_argument("--flat", action="store_true") + p.add_argument("--flat-stairs", action="store_true") + p.add_argument("--stairs", action="store_true") + p.add_argument("--level", type=int, default=None) + p.add_argument("--no-stand", action="store_true") + p.add_argument("--vx", type=float, default=0.5) + args = p.parse_args() + + env = env_registry.make("go1-dreamwaq-walk", num_envs=max(args.num_envs, 1)) + if args.level is not None: + env._force_level = args.level + + env.init_state() + n = env._num_envs + cmd = np.array([args.vx, 0.0, 0.0], dtype=np.float32) + + try: + renderer = NpRenderer(env) + except Exception as e: + print(f"[ERROR] 渲染器创建失败: {e}") + renderer = None + + terrain_name = os.environ.get("DREAMWAQ_TERRAIN", "pyramid") + print(f"[View] {n} 机器人 | 地形={terrain_name}") + print(f"[View] R=重置 H=高度点 T=遍历出生点 Esc=退出") + + show_heights = False + traverse_mode = False + traverse_row = 0 + traverse_col = 0 + traverse_pending = False # 刚切换 cell, 等待稳定 + traverse_settle = 0 + step_count = 0 + + # 地形信息 + num_rows = env._num_rows + num_cols = env._num_cols + cell_m = env._cell_size + + def enter_traverse(): + nonlocal traverse_mode, traverse_row, traverse_col, traverse_pending, traverse_settle + traverse_mode = True + traverse_row = 0 + traverse_col = 0 + traverse_pending = True + traverse_settle = 0 + print(f"\n[T] 遍历模式: {num_rows}行 × {num_cols}列") + print(f"[T] 按 T 前进, R 退出遍历\n") + + def exit_traverse(): + nonlocal traverse_mode, traverse_pending + traverse_mode = False + traverse_pending = False + env.init_state() + print("[T] 退出遍历模式\n") + + def advance_traverse(): + nonlocal traverse_row, traverse_col, traverse_pending, traverse_settle + traverse_col += 1 + if traverse_col >= num_cols: + traverse_col = 0 + traverse_row += 1 + if traverse_row >= num_rows: + print("[T] 遍历完成! 按 R 退出") + traverse_row = num_rows - 1 + traverse_col = num_cols - 1 + return False + traverse_pending = True + traverse_settle = 0 + return True + + def do_traverse_spawn(): + """在当前位置 spawn 并打印信息。""" + nonlocal traverse_settle + cx, cy = _cell_origin(traverse_row, traverse_col) + tname = _type_name(traverse_col) + terrain_z, spawn_z = _spawn_at(env, cx, cy) + + # 标记 spawn 点(绿色球) + if renderer is not None: + g = renderer._render.gizmos + g.draw_sphere(0.15, (np.float32(cx), np.float32(cy), + np.float32(spawn_z))) + + # 让机器人稳定几步 + for _ in range(30): + env.step(np.zeros((1, 12), dtype=np.float32)) + if renderer is not None: + renderer.render() + time.sleep(0.005) + + base_z = env._body.get_pose(env._state.data)[0, 2] + contacts = env._state.info.get("contacts", np.zeros(4)) + cf = env._state.info.get("privileged_obs", + np.zeros((1, 247)))[0, 45:57] + total_cf = np.sum(np.abs(cf)) + + print(f" r{traverse_row}c{traverse_col:02d} {tname:6s} " + f"origin=({cx:+.0f},{cy:+.0f}) " + f"terrain_z={terrain_z:.3f} spawn_z={spawn_z:.3f} " + f"base_z={base_z:.3f} cf={total_cf:.0f}N " + f"feet={contacts.astype(int).tolist()}") + + traverse_settle = 30 + + try: + while True: + # ── 键盘 ── + if renderer is not None: + try: + inp = renderer._render.input + if inp.is_key_just_pressed("r"): + if traverse_mode: + exit_traverse() + else: + env.init_state() + step_count = 0 + print("[R] 重置") + if inp.is_key_just_pressed("h"): + show_heights = not show_heights + print(f"[H] 高度点: {'开' if show_heights else '关'}") + if inp.is_key_just_pressed("t"): + if traverse_mode: + advance_traverse() + else: + enter_traverse() + except Exception: + pass + + # ── 遍历模式 ── + if traverse_mode and traverse_pending: + do_traverse_spawn() + traverse_pending = False + + # ── 正常模式动作 ── + if not traverse_mode or traverse_settle > 0: + if args.no_stand: + env._state.info["commands"][:] = cmd + else: + act = 0.3 * (np.random.rand(n, 12).astype(np.float32) - 0.5) + env.step(act) + if traverse_settle > 0: + traverse_settle -= 1 + + if step_count % 100 == 0 and step_count > 0: + bz = env._body.get_pose(env._state.data)[0, 2] + print(f"[{step_count}] base_z={bz:.3f}") + + # ── 高度点 ── + if show_heights and renderer is not None: + state = env._state + pose = env._body.get_pose(state.data) + bp = pose[0, :3] + yaw = quaternion.get_yaw(pose[0:1, 3:7])[0] + cos_y, sin_y = np.cos(yaw), np.sin(yaw) + for gy in env._hy: + for gx in env._hx: + wx = bp[0] + cos_y * gx - sin_y * gy + wy = bp[1] + sin_y * gx + cos_y * gy + try: + wz = float(env._sample_terrain_height( + np.array([[wx, wy]]))[0]) + g = renderer._render.gizmos + g.draw_sphere(0.02, (np.float32(wx), np.float32(wy), + np.float32(wz))) + except Exception: + pass + + if renderer is not None: + renderer.render() + time.sleep(0.01) + + step_count += 1 + + except (KeyboardInterrupt, RenderClosedError): + pass + try: + if renderer is not None: + renderer.close() + except Exception: + pass + print("[View] 结束") + + +if __name__ == "__main__": + main()