Files
syfffffjj 07027e8fde feat: huoyi M20 smooth/bridge training and stability docs
Current code snapshot: smooth/bridge task configs, smooth random terrain,
roll/pitch stability rewards, M20 URDF updates, eval profile and stability
training documentation.
2026-11-02 16:41:07 +08:00

31 KiB
Raw Permalink Blame History

eval_kit —— 可搬运的机器人性能评价套件

这份套件与 scripts/reinforcement_learning/rsl_rl/eval.py 并存,不导入也不修改它。 两者的指标名完全一致、取点一致、公式一致,所以输出可以直接互相对照。

为什么要另写一套

eval.py 是一个能跑通的评估脚本,但它把三件事混在一起:读仿真环境、算指标、格式化输出。 其中「读环境」必须知道具体机器人长什么样——它写死了 .*_wheel 和 ["base_link", ".*_knee"]。 于是它只能评估火蚁;而且因为 IsaacLab 的 find_bodies 对匹配不到的正则会直接抛 ValueError,换一个没有轮子的机器人跑会在进循环之前就崩,不是给出一个错的分数。

本套件把三层切开,让不依赖框架的部分可以单独测试、单独搬运:

eval_kit/
├── core/                   ★ 零框架依赖(只用 numpy + 标准库),可整块拷到别的项目
│   ├── schema.py           │  RobotProfile / EvalProtocol / Trace 三个数据契约
│   ├── metrics.py          │  指标纯函数(唯一算数的地方)
│   ├── aggregation.py      │  上限网格、score、跨种子 mean±std
│   ├── terrain_spec.py     │  地形等级规格(纯数据)
│   ├── report.py           │  格式化与指标筛选
│   └── naming.py           │  容错版的名字/正则解析
├── isaaclab/               × 依赖 isaaclab,唯一接触仿真内部的地方
│   ├── env_setup.py        │  把协议与地形规格翻译成 env_cfg 改动
│   ├── recorder.py         │  跑 rollout 并采集成 Trace
│   └── policy.py           │  checkpoint 解析与策略加载
├── profiles.py             ★ 机器人形态描述:加机器人 = 加一条记录
├── protocols.py            ★ 评估协议预设:改口径 = 改这里
└── tests/
    ├── test_core.py        ★ 合成轨迹测试指标(不需要仿真)
    └── test_cli.py         ★ 命令行参数与口径覆盖自检(不需要仿真)

打 ★ 的是「可套用」的部分:core/、profiles.py、protocols.py 与两个测试文件 可以在没有 Isaac Sim、没有 GPU 的机器上运行。将来要接别的框架(legged_gym、mujoco), 只需要再写一个适配层产出 Trace,core/ 一行都不用改。

用法

1. 激活火蚁项目的虚拟环境

cd ~/syf/IsaacLab-2.3.2
source env_isaaclab_py311/bin/activate
cd ~/syf/huoyi_training            # 评估脚本在仓库根目录跑

验证环境是否正确:python -c "import isaaclab" 应当无报错,且 isaaclab.__file__ 指向 IsaacLab-2.3.2/source/isaaclab/isaaclab/__init__.py(可编辑安装)。

注意有两个容易踩空的近邻环境:系统 python3(3.10)和 conda 的 env_rl_training 都不能跑——前者没有 isaaclab/isaacsim,后者有 isaacsim 5.1 但没有 isaaclab。只有 env_isaaclab_py311 是完整的。仓库根目录是必须的, 因为 logs/ 查找用的是相对 cwd 的路径。

2. 让 IsaacLab 的日志目录可写

/tmp/isaaclab 属于机器上的其它用户,默认会 PermissionError。 configure_logging 用的是 tempfile.gettempdir()/isaaclab/logs,而 gettempdir() 尊重 TMPDIR,所以设一下即可(不必去动别人的目录):

export TMPDIR=/home/suyufei/tmp

3. 跑评估

