Add eval_kit evaluation suite with WebRTC visualization

- eval_kit/core: 零框架依赖的指标内核(Trace/metrics/aggregation/terrain_spec/report/naming)
- eval_kit/isaaclab: 适配层(env_cfg 改造、rollout 采集、checkpoint 加载)
- eval_kit/profiles.py / protocols.py: 机器人形态描述与评估口径预设
- eval_kit/tests: 24 项内核自检 + 21 项命令行自检(都不需要仿真)
- eval_suite.py: 编排入口,每个(阶段, 种子)一个子进程;与 eval.py 指标口径一致
- WebRTC 可视化:--livestream 透传、视口相机跟随/自由(--view-env / --free-camera)、
  连接前停顿(--pause-s)、自动放开客户端改画幅、打印连接地址
- 诊断与替代路径:rollout 进度打印、--record-frames 抓帧
This commit is contained in:
syfffffjj
2026-10-20 10:40:13 +08:00
parent 1aeeb34653
commit 7d290ca6ff
18 changed files with 4823 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
# Copyright (c) 2025 Deep Robotics
# SPDX-License-Identifier: BSD 3-Clause
"""指标计算:纯函数,只依赖 numpy。
输入是 :class:`~eval_kit.core.schema.Trace`、:class:`~eval_kit.core.schema.RobotProfile`
和 :class:`~eval_kit.core.schema.EvalProtocol`,输出是一个扁平的指标字典。
指标口径与 ``scripts/reinforcement_learning/rsl_rl/eval.py`` 保持一致(同样的取点、
同样的公式),因此默认协议下两套评估的数值可以直接对照;差别只在三处,且都是
有意为之:
1. **质量**不再写死在命令行默认值里,而是从 ``Trace.mass_kg`` 取(适配层从资产读),
传入的 ``profile.mass_kg`` 只作兜底。
2. **加速度阈值**以重力倍数表达,换算成 m/s² 后参与计算,使阈值可跨量级机器人复用。
3. **缺失的形态**返回 ``nan`` 而不是 0。旧实现里没有足端的机器人会得到
「支撑裕度 = 0.00」,看起来像测出来的值,实际是没测。
"""
from __future__ import annotations
from typing import Any
import numpy as np
from eval_kit.core.schema import EvalProtocol, RobotProfile, Trace
#: 全部帧,用于成功判定与最大进度(含起始帧,与旧实现取点一致)。
_ALL_FRAMES = slice(None)
#: 动作执行之后的帧,用于逐帧累积类指标。
_STEP_FRAMES = slice(1, None)
_GRAVITY = 9.81
def tilt_from_projected_gravity(projected_gravity_b: np.ndarray) -> np.ndarray:
"""由机体系投影重力算倾角 [rad]。
``projected_gravity_b`` 为 ``(T, N, 3)``,返回 ``(T, N)``。
"""
z_axis = np.clip(-projected_gravity_b[..., 2], -1.0, 1.0)
return np.arccos(z_axis)
def support_margin_xy(root_xy: np.ndarray, foot_xy: np.ndarray) -> np.ndarray:
"""机身投影点到足端轴对齐包围盒的距离 [m],在盒内为正、盒外为负。
``root_xy`` 为 ``(N, 2)``,``foot_xy`` 为 ``(N, F, 2)``,返回 ``(N,)``。
这是当前实现采用的口径(与旧 ``eval.py`` 相同),记录在
``EvalProtocol.stability_margin_method`` 里。它比凸包/ZMP 版本粗糙,
但不会在退化多边形上失败,也不需要 scipy。
"""
min_xy = np.min(foot_xy, axis=1)
max_xy = np.max(foot_xy, axis=1)
inside_x = (root_xy[:, 0] >= min_xy[:, 0]) & (root_xy[:, 0] <= max_xy[:, 0])
inside_y = (root_xy[:, 1] >= min_xy[:, 1]) & (root_xy[:, 1] <= max_xy[:, 1])
inside = inside_x & inside_y
inside_dist = np.minimum(
np.minimum(root_xy[:, 0] - min_xy[:, 0], max_xy[:, 0] - root_xy[:, 0]),
np.minimum(root_xy[:, 1] - min_xy[:, 1], max_xy[:, 1] - root_xy[:, 1]),
)
outside_dx = np.clip(np.maximum(min_xy[:, 0] - root_xy[:, 0], root_xy[:, 0] - max_xy[:, 0]), 0.0, None)
outside_dy = np.clip(np.maximum(min_xy[:, 1] - root_xy[:, 1], root_xy[:, 1] - max_xy[:, 1]), 0.0, None)
outside_dist = np.sqrt(outside_dx**2 + outside_dy**2)
return np.where(inside, inside_dist, -outside_dist)
def _nanmean_or_nan(values: np.ndarray) -> float:
"""均值;空数组返回 nan(而不是 0,也不是 numpy 的警告)。"""
if values.size == 0:
return float("nan")
return float(np.mean(values))
def compute_metrics(
trace: Trace,
profile: RobotProfile,
protocol: EvalProtocol,
*,
include_per_env: bool = False,
) -> dict[str, Any]:
"""由一次 rollout 的轨迹算出全部指标。
指标名的命名与旧 ``eval.py`` 完全一致,便于新旧结果直接对照。
"""
num_steps = trace.num_steps
num_envs = trace.num_envs
dt = float(trace.dt)
if abs(protocol.dt - dt) > 0.01 * dt:
raise ValueError(
f"协议 dt={protocol.dt} 与轨迹 dt={dt} 不一致。"
"通常意味着协议里的步长与任务的 decimation 不匹配。"
)
command = np.asarray(trace.command, dtype=np.float64)
command_xy = command[:2]
command_yaw = float(command[2])
command_dir = command_xy.copy()
norm = float(np.linalg.norm(command_dir))
command_dir = command_dir / norm if norm > 1e-6 else np.array([1.0, 0.0])
# ------------------------------------------------------------------ 成功判定
base_xy = np.asarray(trace.base_pos_w, dtype=np.float64)[..., :2]
delta_xy = base_xy - base_xy[0][None, :, :]
progress = np.einsum("tnc,c->tn", delta_xy[_ALL_FRAMES], command_dir)
max_progress = progress.max(axis=0)
reached = max_progress >= protocol.success_distance_m
tilt = tilt_from_projected_gravity(np.asarray(trace.projected_gravity_b, dtype=np.float64))
tilt_steps = tilt[_STEP_FRAMES]
max_tilt = tilt_steps.max(axis=0)
ever_fallen = max_tilt > protocol.tilt_threshold_rad
success = reached & ~ever_fallen
success_count = int(np.count_nonzero(success))
# 首次达成成功的时刻:第 i 帧(i>=1)达成则记 i*dt。
crossed = progress[_STEP_FRAMES] >= protocol.success_distance_m
complete_time = np.full((num_envs,), np.nan, dtype=np.float64)
for env_index in range(num_envs):
hit = np.flatnonzero(crossed[:, env_index])
if hit.size:
complete_time[env_index] = float(hit[0] + 1) * dt
successful_time = complete_time[success & np.isfinite(complete_time)]
avg_time = _nanmean_or_nan(successful_time)
# ------------------------------------------------------------- 速度跟踪误差
lin_vel_b = np.asarray(trace.base_lin_vel_b, dtype=np.float64)[_STEP_FRAMES]
ang_vel_b = np.asarray(trace.base_ang_vel_b, dtype=np.float64)[_STEP_FRAMES]
lin_error = np.linalg.norm(lin_vel_b[..., :2] - command_xy[None, None, :], axis=-1)
yaw_error = np.abs(ang_vel_b[..., 2] - command_yaw)
# -------------------------------------------------------------- 能耗与路程
joint_torque = np.asarray(trace.joint_torque, dtype=np.float64)[_STEP_FRAMES]
joint_vel = np.asarray(trace.joint_vel, dtype=np.float64)[_STEP_FRAMES]
mechanical_power = joint_torque * joint_vel
if protocol.energy_convention == "absolute":
integrand = np.abs(mechanical_power)
elif protocol.energy_convention == "signed":
integrand = mechanical_power
else:
raise ValueError(
f"未知能耗约定 {protocol.energy_convention!r};可选 'absolute' 或 'signed'。"
)
energy = integrand.sum(axis=(0, 2)) * dt
lin_vel_w = np.asarray(trace.base_lin_vel_w, dtype=np.float64)
route = np.linalg.norm(lin_vel_w[_STEP_FRAMES][..., :2], axis=-1).sum(axis=0) * dt
# ------------------------------------------------------------ 运动不稳定性
accel = np.linalg.norm(np.diff(lin_vel_w, axis=0), axis=-1) / dt
instability_count = (accel > protocol.acc_threshold_mps2).sum(axis=0)
stable_time = (tilt_steps < protocol.tilt_threshold_rad).sum(axis=0) * dt
# ------------------------------------------------------------- 支撑多边形
feet_pos_w = np.asarray(trace.feet_pos_w, dtype=np.float64)[_STEP_FRAMES]
num_feet = int(feet_pos_w.shape[2])
if num_feet > 0:
margins = support_margin_xy(base_xy[_STEP_FRAMES][:, :, :].reshape(-1, 2), feet_pos_w.reshape(-1, num_feet, 2))
margins = margins.reshape(feet_pos_w.shape[0], num_envs)
avg_margin = _nanmean_or_nan(margins)
margin_method = protocol.stability_margin_method
else:
avg_margin = float("nan")
margin_method = "unavailable"
# --------------------------------------------------------------- 支撑接触
support_force = np.asarray(trace.support_force_n, dtype=np.float64)
support_contact_time = np.asarray(trace.support_contact_time_s, dtype=np.float64)
num_support_bodies = int(support_force.shape[2])
support_threshold = (
profile.support_force_threshold_n
if protocol.support_force_threshold_n is None
else protocol.support_force_threshold_n
)
if num_support_bodies > 0:
active = np.any(support_force[_STEP_FRAMES] > support_threshold, axis=2) # (T-1, N)
support_steps = active.sum(axis=0)
# 起始帧之前视为「未接触」,因此首帧接触也算一次事件(与旧实现一致)。
active_padded = np.concatenate([np.zeros((1, num_envs), dtype=bool), active], axis=0)
support_events = (active_padded[1:] & ~active_padded[:-1]).sum(axis=0)
max_support_time = support_contact_time[_STEP_FRAMES].max(axis=(0, 2))
support_contact_ratio = _nanmean_or_nan(support_steps / max(num_steps, 1))
avg_support_events = float(np.mean(support_events))
avg_max_support_time = float(np.mean(max_support_time))
if success_count > 0:
success_support_ratio = float(np.mean((support_events[success] > 0).astype(np.float64)))
else:
success_support_ratio = float("nan")
else:
support_contact_ratio = float("nan")
avg_support_events = float("nan")
avg_max_support_time = float("nan")
success_support_ratio = float("nan")
# --------------------------------------------------------- 质量与 COT
mass = trace.mass_kg if trace.mass_kg is not None else profile.mass_kg
if mass is None or mass <= 0.0:
cost_of_transport = np.full((num_envs,), np.nan)
mass_used = float("nan")
else:
cost_of_transport = energy / ((route + 1e-6) * mass * _GRAVITY)
mass_used = float(mass)
metrics: dict[str, Any] = {
"profile": profile.name,
"protocol_fingerprint": protocol.fingerprint(),
"num_envs": float(num_envs),
"num_steps": float(num_steps),
"episode_length_s": float(num_steps * dt),
"terrain_size_m": float(protocol.terrain_size_m),
"success_distance_m": float(protocol.success_distance_m),
"robot_mass_kg": mass_used,
"energy_convention": protocol.energy_convention,
"stability_margin_method": margin_method,
"tilt_threshold_rad": float(protocol.tilt_threshold_rad),
"acc_threshold_mps2": float(protocol.acc_threshold_mps2),
"support_force_threshold_n": float(support_threshold) if num_support_bodies > 0 else float("nan"),
# --- 成功率与进度
"success_count": float(success_count),
"success_rate": float(success_count / max(num_envs, 1)),
"avg_final_progress_m": float(np.mean(max_progress)),
# --- 时间
"avg_time_consumption_s": avg_time,
"time_efficiency": float(
avg_time / (protocol.success_distance_m / max(abs(float(command[0])), 1e-6))
)
if np.isfinite(avg_time)
else float("nan"),
# --- 跟踪误差
"mean_lin_vel_error": float(np.mean(lin_error)),
"mean_yaw_vel_error": float(np.mean(yaw_error)),
# --- 能耗
"avg_energy_j": float(np.mean(energy)),
"avg_cost_of_transport": _nanmean_or_nan(cost_of_transport),
# --- 稳定性
"unstability_rate": float(np.mean(instability_count / max(num_steps, 1))),
"avg_stability_margin_m": avg_margin,
"avg_max_tilt_rad": float(np.mean(max_tilt)),
"avg_stable_time_s": float(np.mean(stable_time)),
# --- 支撑接触
"avg_support_contact_ratio": support_contact_ratio,
"avg_support_events": avg_support_events,
"avg_max_support_contact_time_s": avg_max_support_time,
"successful_runs_with_support_ratio": success_support_ratio,
}
if include_per_env:
metrics["_per_env"] = {
"success": success.astype(float).tolist(),
"max_progress": max_progress.tolist(),
"max_tilt": max_tilt.tolist(),
}
return metrics