Files
Motrixlab/scripts/diag_terrain_collision.py
2026-07-22 02:17:01 +08:00

459 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""地形碰撞诊断工具 — 系统性检测 hfield PNG 地形的物理交互质量。
检测项:
1. 脚部穿透foot z < terrain z
2. 接触力异常(为 0 但应该接触 / 过大)
3. NaN/Inf 传播
4. 物理爆炸(关节速度飙升)
5. 机器人卡住(速度接近 0 但有命令)
6. 躯干沉入地形base z < terrain z
用法:
uv run scripts/diag_terrain_collision.py # 全量检测
uv run scripts/diag_terrain_collision.py --level 5 # 仅 level 5
uv run scripts/diag_terrain_collision.py --type stairs # 仅楼梯地形
uv run scripts/diag_terrain_collision.py --render # 渲染可视化
"""
import argparse, os, sys, time
import numpy as np
os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false")
os.environ.setdefault("JAX_PLATFORMS", "cpu")
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
# ═══════════════════════════════════════════════════════════════════════
# 配置
# ═══════════════════════════════════════════════════════════════════════
NUM_ROWS = 10
NUM_COLS = 20
CELL_M = 8.0
BORDER_M = 5.0
SETTLE_STEPS = 100 # 自由落体稳定步数200Hz
TEST_STEPS = 400 # 测试步数
STEPS_PER_CELL = 5 # 每个 cell 测试的 spawn 位置数
# 地形类型名(与 gen_dreamwaq_terrain.py PROPORTIONS 对应)
TYPE_NAMES = ["平滑斜坡", "粗糙斜坡", "下行楼梯", "上行楼梯", "离散障碍"]
CUM = [0.1, 0.2, 0.55, 0.9, 1.0]
def _cell_origin(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 _terrain_type(col):
choice = col / NUM_COLS + 0.001
for i, c in enumerate(CUM):
if choice < c:
return i
return len(TYPE_NAMES) - 1
# ═══════════════════════════════════════════════════════════════════════
# 诊断核心
# ═══════════════════════════════════════════════════════════════════════
class CollisionDiag:
def __init__(self, env, render=False):
self.env = env
self.render = render
self.renderer = None
if render:
from motrix_envs.np.renderer import NpRenderer
self.renderer = NpRenderer(env)
# 统计数据
self.stats = {
"penetrations": [], # (row, col, ttype, foot_z - terrain_z)
"contact_anomalies": [], # (row, col, ttype, max_contact_force)
"nan_events": [], # (row, col, ttype, step)
"explosions": [], # (row, col, ttype, max_dof_vel)
"stuck_events": [], # (row, col, ttype, speed)
"base_sink": [], # (row, col, ttype, base_z - terrain_z)
"ok_count": 0,
"total_tested": 0,
}
def _get_terrain_z(self, x, y):
"""查询 (x, y) 处的地形高度。"""
hm = self.env._hm_cache
if hm is None:
return 0.0
nr, nc = hm.shape
xmin = self.env._h_xmin
ymax = self.env._h_ymax
w = self.env._h_xmax - xmin
h = ymax - self.env._h_ymin
z_base = self.env._h_zbase
col = int(np.clip((x - xmin) / max(w, 1e-6) * (nc - 1), 0, nc - 1))
row = int(np.clip((ymax - y) / max(h, 1e-6) * (nr - 1), 0, nr - 1))
return float(hm[row, col]) + z_base
def _get_foot_positions(self, state):
"""获取所有足部在世界坐标系中的位置 (N, 4, 3)。"""
data = state.data
n = data.shape[0]
feet = np.zeros((n, 4, 3), dtype=np.float32)
foot_names = ["FR_foot", "FL_foot", "RR_foot", "RL_foot"]
for i, name in enumerate(foot_names):
gidx = self.env._model.get_geom_index(name)
if gidx is not None:
pose = self.env._model.geoms[gidx].get_pose(data)
feet[:, i, :] = pose[:, :3]
return feet
def test_cell(self, row, col, level):
"""在单个 cell 上测试多个 spawn 位置。"""
cx, cy = _cell_origin(row, col)
ttype = _terrain_type(col)
tname = TYPE_NAMES[ttype]
# 在 cell 内取几个 spawn 点(中央 + 四角附近)
offsets = [
(0, 0),
(CELL_M * 0.3, 0), (-CELL_M * 0.3, 0),
(0, CELL_M * 0.3), (0, -CELL_M * 0.3),
][:STEPS_PER_CELL]
for ox, oy in offsets:
sx, sy = cx + ox, cy + oy
terrain_z = float(self.env._sample_terrain_height(
np.array([[sx, sy]], dtype=np.float32), radius=0.35)[0])
self.stats["total_tested"] += 1
self._test_single(sx, sy, terrain_z, row, col, level, ttype, tname)
def _test_single(self, sx, sy, terrain_z, row, col, level, ttype, tname):
"""单个 spawn 点的完整测试。"""
env = self.env
data = env._state.data
n = 1
# Spawn
init_pos = env._init_dof_pos.copy().reshape(1, -1)
init_pos[0, 0] = sx
init_pos[0, 1] = sy
spawn_z = terrain_z + 0.45 # 标准 clearance
init_pos[0, 2] = spawn_z
yaw = np.random.uniform(-np.pi, np.pi)
init_pos[0, 3:7] = [0, 0, np.sin(yaw / 2), np.cos(yaw / 2)]
data.reset(env._model)
data.set_dof_pos(init_pos, env._model)
env._model.forward_kinematic(data)
# 给一个随机动作防止完全静止(小幅度 PD 控制)
action = 0.1 * (np.random.randn(n, 12).astype(np.float32))
has_nan = False
max_dof_vel = 0.0
max_contact_force = 0.0
min_foot_z = spawn_z
min_base_z = spawn_z
total_speed = 0.0
for step in range(SETTLE_STEPS + TEST_STEPS):
# Apply PD
actions_scaled = action * env.cfg.control_config.action_scale
target = actions_scaled + env.default_angles.reshape(1, -1)
lo = env._model.joint_limits[0].reshape(1, -1)
hi = env._model.joint_limits[1].reshape(1, -1)
target = np.clip(target, lo, hi)
torques = env.kps * (target - env.get_dof_pos(data)) - env.kds * env.get_dof_vel(data)
data.actuator_ctrls = np.clip(torques, -80.0, 80.0)
env._model.step(data)
# ── 检测 ──
# 1. NaN 检测
dv = np.array(data.dof_vel)
dp = np.array(data.dof_pos)
if np.any(~np.isfinite(dv)) or np.any(~np.isfinite(dp)):
self.stats["nan_events"].append((row, col, ttype, step))
has_nan = True
break
# 2. 速度爆炸
cur_max_vel = float(np.max(np.abs(dv[:, 6:])))
max_dof_vel = max(max_dof_vel, cur_max_vel)
if cur_max_vel > 100.0:
self.stats["explosions"].append((row, col, ttype, cur_max_vel))
break
# 3. 接触力
# Read contact forces via MotrixSim sensor API
cf_vals = []
for foot in ["FR", "FL", "RR", "RL"]:
try:
v = env._model.get_sensor_value(f"{foot}_foot_contact", data)
cf_vals.append(v[0])
except Exception:
cf_vals.append(np.zeros(3, dtype=np.float32))
cf_raw = np.concatenate(cf_vals)
cur_cf = float(np.max(np.abs(cf_raw)))
max_contact_force = max(max_contact_force, cur_cf)
# 4. 足部穿透settle 之后测)
if step >= SETTLE_STEPS:
feet = self._get_foot_positions(env._state)
for fi in range(4):
fz = float(feet[0, fi, 2])
tz = self._get_terrain_z(float(feet[0, fi, 0]), float(feet[0, fi, 1]))
penetration = tz - fz # 正 = 穿透
min_foot_z = min(min_foot_z, fz - tz) # 相对地形的高度
if penetration > 0.02: # > 2cm 穿透
self.stats["penetrations"].append(
(row, col, ttype, float(penetration)))
# 5. 躯干沉入
base_pose = env._body.get_pose(data)
bz = float(base_pose[0, 2])
tz = self._get_terrain_z(float(base_pose[0, 0]), float(base_pose[0, 1]))
min_base_z = min(min_base_z, bz - tz)
if bz < tz + 0.10 and step >= SETTLE_STEPS: # 躯干离地面 < 10cm
self.stats["base_sink"].append((row, col, ttype, float(bz - tz)))
# 6. 速度(卡住检测)
if step >= SETTLE_STEPS:
linvel = env.get_local_linvel(data)[0]
total_speed += float(np.linalg.norm(linvel[:2]))
if self.renderer and step % 20 == 0:
self.renderer.render()
time.sleep(0.005)
# ── 统计 ──
if not has_nan and max_dof_vel < 100.0:
avg_speed = total_speed / max(TEST_STEPS, 1)
if avg_speed < 0.01 and step == SETTLE_STEPS + TEST_STEPS - 1:
self.stats["stuck_events"].append((row, col, ttype, avg_speed))
if not has_nan and max_dof_vel < 50.0 and min_base_z > 0.05:
self.stats["ok_count"] += 1
# 记录接触力异常(完全无接触力但足部应该着地)
if max_contact_force < 1e-3 and step == SETTLE_STEPS + TEST_STEPS - 1:
feet = self._get_foot_positions(env._state)
for fi in range(4):
fz = float(feet[0, fi, 2])
tz = self._get_terrain_z(float(feet[0, fi, 0]), float(feet[0, fi, 1]))
if abs(fz - tz) < 0.05: # 足部接近地面
self.stats["contact_anomalies"].append(
(row, col, ttype, 0.0)) # 0 = 无接触力
break
def run(self, target_level=None, target_type=None):
"""遍历所有地形 cell 测试。"""
total = 0
start_t = time.time()
for row in range(NUM_ROWS):
if target_level is not None and row != target_level:
continue
for col in range(NUM_COLS):
ttype = _terrain_type(col)
if target_type is not None and ttype != target_type:
continue
total += 1
print(f"[Diag] 测试 {NUM_ROWS}×{NUM_COLS} 网格, "
f"每 cell {STEPS_PER_CELL} 个 spawn 点 = {total * STEPS_PER_CELL}")
print(f"[Diag] Settle={SETTLE_STEPS}步 Test={TEST_STEPS}")
count = 0
for row in range(NUM_ROWS):
if target_level is not None and row != target_level:
continue
for col in range(NUM_COLS):
ttype = _terrain_type(col)
if target_type is not None and ttype != target_type:
continue
self.test_cell(row, col, row)
count += 1
if count % 10 == 0:
elapsed = time.time() - start_t
print(f" [{count}/{total}] {elapsed:.1f}s "
f"OK={self.stats['ok_count']}/{self.stats['total_tested']} "
f"穿透={len(self.stats['penetrations'])} "
f"NaN={len(self.stats['nan_events'])} "
f"爆炸={len(self.stats['explosions'])}")
elapsed = time.time() - start_t
self._report(elapsed)
def _report(self, elapsed):
s = self.stats
t = s["total_tested"]
print("\n" + "=" * 70)
print(f"地形碰撞诊断报告 ({elapsed:.1f}s, {t} 次测试)")
print("=" * 70)
# 总体统计
ok_rate = s["ok_count"] / max(t, 1) * 100
print(f"\n ✅ 通过: {s['ok_count']}/{t} ({ok_rate:.1f}%)")
# 穿透
if s["penetrations"]:
pens = s["penetrations"]
pvals = [p[3] for p in pens]
print(f"\n 🔴 足部穿透: {len(pens)}")
print(f" 最大穿透: {max(pvals):.3f}m 平均: {np.mean(pvals):.3f}m")
# 按地形类型分组
by_type = {}
for p in pens:
tn = TYPE_NAMES[p[2]]
by_type.setdefault(tn, []).append(p[3])
for tn, vals in sorted(by_type.items()):
print(f" {tn}: {len(vals)}次 max={max(vals):.3f}m avg={np.mean(vals):.3f}m")
# 按 level 分组
by_level = {}
for p in pens:
by_level.setdefault(p[0], []).append(p[3])
print(f" 按 level: ", end="")
for lv in sorted(by_level.keys()):
vals = by_level[lv]
print(f"L{lv}({len(vals)}x max={max(vals):.3f}) ", end="")
print()
else:
print(f"\n 🟢 足部穿透: 0 次 ✓")
# NaN
if s["nan_events"]:
print(f"\n 🔴 NaN/Inf: {len(s['nan_events'])}")
for n in s["nan_events"][:5]:
print(f" L{n[0]} C{n[1]:02d} {TYPE_NAMES[n[2]]} @step {n[3]}")
else:
print(f" 🟢 NaN/Inf: 0 次 ✓")
# 物理爆炸
if s["explosions"]:
print(f"\n 🟡 速度爆炸 (>{100}rad/s): {len(s['explosions'])}")
for e in s["explosions"][:5]:
print(f" L{e[0]} C{e[1]:02d} {TYPE_NAMES[e[2]]} max_vel={e[3]:.1f}")
else:
print(f" 🟢 速度爆炸: 0 次 ✓")
# 接触力异常
if s["contact_anomalies"]:
print(f"\n 🟡 接触力异常 (无接触但足部近地): {len(s['contact_anomalies'])}")
else:
print(f" 🟢 接触力异常: 0 次 ✓")
# 卡住
if s["stuck_events"]:
print(f"\n 🟡 卡住 (速度<0.01m/s): {len(s['stuck_events'])}")
by_type = {}
for e in s["stuck_events"]:
tn = TYPE_NAMES[e[2]]
by_type.setdefault(tn, []).append(1)
for tn, vals in sorted(by_type.items()):
print(f" {tn}: {len(vals)}")
else:
print(f" 🟢 卡住: 0 次 ✓")
# 躯干沉入
if s["base_sink"]:
sinks = s["base_sink"]
print(f"\n 🟡 躯干贴地 (<10cm): {len(sinks)}")
vals = [s[3] for s in sinks]
print(f" 最差: {min(vals):.3f}m 平均: {np.mean(vals):.3f}m")
else:
print(f" 🟢 躯干贴地: 0 次 ✓")
# 按地形类型汇总
print(f"\n── 按地形类型汇总 ──")
for ti, tn in enumerate(TYPE_NAMES):
pens_t = sum(1 for p in s["penetrations"] if p[2] == ti)
nans_t = sum(1 for n in s["nan_events"] if n[2] == ti)
exps_t = sum(1 for e in s["explosions"] if e[2] == ti)
icon = "🔴" if (pens_t + nans_t + exps_t) > 0 else "🟢"
print(f" {icon} {tn}: 穿透{pens_t} NaN{nans_t} 爆炸{exps_t}")
# 结论
print(f"\n── 全局结论 ──")
if ok_rate > 95 and len(s["nan_events"]) == 0 and len(s["penetrations"]) < 5:
print(f" ✅ 仿真环境正常 — hfield 地形碰撞基本可靠")
elif ok_rate > 80:
print(f" ⚠ 仿真环境存在一些问题 — 部分地形类型/level 有问题")
else:
print(f" 🔴 仿真环境问题严重 — 需要排查 MotrixSim hfield 实现")
if len(s["penetrations"]) > 10:
print(f" 💡 穿透较多 → hfield 碰撞面可能偏软或 contact margin 过大")
print(f" 可尝试增大 geom margin 或检查 MotrixSim 碰撞参数")
# ═══════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════
def main():
p = argparse.ArgumentParser(description="DreamWaQ 地形碰撞诊断")
p.add_argument("--level", type=int, default=None, help="仅测试指定 level (0-9)")
p.add_argument("--type", dest="ttype", default=None,
choices=["slope", "rough", "stairs_down", "stairs_up", "obstacles"],
help="仅测试指定地形类型")
p.add_argument("--render", action="store_true", help="渲染可视化")
p.add_argument("--settle", type=int, default=SETTLE_STEPS)
p.add_argument("--test", type=int, default=TEST_STEPS)
args = p.parse_args()
type_map = {"slope": 0, "rough": 1, "stairs_down": 2, "stairs_up": 3, "obstacles": 4}
target_type = type_map.get(args.ttype)
print("[Diag] 创建 DreamWaQ 环境...")
env = env_registry.make("go1-dreamwaq-walk", num_envs=1)
# 如果指定 level强制执行
if args.level is not None:
env._force_level = args.level
print(f"[Diag] 强制 level={args.level}")
env.init_state()
# 确保 terrain 初始化(触发 hfield cache
if env._model.num_hfields > 0:
hf = env._model.get_hfield(0)
env._hm_cache = hf.height_matrix
env._h_xmin, env._h_ymin = hf.bound[0], hf.bound[1]
env._h_xmax, env._h_ymax = hf.bound[3], hf.bound[4]
env._h_nr, env._h_nc = hf.height_matrix.shape
cfg_base = getattr(env.cfg, "hfield_z_base", None)
env._h_zbase = float(cfg_base) if cfg_base is not None else float(-hf.height_matrix[0, 0])
print(f"[Diag] HField: {hf.height_matrix.shape} bound={hf.bound}")
print(f"[Diag] z_base={env._h_zbase:.3f}")
else:
print("[Diag] WARNING: 无 hfieldflat 场景),跳过诊断")
return
diag = CollisionDiag(env, render=args.render)
diag.SETTLE_STEPS = args.settle
diag.TEST_STEPS = args.test
try:
diag.run(target_level=args.level, target_type=target_type)
except KeyboardInterrupt:
print("\n[Diag] 中断")
finally:
if diag.renderer:
diag.renderer.close()
print("[Diag] 完成")
if __name__ == "__main__":
main()