两条硬性约束,先看:

  • 必须传 --checkpoint(绝对路径最省事)。编排进程不启动仿真,因此不加载 hydra,无法按 --load-run 用实验名查找权重。
  • 每个种子一个子进程。IsaacLab 在本配置下一个进程只能创建一个环境: 第一个环境建好后,ROUGH_TERRAINS_CFG 这类模块级单例会被运行时长出自引用, 之后任何 cfg 构造(新实例、开场存的 pristine 深拷贝、重新深拷贝)都会在 cfg.validate() 里 RecursionError —— 三种补救方式都实测失败。所以 eval_suite.py 的编排进程自己不建环境,而是为每个(阶段, 种子)fork 一个子进程。 代价是每个子进程要重新启动一次 Isaac Sim(约 15 s),所以 --protocol standard_v1_multiseed(5 个种子)大约要 5~10 分钟。
# 标准评估(默认用 solenopsis 描述 + standard_v1 协议)
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_19999.pt \
    --headless

# 上限评估 + 5 个随机种子 + 存轨迹
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_19999.pt \
    --protocol standard_v1_multiseed --save-trace /tmp/traces --headless

# 抗扰评估(每 2.01 s 施加一次水平扰动)
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_19999.pt \
    --eval-type robustness --headless

# 只看某一种地形的上限
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_19999.pt \
    --eval-type upper_limits --terrain-type stairs_up --headless

# 推荐用于机器人对比:放大地砖,消除越界终止项的误判(见下一节)
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_19999.pt \
    --protocol standard_v1_clean --headless

# 或者直接关掉那个终止项
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_19999.pt \
    --no-terrain-bounds --headless

# 冒烟测试(小规模,只为确认流水线通)
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_19999.pt \
    --protocol smoke --headless

输出默认写到 <checkpoint 目录>/eval_kit/<eval_type>_<时间戳>.json——刻意与 eval.py 的 <checkpoint 目录>/eval/ 分开,避免两套结果混在一个目录里。

快速自检(不需要仿真):

python3 eval_kit/tests/test_core.py    # 24 项:指标、聚合、名字解析、轨迹存取
python3 eval_kit/tests/test_cli.py     # 19 项:参数解析、口径覆盖、路径规则、推流开关

4. 用 WebRTC 看画面(可视化)

机器上没有显示器时,用 IsaacLab 的推流模式看评估过程:加 --livestream 即可 (这个参数由 AppLauncher 提供,eval_suite.py 自己没有定义)。

# 局域网直连(推荐):客户端和服务器在同一网段
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 \
    --checkpoint /绝对路径/model_18200.pt \
    --protocol smoke --terrain-type stairs_up \
    --livestream 2 --device cuda:6
  • --livestream 2:局域网/私有网络 WebRTC,客户端直连 <本机 IP>:49100。
  • --livestream 1:公网 WebRTC,走 NVIDIA NVCF 信令,需要 PUBLIC_IP 且能出公网。
  • 两个值都会让 AppLauncher 自动进入 headless(推流由服务端渲染), 不要再传 --headless(传了也无害,只是多余)。

客户端用 NVIDIA 的 Isaac Sim WebRTC Streaming Client(随 Isaac Sim 一起提供, Windows/Linux 都有),填入服务器地址即可;端口默认 49100,脚本启动时会把实际 地址打印出来:

[eval_kit] WebRTC 推流已启用(--livestream 2,局域网直连)
[eval_kit]   客户端连接地址:10.62.25.137:49100  192.168.77.26:49100

脚本为推流额外做三件事:

  1. 视口相机默认跟着被评估的机器人。--view-env N 选第 N 个环境(默认 env 0), 上限评估时会打印这个编号对应哪种地形、哪个难度等级。不设的话推流画面停在 默认相机上,看不到机器人。 想自己控制镜头就加 --free-camera:改为不跟随任何物体,起始摆在俯视全场的 位置(按实际地形范围算中心与半径),之后在客户端里用鼠标/键盘自由移动 —— Isaac Lab 的 ViewportCameraController 只在 origin_type="asset_root" 时每帧 改写相机,设成 world 就只在开头写一次,不会把你的操作抢回去。同时给了 --view-env 时以 --free-camera 为准。
  2. 开始 rollout 前留出连接时间:--pause-s(秒),推流时默认 30 s, 期间物理被冻结、画面照常刷新,在终端按回车可提前开始。不推流时默认 0。 已验证:停顿与否跑出来的指标逐位相同(同一 seed)。 注意这一窗口里机器人是悬空静止的:它停在协议规定的起始位姿上,而 standard_v1_clean 的 start_z_offset_m=0.5 会把它抬高 0.5 m 再落下, 所以「离地没落下、也不动」正是正常现象;sim.play() 之后才开始动。 启动时会打印倒计时,别把它当成卡死。
  3. 打印客户端连接地址(从 carb 设置里读实际端口,不是写死的)。

想看清机器人走路:smoke/standard_v1 一类协议一个 episode 只有 2~10 s 仿真时间, 扣掉 30 s 等待后留给你看的时间很短。只为观察的话把 episode 拉长(这会改变 protocol 指纹,别拿这些数字做对比):

# 只看画面:60 s 的 episode、60 个机器人,混合地形上一直走
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 --checkpoint /绝对路径/model_18200.pt \
    --protocol standard_v1_clean --eval-type velocity_error \
    --episode-length-s 60 --num-envs 60 \
    --livestream 2 --view-env 0 --pause-s 30 --device cuda:0

每个(阶段, 种子)是一个独立子进程,各自开一次推流;子进程退出时画面中断, 下一个阶段会自动重新推流,客户端重连即可。所以 --protocol standard_v1_multiseed 这种 8 个种子的协议会看到 8 次「断线—重连」。想连续观察就用单种子的协议 (standard_v1 / smoke),或者按地形单独跑。

这块机器上跑推流要先挑一张空闲卡。渲染 + 编码要占显存,而渲染路径(MGPU/ 流编码器)会在 GPU 0 上申请资源,GPU 0 正被别人的训练占满时会刷大量 Out of GPU memory / Buffer creation failed for the device: 0,严重时 CUDA error: an illegal memory access 直接把评估打断。处置:

nvidia-smi --query-gpu=index,memory.used --format=csv   # 挑一张空卡
--device cuda:N                                         # 渲染与物理都钉到 N
--rendering_mode performance                            # 可选:低画质换显存与延迟
--kit_args=--/renderer/multiGpu/maxGpuCount=1           # 可选:限制渲染只用一张卡

--device 现在同时决定策略网络放在哪张卡:以前策略只认 agent 配置里的 device(默认 cuda:0),在别人占着 0 号卡时会莫名其妙 OOM。

读数提醒(未解):同一 checkpoint、同一协议、同一 seed,启动方式不同会得到不同的 数值。实测(--protocol smoke --eval-type velocity_error --num-envs 64,同一个 model_18200.pt):

启动方式 mean_lin_vel_error mean_yaw_vel_error 中途早退
--headless --device cuda:6(三次独立重跑) 0.8159 0.1480 10
CUDA_VISIBLE_DEVICES=6 --headless --device cuda:0 0.8507 0.3334 17
--headless --enable_cameras(渲染开,无窗口) 0.8507 0.3334 17
--livestream 2 --device cuda:6(推流,暂停 0~30 s 都试过) 0.8507 0.3334 17

每一组内部都是逐位可复现的(同组三次重跑完全一致),跨组则有稳定差异;--pause-s 已验证与差异无关(headless 加暂停与不加完全相同;推流下暂停 0 s 与 30 s 也相同)。 两档之间的成因尚未定位(与「是否渲染」和「--device 写法/可见卡数」都相关,未能归因到单一因素)。 在这个问题查清之前:拿数字做对比时固定一种启动方式,不要把可视化那次的数值与历史 headless 结果混在一个表里报。看画面用推流没有影响,只是它的数字属于另一档。

画面卡住时先看终端,不要猜。rollout 期间每 10% 打印一行进度:

[eval_kit] 停顿 5.0 s 结束,开始 rollout(画面从这里才动)
[eval_kit] rollout 100/1000 步,用时 7.0 s
[eval_kit] rollout 200/1000 步,用时 13.6 s
...
[eval_kit] 结果已保存:.../upper_limits_2026-10-19_17-16-19.json
  • 有 rollout 打印在涨 → 仿真在正常推进,卡的是画面(渲染/编码侧)。按优先级试: 换到空闲卡(--device cuda:N,N 用空闲卡的编号)、 --rendering_mode performance、确认客户端没断线、机器人数量少一点(--num-envs)。 已知一种具体成因已经默认修掉:客户端改窗口大小/全屏会发 onCustomResizeMessage, 服务端默认拒绝并让推流的 shared swapchain buffer 进入坏状态,此后画面不再更新 (仿真还在跑)。脚本现在会给推流自动追加 --/app/livestream/allowResize=true --/app/livestream/allowDynamicResize=true。 如果日志里仍出现 onCustomResizeMessage ... must both be positive,别缩放客户端窗口, 并把那段日志发出来。
  • rollout 打印不再涨 → 是仿真停了,通常是在客户端 UI 里动了时间轴/仿真控件: 视口下方的时间轴 Play/Pause/Stop 按钮、IsaacLab 面板里的 Record Animation (START 之后默认 10 s 结束会直接关掉应用)、Reset Simulation on Stop。 看画面时别点它们;真被误触了就 Ctrl-C 重跑。
  • 进程已经结束(终端出现 结果已保存,或被 Ctrl-C)→ 客户端只会把最后一帧留在屏幕上, 看起来就是「画面定住」,这既不是卡住也没有在算,重跑即可。
  • 一轮跑完的标志只有两个:终端出现 结果已保存:<路径>.json,以及 <checkpoint 目录>/eval_kit/ 下多出一个 JSON。

已知问题:WebRTC 画面在客户端接上后停止更新

实测到过这个现象:仿真、渲染、相机都正常(--record-frames 抓出的帧显示机器人在动、 相机跟着走),只有推流给客户端的画面不再刷新。已排除的原因:物理冻结(终端 rollout 进度一直在涨)、暂停窗口(停顿结束后仍不动)、客户端改画幅被拒(已默认打开 allowResize/allowDynamicResize)、时间轴被暂停(那样进度会停)。

推流器默认把日志静音(app.livestream.logLevel = "silence"),所以要先让它说话:

--kit_args=--/app/livestream/logLevel=debug
# 另外可以试:--kit_args=--/renderer/multiGpu/maxGpuCount=1
# (IsaacLab 自己的 isaaclab.python.rendering.kit 就是这么设的,多卡机器上更保险)

再不行就用 --record-frames 那条路径看评估过程(功能上等价,只是不能实时移动相机)。

推流不可用时的替代路径:抓帧成视频。 --record-frames DIR 会在 rollout 期间按 间隔把视口画面存成 PNG,跑完打印合成命令:

CUDA_VISIBLE_DEVICES=7 python scripts/reinforcement_learning/rsl_rl/eval_suite.py \
    --task Rough-Deeprobotics-M20-v0 --checkpoint /绝对路径/model_18200.pt \
    --protocol standard_v1_clean --eval-type velocity_error \
    --episode-length-s 30 --num-envs 16 \
    --livestream 2 --pause-s 0 --view-env 0 --device cuda:0 \
    --record-frames /tmp/eval_frames --record-every 5

抓帧依赖视口渲染,所以必须开着渲染:推流(--livestream 2)或本地 GUI 都行, 纯 headless(--headless 且不推流)没有视口,抓不到帧。跑完合成 (--record-every 5 时每帧 0.1 s 仿真时间,10 fps 即按仿真速度播放):

cd /tmp/eval_frames && ffmpeg -y -framerate 10 -pattern_type glob -i '*.png' \
  -pix_fmt yuv420p -c:v libx264 -vf 'pad=ceil(iw/2)*2:ceil(ih/2)*2' /tmp/eval.mp4

已实测:600 步抓到 102 张 1280×720 PNG,合出的 mp4 能正常播放,画面里机器人从 起始高度落地并在地形上移动、相机跟着它走 —— 这条路径绕开了推流传输层, 在推流卡住时仍然能看评估过程。

三个对象

  • RobotProfile(profiles.py):这个机器人谁是足端、谁是支撑体、哪些关节耗电、多重。 评价代码里没有任何 link/joint 名字或质量常数。
  • EvalProtocol(protocols.py):一次评估的全部口径——指令、时长、成功距离、 倾角阈值、加速度阈值、能耗约定、地形规格、种子。决定数值的字段会被哈希成 protocol_fingerprint 写进输出 JSON。
  • Trace(core/schema.py):一次 rollout 的逐帧记录(机身位姿/速度、关节力矩与速度、 足端位置、支撑接触力与时长)。第 0 帧是动作前的起始状态,之后每步一帧。

流水线是单向的:适配器产 Trace → 内核算指标 → 报告层格式化。 Trace 可以存成 .npz,事后加新指标不用重跑仿真。

加一个机器人

在 profiles.py 加一条记录即可,评估代码不动:

MY_ROBOT = RobotProfile(
    name="my_robot",
    mass_kg=None,                          # None = 运行时从资产读真实质量
    feet_body_patterns=(".*_foot",),
    support_body_patterns=(),              # 没有支撑体语义 → 相关指标返回 nan
    actuated_joint_patterns=(".*_joint",),
    support_force_threshold_n=15.0,
    body_length_m=0.5,
)

test_core.py::test_naming_does_not_crash_when_morphology_is_missing 就是在验证这件事: 用火蚁的描述去解析一个没有轮子的机器人,得到的是「未命中」记录 + 跳过对应指标,而不是崩溃。

相对 eval.py 的三处有意差异

指标名、取点、公式全部保持一致,只有三处刻意不同:

项 eval.py 本套件 原因
COT 的质量 --robot_mass 默认 25.482 默认从 root_physx_view.get_masses() 读(Solenopsis.urdf 实为 26.581 kg),命令行可覆盖 旧默认值是更早的 M20 简化模型名义质量,会让 COT 偏低约 4%
加速度阈值 --acc_threshold 2.0 m/s²(绝对值) acc_threshold_g 重力倍数(默认仍是 2.0/9.81) 绝对值对不同量级的机器人不公平
缺失形态的指标 返回 0.0 返回 nan,并在 JSON 里记录 stability_margin_method="unavailable" 0.0 看起来像测出来的值,实际是没测

另外新增了两项检查:输出 JSON 里带 protocol_fingerprint(防止不同口径的结果被悄悄比较), 以及 early_resets(环境在 episode 中途被重置时会警告,因为那会让路程/进度类指标混入两次 rollout)。

口径与 criterion.py 的对照(备忘)

criterion.py(来自 legged_gym 的山猫项目)与 eval.py 在 7 处口径上不一致, 移植/对照时要注意。已经核对过的结论:

项 criterion.py eval.py / 本套件
能耗积分 Σ(τ·q̇)·dt(有符号) Σ|τ·q̇|·dt(绝对值,本套件可切换但默认绝对值)
COT 质量 写死 27.78822 自动读取(26.581 kg)
倾角阈值 π/6 ≈ 0.524 rad 0.70 rad
稳定裕度 ZMP 到足端凸包距离(scipy + shapely) 机身到足端包围盒距离
成功判定 env 维护目标区域 沿指令方向前进 ≥ success_distance
时间效率基准 走过半块地形的时间 走完 success_distance 的时间
上限 score 累积成功率加权 代数等价,已验算一致 ✓

其中「能耗积分」这一条最要紧:有符号口径下负功会抵消正功,同一段运动算出的能耗可以 差一倍以上,所以跨机器人比较时必须在协议里钉死一种。

⚠️ 发现的问题:越界终止项让约 20% 的环境从第 0 步就被重置

这是实跑冒烟测试时发现的,是 eval.py 评估设置里既有的问题,不是本套件引入的。

IsaacLab 的 terrain_out_of_bounds 终止项(isaaclab_tasks/.../mdp/terminations.py) 比较的是机器人的世界坐标与整张地图的范围,而不是机器人自己那块地砖:

map_width = n_rows * tile + 2 * border_width
x_out_of_bounds = |root_pos_w[:, 0]| > 0.5 * map_width - distance_buffer

在 eval.py 的默认评估配置下(地砖 2.0 m、border_width 2.0、distance_buffer 3.0、 起始位姿 x = −0.9),网格最外侧格子中心在 ±(rows−1)/2 × 2.0 处,机器人起始位置因此 越过阈值。实测数据(8 难度 × 2 地形 × 每格 2 个 = 32 个环境):

  • 9 行格子中有 2 个整格越界(origin x = −7.0 → 起始 x = −7.9 > 阈值 7.0)
  • 首步就有 6/32 = 19% 的环境被判越界并重置
  • 5×5 网格下同理:最左一整列(20%)从第 0 步起每步都被重置

还有第二个机制:|y| 恰好等于阈值的那些列(y = ±4.0 对阈值 4.0)处于浮点边界上, 取决于最后一个 bit 是否触发。

两个后果,都影响可比性:

  1. 这些环境永远没有机会尝试地形,却全部被记成失败 → 成功率和 score 系统性偏低。
  2. 地形行序是打乱的(哪一行落在最外侧每次都不同)→ 被清零的是哪个难度等级 跨种子都不稳定,所以逐等级成功率和 score 连自己跟自己比都不可靠。

三种处置(按推荐顺序)

方案 做法 效果
1 --terrain-size 4.0 或 --protocol standard_v1_clean 保留终止项,消除误判。阈值由 minimum_terrain_size_for_bounds() 给出:tile >= 2*(0.9 + 3.0 − 2.0) = 3.8 m
2 --no-terrain-bounds 或 --protocol standard_v1_no_bounds 直接去掉该终止项。成功判定本就按前进距离算,走出地砖不构成失败
3 保持默认(2.0 m 地砖 + 启用终止项) 与旧 eval.py 数值可比,但带着上述偏差;只建议用于对照

因果验证

同一配置(5×5 网格、10 个环境、8 步、零动作策略),只切换该终止项:

enforce_terrain_bounds 各环境中途重置次数 结论
True(默认,与 eval.py 一致) [7, 1, 0, 0, 0, 0, 0, 7, 7, 0] 4/10 环境受污染
False [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] 无早退

本套件的 early_resets 检查就是为发现这类问题加的,输出 JSON 里一直带着它。

⚠️ 发现的问题二、三:地形参数与生成位置

下面两处同样是实跑时发现的,也都继承自 eval.py 的评估设置。

问题二:机器人在倒金字塔台阶地形里被生成到几何体内部

旧写法把重置高度钉在瓦片中心的 env_origin.z 上(pose_range.z = (0, 0)), 这只对「瓦片中心是最高点」的地形成立。

stairs_down 用的 MeshInvertedPyramidStairsTerrainCfg 中心是最低点, 再叠加 platform_width 被从 3.0 m 压到 0.4 m(min(0.4, tile*0.2)), 起始点 x = −0.9 m 就落在平台之外且低于该处地表 —— 机器人被生成在几何体内部、卡住。

结果是 stairs_down 在全部 8 个难度上都是 0.00,连只有 4 cm 台阶的 L1 也是, 看上去像「这机器人不会下台阶」,实际是 harness 的问题。

修复:EvalProtocol.start_z_offset_m(CLI --start-z-offset)。 实测对照(同一 checkpoint、只跑 --terrain-type stairs_down):

起始高度 score 成功率
0.0(旧 eval.py) 0.00 / 8 0.05
0.3 m 3.64 / 8 0.47
0.5 m 4.53 / 8 —

取 0.5 m 是因为嵌入深度随难度递增(L1 约 0.11 m、L8 约 0.50 m),0.5 刚好覆盖最深一级。 standard_v1_clean 已内置该值;standard_v1 保持 0.0 以维持与旧 eval.py 的可比性。 代价是机器人在平台上方落下会弹一下,stairs_up 略降,但整体是净收益。

问题三:粗糙地形的噪声参数被当成起伏幅度

rough_slope 用的 HfRandomUniformTerrainCfg 有两个容易误解的点,IsaacLab 源码里 写得很清楚但很容易忽略:

  1. difficulty 参数被忽略(docstring 原文:"The difficulty parameter is ignored for this terrain")。所以那一列没有难度梯度,8 行是同一分布的重复采样 —— 这也正是它能被看出来的原因:它的成功率不单调(0.12/0.00/0.38/0.25/0.62/0.75/0.12/0.00)。
  2. noise_range 是绝对高度范围,不是起伏幅度。旧 eval.py 设成 (0.05, 0.40), 而 noise_step 又被改成 0.01 —— 在 1 cm 水平网格上放 40 cm 高的随机起伏, 等于一堵堵随机尖刺墙,轮半径只有 0.096 m 的机器人无法通过。

修复:noise_step 回到上游的 0.02,noise_range 的上限改为 0.10(与训练地形 noise_range=(0.02, 0.10) 同量级)。实测:

noise_range score 成功率
(0.05, 0.40)(旧 eval.py)+ noise_step 0.01 0.12 / 8 0.05
(0.02, 0.10) + noise_step 0.02 6.38 / 8 0.86

同时给规格加了 isaaclab_extra_params(写与等级无关的固定字段),并把裸 setattr 换成带 hasattr 校验的 _set_sub_terrain_field:字段名写错现在会直接报错, 不会再静默造一个假属性、让地形保持默认难度(那看起来只是「很难」)。

rough_slope 的 unit 里也写明了「此地形忽略难度参数,等级排名无意义」, 所以这条限制会随输出 JSON 一起保存。

三处修正的合计效果

同一 checkpoint(model_19999.pt、seed 0),只切换协议预设:

地形 旧 eval.py 口径 standard_v1_clean
stairs_up 2.88 3.03
stairs_down 0.00 4.21
smooth_slope 6.50 6.50
rough_slope 0.12 6.03
boxes 4.28 4.60
总成功率 0.434 0.659
平均前进距离 3.59 m 5.26 m
COT 4.50 2.45
score 总和 13.78 24.38

⚠️ 尚存的未解异常:所有地形的 L8 都是 0.00

修正后在完整评估里观察到:五个地形在最高难度 L8 上的成功率全部是 0.00。

对 stairs_up / boxes 这类台阶高度确实随难度递增的地形,L8 失败是合理的; 但 rough_slope 忽略难度参数,L8 的统计分布与 L1–L7 完全相同(那 7 行是 0.75–1.00), 却精确地得到 0.00 —— 这在统计上不可能是能力问题,指向最后一行的地形原点或生成高度有问题 (最可能是 terrain_origins 最后一行的 z 或布局异常,使机器人被放在地形之外/之下)。

影响:每个地形的 score 末项 acc[-1] * num_levels 因 acc[-1] = 0 而丢失, score 被低估约 1 分(以 smooth_slope 为例,6.50 → 若 L8 正常约 7.40)。

这一条尚未定位,也没有修。 在有明确结论之前,请把矩阵最后一行的数据当作不可用, score 当作保守下界。

验证状态(请如实看待)

已在真机(IsaacLab-2.3.2/env_isaaclab_py311 + RTX 4090)上实跑验证:

  • eval_kit/isaaclab/ 三个模块已端到端跑通,三步冒烟全部 PASS:
    • names —— 形态解析在真实 body 名上命中:足端 4 个 ['fl_wheel','fr_wheel','hl_wheel','hr_wheel']、支撑体 5 个 ['base_link','fl_knee',...,'hr_knee']、驱动关节 8 个,全部与描述文件一致
    • plain —— 配置 → 采集 → 指标全链路:11 帧 × 4 环境,指标齐出 (margin=0.1667 m、max_tilt=0.051 rad、energy=8.0572 J、COT=2.1570)
    • upper —— 上限网格链路:8 个环境按 2 类型 × 2 等级分格,矩阵与 score 正确折叠
  • 整机质量从资产读出为 26.581 kg,与 docs/solenopsis_urdf_comparison.md 的记录一致; 这确认了 eval.py 的 --robot_mass 默认值 25.482 确实偏低约 4%,本套件改为自动读取 是有必要的。
  • 越界终止项的偏差已定位、量化并做过因果验证(见上一节)。
  • eval_kit/core/ 的指标用合成轨迹验证,期望值手算:22 项测试通过。
  • eval_suite.py 的参数解析与口径覆盖用桩替换 isaaclab.app 后测试:11 项通过。

仍未验证:

  • 没有用训练到收敛的 checkpoint 跑过,所以指标数值本身没有和 eval.py 做过逐项对照。 现有的 model_0.pt 是 iteration 0(基本等于随机权重),跑出来的 success_rate 恒为 0、 time_consumption 恒为 nan,拿这种退化值对照没有信息量——这时"通过"是弱证据, "报错"才是强证据,而报错部分已经全部排除。
  • 因此第一次拿到训练好的 checkpoint 时仍建议做等价性验证:同一 checkpoint、 同一组参数分别跑 eval.py 与 eval_suite.py --protocol standard_v1,除「相对 eval.py 的三处有意差异」一节列出的三项外应逐位相同。注意 eval.py 目前无法启动(见下)。
  • profiles.py 里的 lite3 记录没有在仿真里跑过。

⚠️ eval.py 目前无法启动

实跑时顺带发现的:eval.py 自己也加了一个 --verbose,而 AppLauncher.add_app_launcher_args() 会无条件添加同名参数,argparse 因此在构造 parser 阶段就抛错,脚本连 --help 都跑不出来:

argparse.ArgumentError: argument --verbose: conflicting option string: --verbose

--device 和 --verbose 是两次踩到的坑,但只有 device / headless / enable_cameras / experience / livestream / rendering_mode / xr 这 7 个会被 AppLauncher 自己的显式检查 拦下并给出可读报错;--verbose / --info / --cpu 等其余 7 个由 argparse 拦。 本套件把 14 个名字都记在 tests/test_cli.py::APPLAUNCHER_ARG_NAMES 里,并在 test_parser_compatible_with_app_launcher 中复刻检查,所以这类冲突不必启动仿真就能发现。

修 eval.py 只需把那行 --verbose 改名(例如 --verbose-terrain)并同步 args_cli.verbose 的用法。我没有改动 eval.py——按你的要求新写一套、不碰原文件。

已知限制

  • 一个进程只能建一个环境(IsaacLab 在本配置下的限制,实测确认)。因此 eval_suite.py 用「编排进程 + 每(阶段, 种子)一个子进程」的结构,每个子进程 重新启动一次 Isaac Sim。这不是本套件的设计偏好,是绕不过去的约束。
  • 地形规格是唯一真正不可移植的部分。 规格数值(等级 × 类型 × 绝对高度)是纯数据, 但「把规格翻译成地形生成器」的代码是 IsaacLab 专用(isaaclab/env_setup.py)。 换框架需要另写这个 builder。
  • 稳定裕度口径较粗。 当前是「机身到足端轴对齐包围盒的距离」,比 ZMP/凸包版本粗糙, 但不会在退化多边形上失败、也不需要 scipy。算法名记录在输出 JSON 里。
  • 重复试验只覆盖随机种子。 aggregate_runs 给的是种子间的 mean±std, 不含 checkpoint 之间的方差(同一实验不同迭代的权重差异)——那个要靠多取几个 checkpoint 评估。
  • 单次 rollout 的噪声很大。 比较两个策略/机器人时请用 --seeds 给多个种子, 看 mean±std,不要看单次数值。