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
+18
View File
@@ -0,0 +1,18 @@
# Copyright (c) 2025 Deep Robotics
# SPDX-License-Identifier: BSD 3-Clause
"""IsaacLab 适配层:唯一接触仿真环境内部的地方。
这一层**依赖** isaaclab / isaacsim,因此必须在 ``AppLauncher`` 启动之后才能 import。
上面的 :mod:`eval_kit.core` 与 :mod:`eval_kit.profiles` 不依赖它,可以单独使用。
职责边界:
- :mod:`eval_kit.isaaclab.env_setup` —— 把协议与地形规格翻译成 env_cfg 的改动
- :mod:`eval_kit.isaaclab.recorder` —— 跑 rollout 并采集成 :class:`~eval_kit.core.schema.Trace`
- :mod:`eval_kit.isaaclab.policy` —— checkpoint 解析与策略加载
这里没有任何指标计算逻辑,指标全在内核里。
"""
__all__ = ["env_setup", "policy", "recorder"]
+307
View File
@@ -0,0 +1,307 @@
# Copyright (c) 2025 Deep Robotics
# SPDX-License-Identifier: BSD 3-Clause
"""把评估协议与地形规格翻译成 IsaacLab 的 ``env_cfg`` 改动。
这些函数对应旧 ``eval.py`` 里的 ``apply_eval_terrain_baseline`` /
``configure_eval_env`` / ``configure_upper_limit_env`` / ``assign_upper_limit_origins``,
区别是:
- 数值全部来自 :class:`~eval_kit.core.terrain_spec.TerrainSpec` 与
:class:`~eval_kit.core.schema.EvalProtocol`,没有散落的常数
- 需要关掉的随机化事件分「通用」与「机器人相关」两组,机器人相关的那组用
``hasattr`` 守卫,因此换机器人不会因为字段不存在而报错
"""
from __future__ import annotations
import copy
from typing import Any, Sequence
import torch
from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG
from eval_kit.core.schema import EvalProtocol
from eval_kit.core.terrain_spec import TerrainSpec
#: 所有任务都需要的通用随机化项。
COMMON_RANDOMIZATION_TERMS: tuple[str, ...] = (
"randomize_rigid_body_material",
"randomize_rigid_body_mass",
"randomize_rigid_body_mass_base",
"randomize_rigid_body_inertia",
"randomize_com_positions",
"randomize_apply_external_force_torque",
"randomize_actuator_gains",
)
#: 火蚁专属的随机化项(机身/膝部与轮胎分开做材质随机化)。
ROBOT_SPECIFIC_RANDOMIZATION_TERMS: tuple[str, ...] = (
"randomize_wheel_material",
"randomize_support_material",
)
def set_fixed_command(env_cfg: Any, protocol: EvalProtocol) -> None:
"""把速度指令钉成一个恒定值,并在整个 episode 内不重采样。"""
command_cfg = env_cfg.commands.base_velocity
command_x, command_y, command_yaw = protocol.command
command_cfg.heading_command = False
command_cfg.resampling_time_range = (
protocol.steps * protocol.dt + 0.01,
protocol.steps * protocol.dt + 0.01,
)
command_cfg.ranges.lin_vel_x = (command_x, command_x)
command_cfg.ranges.lin_vel_y = (command_y, command_y)
command_cfg.ranges.ang_vel_z = (command_yaw, command_yaw)
command_cfg.debug_vis = False
def apply_eval_terrain_baseline(env_cfg: Any, spec: TerrainSpec, protocol: EvalProtocol) -> None:
"""所有任务统一用同一套评估地形,保证不同机器人/不同实验面对的是同一块地。"""
if env_cfg.scene.terrain.terrain_type == "plane":
env_cfg.scene.terrain.terrain_type = "generator"
env_cfg.scene.terrain.terrain_generator = copy.deepcopy(ROUGH_TERRAINS_CFG)
generator = env_cfg.scene.terrain.terrain_generator
generator.size = (protocol.terrain_size_m, protocol.terrain_size_m)
generator.border_width = 2.0
generator.curriculum = False
generator.num_rows = spec.grid_rows
generator.num_cols = spec.grid_cols
generator.use_cache = False
env_cfg.scene.terrain.max_init_terrain_level = None
# 瓦片变小的时候,台阶宽度/平台宽度等特征也要跟着收,否则一块地里塞不下
for sub_cfg in generator.sub_terrains.values():
for field_name in spec.feature_scaling:
if hasattr(sub_cfg, field_name):
setattr(sub_cfg, field_name, spec.scaling_for(field_name, protocol.terrain_size_m))
def _build_push_event(protocol: EvalProtocol):
"""构造周期性扰动事件。"""
from isaaclab.managers import EventTermCfg as EventTerm
try:
# 与旧 eval.py 同源,保证扰动行为完全一致
import rl_training.tasks.manager_based.locomotion.velocity.mdp as mdp
except ImportError:
from isaaclab_tasks.manager_based.locomotion.velocity import mdp
return EventTerm(
func=mdp.push_by_setting_velocity,
mode="interval",
interval_range_s=(protocol.push_interval_s[0], protocol.push_interval_s[1]),
params={
"velocity_range": {
"x": protocol.push_velocity_range,
"y": protocol.push_velocity_range,
}
},
)
def _configure_viewer(env_cfg: Any, viewer_env_index: int | None, free_camera: bool) -> None:
"""设置视口相机:跟随某个机器人,或交给客户端自己控制。
``ViewportCameraController`` 只在 ``origin_type`` 是 ``asset_root`` /
``asset_body`` 时每帧改写相机位姿;设成 ``world`` 只在初始化时写一次,
之后客户端的操作不会被抢回去 —— 这就是「自由视角」。起始视角由子进程按
实际地形范围另设(见 ``eval_suite._set_free_camera_overview``)。
"""
viewer = getattr(env_cfg, "viewer", None)
if viewer is None:
return
if free_camera:
viewer.origin_type = "world"
# 仅作初始化时的临时值,正式视角在环境建好后按地形范围重设
viewer.eye = (0.0, -20.0, 15.0)
viewer.lookat = (0.0, 0.0, 0.0)
return
if viewer_env_index is None:
return
viewer.origin_type = "asset_root"
viewer.asset_name = "robot"
viewer.env_index = int(viewer_env_index)
# 相对机器人的偏移:斜前方 2.5 m、抬高 1.6 m,看向机身略前方
viewer.eye = (2.5, -2.5, 1.6)
viewer.lookat = (0.4, 0.0, 0.1)
def configure_eval_env(
env_cfg: Any,
spec: TerrainSpec,
protocol: EvalProtocol,
*,
num_envs: int | None = None,
device: str | None = None,
seed: int | None = None,
viewer_env_index: int | None = None,
free_camera: bool = False,
) -> None:
"""把训练用配置改造成单次评估用配置。
关掉观测噪声、课程、随机化,把重置位姿钉死,指令固定,地形统一。
``viewer_env_index`` 不为 None 时,把视口相机改成跟随该环境的机器人;
``free_camera`` 为 True 时改成不跟随任何物体、由客户端自己移动(两者互斥,
前者优先)。这两种都只在有画面时(本地 GUI 或推流)才有意义 —— headless
且不推流时 IsaacLab 根本不创建相机控制器,设了也无害。
"""
env_cfg.scene.num_envs = num_envs if num_envs is not None else env_cfg.scene.num_envs
env_cfg.episode_length_s = protocol.steps * protocol.dt
if seed is not None:
env_cfg.seed = seed
if device is not None:
env_cfg.sim.device = device
_configure_viewer(env_cfg, viewer_env_index, free_camera)
env_cfg.observations.policy.enable_corruption = False
set_fixed_command(env_cfg, protocol)
apply_eval_terrain_baseline(env_cfg, spec, protocol)
# 课程必须关掉,否则难度/指令会在评估过程中自己变
for term in ("terrain_levels", "command_levels"):
if getattr(env_cfg.curriculum, term, None) is not None:
setattr(env_cfg.curriculum, term, None)
for term in (*COMMON_RANDOMIZATION_TERMS, *ROBOT_SPECIFIC_RANDOMIZATION_TERMS):
if hasattr(env_cfg.events, term):
setattr(env_cfg.events, term, None)
# 重置位姿钉死:同一批机器人从同一个位置、同一姿态、零速度出发
reset_base = getattr(env_cfg.events, "randomize_reset_base", None)
if reset_base is not None:
start_x, start_y, start_yaw = protocol.start_pose
# z 是相对地形瓦片中心高度的抬升。默认 0.0 与旧 eval.py 一致,但只对
# 「瓦片中心最高」的地形成立;倒金字塔台阶这类中心最低的地形需要正值,
# 否则机器人在 |x|>platform 处会被生成到地形几何体内部而卡住。
reset_base.params = {
"pose_range": {
"x": (start_x, start_x),
"y": (start_y, start_y),
"z": (protocol.start_z_offset_m, protocol.start_z_offset_m),
"roll": (0.0, 0.0),
"pitch": (0.0, 0.0),
"yaw": (start_yaw, start_yaw),
},
"velocity_range": {axis: (0.0, 0.0) for axis in ("x", "y", "z", "roll", "pitch", "yaw")},
}
if hasattr(env_cfg.events, "randomize_push_robot"):
env_cfg.events.randomize_push_robot = _build_push_event(protocol) if protocol.enable_interval_pushes else None
# 越界终止项:它比较的是世界坐标与整张地图的范围,不是机器人自己那块地砖,
# 小地砖 + 大网格时会把最外侧格子判成越界(详见 EvalProtocol 的说明)。
if not protocol.enforce_terrain_bounds and getattr(env_cfg.terminations, "terrain_out_of_bounds", None) is not None:
env_cfg.terminations.terrain_out_of_bounds = None
# 说明:判定地砖是否够大的逻辑已移到 eval_kit.core.schema.EvalProtocol.
# minimum_terrain_size_for_bounds()。它只用到协议自己的 start_pose,放在适配层会
# 让不启动仿真的编排进程被迫 import isaaclab(进而因缺 pxr 而失败)。
def configure_upper_limit_env(
env_cfg: Any,
spec: TerrainSpec,
protocol: EvalProtocol,
terrain_types: Sequence[str],
*,
seed: int | None = None,
device: str | None = None,
viewer_env_index: int | None = None,
free_camera: bool = False,
) -> None:
"""配置上限评估的固定网格:行 = 难度等级,列 = 地形类型。
``seed`` 同时决定地形生成与重置采样。显式传入是为了让地形本身在不同种子间
保持一致(同一块地),而机器人初始状态可以随种子变化。
``viewer_env_index`` / ``free_camera`` 透传给 :func:`configure_eval_env`,
用于让视口相机跟随某个环境的机器人,或者干脆交给客户端自由控制。
"""
configure_eval_env(
env_cfg, spec, protocol, seed=seed, device=device,
viewer_env_index=viewer_env_index, free_camera=free_camera,
)
generator = env_cfg.scene.terrain.terrain_generator
generator.curriculum = True
generator.num_rows = protocol.num_levels
generator.num_cols = len(terrain_types)
generator.difficulty_range = (0.0, 1.0)
if hasattr(generator, "seed"):
generator.seed = int(seed) if seed is not None else int(env_cfg.seed or 0)
# 每个格子里放 envs_per_cell 个机器人
env_cfg.scene.num_envs = protocol.num_levels * len(terrain_types) * protocol.envs_per_cell
env_cfg.scene.terrain.max_init_terrain_level = protocol.num_levels - 1
# 只保留这次评估要用到的子地形,且按被选中的次数分配占比
isaaclab_names = [spec.isaaclab_subterrain[terrain_type] for terrain_type in terrain_types]
original = generator.sub_terrains
generator.sub_terrains = {name: copy.deepcopy(original[name]) for name in isaaclab_names}
for name, sub_cfg in generator.sub_terrains.items():
sub_cfg.proportion = isaaclab_names.count(name) / len(isaaclab_names)
# 把等级表里的首尾值写进子地形的难度区间。
# 用 _set_sub_terrain_field 而不是裸 setattr:字段名写错要立刻报错,
# 静默造一个假属性会让地形保持默认难度,而结果看起来「只是很难」。
for terrain_type in terrain_types:
sub_cfg = generator.sub_terrains[spec.isaaclab_subterrain[terrain_type]]
levels = spec.levels_for(terrain_type, protocol.num_levels)
_set_sub_terrain_field(sub_cfg, spec.isaaclab_param[terrain_type], (levels[0], levels[-1]), terrain_type)
# 与等级无关的固定字段(例如粗糙地形的噪声步长)
for terrain_type, extra in spec.isaaclab_extra_params.items():
if terrain_type not in terrain_types:
continue
sub_cfg = generator.sub_terrains[spec.isaaclab_subterrain[terrain_type]]
for field_name, value in extra.items():
_set_sub_terrain_field(sub_cfg, field_name, value, terrain_type)
def _set_sub_terrain_field(sub_cfg: Any, field_name: str, value: Any, terrain_type: str) -> None:
"""写入子地形字段,字段不存在时报错而不是静默创建一个假属性。"""
if not hasattr(sub_cfg, field_name):
raise AttributeError(
f"地形规格为 {terrain_type!r} 指定的字段 {field_name!r} 在 "
f"{type(sub_cfg).__name__} 上不存在。裸 setattr 会静默造一个假属性、"
f"使地形保持默认难度,而那看起来只是「很难」。可用字段:"
f"{sorted(k for k in vars(sub_cfg) if not k.startswith('_'))}"
)
setattr(sub_cfg, field_name, value)
def assign_upper_limit_origins(
env: Any,
spec: TerrainSpec,
protocol: EvalProtocol,
terrain_types: Sequence[str],
) -> tuple[torch.Tensor, torch.Tensor]:
"""给每个格子平均分配机器人,并直接把地形等级/类型/起点写进地形对象。
返回 ``(level_ids, type_ids)``,用于事后把逐环境结果折叠成矩阵。
"""
base_env = env.unwrapped
terrain = base_env.scene.terrain
device = base_env.device
levels: list[int] = []
types: list[int] = []
for type_index in range(len(terrain_types)):
for level_index in range(protocol.num_levels):
levels.extend([level_index] * protocol.envs_per_cell)
types.extend([type_index] * protocol.envs_per_cell)
level_ids = torch.tensor(levels, dtype=torch.long, device=device)
type_ids = torch.tensor(types, dtype=torch.long, device=device)
terrain.terrain_levels = level_ids
terrain.terrain_types = type_ids
terrain.env_origins = terrain.terrain_origins[level_ids, type_ids]
env.reset()
return level_ids, type_ids
+71
View File
@@ -0,0 +1,71 @@
# Copyright (c) 2025 Deep Robotics
# SPDX-License-Identifier: BSD 3-Clause
"""checkpoint 解析与策略加载(IsaacLab + RSL-RL)。"""
from __future__ import annotations
import os
from typing import Any
import gymnasium as gym
from isaaclab.envs import (
DirectMARLEnv,
DirectMARLEnvCfg,
DirectRLEnvCfg,
ManagerBasedRLEnvCfg,
multi_agent_to_single_agent,
)
from isaaclab_tasks.utils import get_checkpoint_path
from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper
def resolve_checkpoint(
log_root_path: str,
load_run: str | None = None,
checkpoint: str | None = None,
) -> str:
"""解析出 checkpoint 的绝对路径。
允许 ``load_run`` / ``checkpoint`` 传绝对路径或带目录的相对路径,从而跨实验目录
取权重(例如从 Stage 1 的目录里取权重来评估),否则回退到 IsaacLab 的按名查找。
"""
if checkpoint is not None:
checkpoint_path = os.path.expanduser(checkpoint)
if os.path.isabs(checkpoint_path) or os.path.dirname(checkpoint_path):
checkpoint_path = os.path.abspath(checkpoint_path)
if not os.path.isfile(checkpoint_path):
raise FileNotFoundError(f"找不到 checkpoint:{checkpoint_path}")
return checkpoint_path
if load_run is not None:
run_path = os.path.expanduser(load_run)
if os.path.isabs(run_path) or os.path.dirname(run_path):
run_path = os.path.abspath(run_path)
if not os.path.isdir(run_path):
raise FileNotFoundError(f"找不到运行目录:{run_path}")
return get_checkpoint_path(os.path.dirname(run_path), os.path.basename(run_path), checkpoint)
return get_checkpoint_path(log_root_path, load_run, checkpoint)
def make_env(
task: str,
env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg,
clip_actions: float | None = None,
):
"""按已配置好的 ``env_cfg`` 建环境,并包成 RSL-RL 的向量环境。"""
env = gym.make(task, cfg=env_cfg)
if isinstance(env.unwrapped, DirectMARLEnv):
env = multi_agent_to_single_agent(env)
return RslRlVecEnvWrapper(env, clip_actions=clip_actions)
def load_policy(env, agent_cfg: Any, resume_path: str, device: str | None = None):
"""加载 checkpoint 并返回推理策略。"""
from rsl_rl.runners import OnPolicyRunner
runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=device or agent_cfg.device)
runner.load(resume_path)
return runner.get_inference_policy(device=device or env.unwrapped.device)
+257
View File
@@ -0,0 +1,257 @@
# Copyright (c) 2025 Deep Robotics
# SPDX-License-Identifier: BSD 3-Clause
"""跑 rollout 并把仿真信号采集成框架无关的 :class:`~eval_kit.core.schema.Trace`。
这是唯一读取 ``env`` 内部状态的地方。采集完之后环境就可以关掉了,后续所有
指标计算都在内核里对纯数组进行。
形态相关的下标(谁是轮、谁是支撑体、哪些关节耗电)全部由
:class:`~eval_kit.core.schema.RobotProfile` 的正则解析得到,代码里没有硬编码的
link/joint 名字。解析不到时记录警告并跳过对应指标,而不是抛异常。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
import numpy as np
import torch
from eval_kit.core.naming import resolve_matching_indices
from eval_kit.core.schema import EvalProtocol, RobotProfile, Trace
#: 默认的接触力传感器名字,与 ``velocity_env_cfg.py`` 的场景定义一致。
DEFAULT_CONTACT_SENSOR = "contact_forces"
@dataclass
class RolloutRecorder:
"""一次评估里固定不变的东西:形态下标与质量。建一次,跑多次。"""
feet_ids: list[int] = field(default_factory=list)
feet_names: list[str] = field(default_factory=list)
support_ids: list[int] = field(default_factory=list)
support_names: list[str] = field(default_factory=list)
joint_ids: list[int] = field(default_factory=list)
joint_names: list[str] = field(default_factory=list)
mass_kg: float | None = None
contact_sensor_name: str = DEFAULT_CONTACT_SENSOR
warnings: list[str] = field(default_factory=list)
def describe(self) -> str:
lines = [
f"足端 {len(self.feet_ids)} 个: {self.feet_names or '(无)'}",
f"支撑体 {len(self.support_ids)} 个: {self.support_names or '(无)'}",
f"驱动关节 {len(self.joint_ids)} 个: {self.joint_names or '(无)'}",
f"整机质量: {'未知' if self.mass_kg is None else f'{self.mass_kg:.3f} kg'}",
]
for warning in self.warnings:
lines.append(f"[警告] {warning}")
return "\n".join(lines)
def read_total_mass(robot: Any) -> float | None:
"""从仿真资产读整机质量。
优先读 PhysX 运行时的真实质量(会反映任何质量随机化),读不到再退回资产默认质量。
"""
try:
masses = robot.root_physx_view.get_masses()
return float(masses[0].sum().item())
except Exception: # noqa: BLE001 - 不同版本 API 差异,读不到就退回
pass
try:
return float(robot.data.default_mass[0].sum().item())
except Exception: # noqa: BLE001
return None
def build_recorder(
env: Any,
profile: RobotProfile,
*,
contact_sensor_name: str = DEFAULT_CONTACT_SENSOR,
) -> RolloutRecorder:
"""按机器人描述解析出足端/支撑体/关节下标,并读一次质量。"""
base_env = env.unwrapped
robot = base_env.scene["robot"]
recorder = RolloutRecorder(contact_sensor_name=contact_sensor_name)
feet = resolve_matching_indices(profile.feet_body_patterns, robot.body_names)
recorder.feet_ids = feet.indices
recorder.feet_names = feet.names
for pattern in feet.unmatched_patterns:
recorder.warnings.append(
f"机器人 {profile.name!r} 的足端正则 {pattern!r} 在 body_names 里没有命中,"
"支撑裕度指标将被跳过。"
)
robot_bodies = set(robot.body_names)
sensor = base_env.scene.sensors.get(contact_sensor_name) if base_env.scene.sensors else None
if sensor is None:
recorder.warnings.append(
f"场景里没有接触力传感器 {contact_sensor_name!r},支撑接触类指标将被跳过。"
)
else:
support = resolve_matching_indices(profile.support_body_patterns, sensor.body_names)
recorder.support_ids = support.indices
recorder.support_names = support.names
missing = [name for name in support.names if name not in robot_bodies]
if missing:
recorder.warnings.append(
f"接触力传感器上的 {missing} 不在机器人的 body 列表里,指标可能对不上。"
)
for pattern in support.unmatched_patterns:
if pattern in profile.support_body_patterns:
recorder.warnings.append(
f"机器人 {profile.name!r} 的支撑体正则 {pattern!r} 没有命中,"
"支撑接触类指标将被跳过。"
)
joints = resolve_matching_indices(profile.actuated_joint_patterns, robot.joint_names)
recorder.joint_ids = joints.indices
recorder.joint_names = joints.names
if not joints.indices:
recorder.warnings.append(
"驱动关节正则一个都没命中,能耗与 COT 将无法计算(会返回 nan)。"
)
recorder.mass_kg = read_total_mass(robot)
if recorder.mass_kg is None and profile.mass_kg is None:
recorder.warnings.append("既读不到资产质量、描述里也没写兜底值,COT 将返回 nan。")
return recorder
class _FrameCollector:
"""逐帧收集张量,最后一次性转成 numpy——每帧都转会明显变慢。"""
def __init__(self) -> None:
self.frames: dict[str, list[np.ndarray]] = {}
def add(self, **signals: np.ndarray) -> None:
for key, value in signals.items():
self.frames.setdefault(key, []).append(value)
def stack(self) -> dict[str, np.ndarray]:
return {key: np.stack(values, axis=0) for key, values in self.frames.items()}
def _capture(robot: Any, sensor: Any, recorder: RolloutRecorder, num_envs: int) -> dict[str, np.ndarray]:
"""读一帧信号。所有张量都要 clone,否则会被后续仿真步就地覆写。"""
def to_numpy(tensor: torch.Tensor) -> np.ndarray:
return tensor.detach().to("cpu", torch.float64).numpy()
signals = {
"base_pos_w": to_numpy(robot.data.root_pos_w.clone()),
"base_lin_vel_w": to_numpy(robot.data.root_lin_vel_w.clone()),
"base_lin_vel_b": to_numpy(robot.data.root_lin_vel_b.clone()),
"base_ang_vel_b": to_numpy(robot.data.root_ang_vel_b.clone()),
"projected_gravity_b": to_numpy(robot.data.projected_gravity_b.clone()),
"joint_torque": to_numpy(robot.data.applied_torque[:, recorder.joint_ids].clone())
if recorder.joint_ids
else np.zeros((num_envs, 0)),
"joint_vel": to_numpy(robot.data.joint_vel[:, recorder.joint_ids].clone())
if recorder.joint_ids
else np.zeros((num_envs, 0)),
"feet_pos_w": to_numpy(robot.data.body_pos_w[:, recorder.feet_ids, :2].clone())
if recorder.feet_ids
else np.zeros((num_envs, 0, 2)),
}
if sensor is not None and recorder.support_ids:
forces = sensor.data.net_forces_w
contact_time = sensor.data.current_contact_time
if forces is None:
# 传感器还没更新过(第一帧),按无接触处理
signals["support_force_n"] = np.zeros((num_envs, len(recorder.support_ids)))
signals["support_contact_time_s"] = np.zeros((num_envs, len(recorder.support_ids)))
else:
selected = forces[:, recorder.support_ids, :]
signals["support_force_n"] = to_numpy(torch.linalg.norm(selected, dim=-1).clone())
signals["support_contact_time_s"] = to_numpy(
(contact_time[:, recorder.support_ids].clone() if contact_time is not None else torch.zeros_like(selected[..., 0]))
)
else:
signals["support_force_n"] = np.zeros((num_envs, 0))
signals["support_contact_time_s"] = np.zeros((num_envs, 0))
return signals
def record_rollout(
env: Any,
policy: Any,
protocol: EvalProtocol,
profile: RobotProfile,
recorder: RolloutRecorder | None = None,
) -> Trace:
"""跑 ``protocol.steps`` 步,采集一条 :class:`Trace`。
第 0 帧记录动作执行之前的起始状态,之后每步记录一次动作之后的状态,
因此帧数比步数多一。
"""
base_env = env.unwrapped
robot = base_env.scene["robot"]
if recorder is None:
recorder = build_recorder(env, profile)
sensors = base_env.scene.sensors
sensor = sensors.get(recorder.contact_sensor_name) if sensors else None
num_envs = int(base_env.num_envs)
collector = _FrameCollector()
initial = _capture(robot, sensor, recorder, num_envs)
# 起始帧还没有任何 episode 结束,置零以保证与后续帧形状一致
initial["done"] = np.zeros(num_envs, dtype=np.float64)
collector.add(**initial)
obs = env.get_observations()
# 终端侧进度:可视化时推流画面可能因为渲染/编码出问题而冻住,这时只有终端能
# 说明仿真到底有没有在推进(推进则打印继续,冻结则这里也停住)。每 10% 打一行。
progress_every = max(1, protocol.steps // 10)
started = time.monotonic()
for step in range(protocol.steps):
with torch.inference_mode():
actions = policy(obs)
if isinstance(actions, tuple):
actions = actions[0]
obs, _, dones, _ = env.step(actions)
frame = _capture(robot, sensor, recorder, num_envs)
frame["done"] = dones.detach().to("cpu", torch.float64).numpy().reshape(num_envs)
collector.add(**frame)
if (step + 1) % progress_every == 0 or step + 1 == protocol.steps:
print(
f"[eval_kit] rollout {step + 1}/{protocol.steps} 步,用时 {time.monotonic() - started:.1f} s",
flush=True,
)
arrays = collector.stack()
return Trace(
dt=float(base_env.step_dt),
command=np.asarray(protocol.command, dtype=np.float64),
mass_kg=recorder.mass_kg,
**arrays,
)
def summarize_early_resets(trace: Trace) -> dict[str, Any]:
"""统计 episode 中途被重置的环境数量。
中途重置会让后续帧来自新 episode,路程/进度类指标会被污染。旧
``eval.py`` 没有检查这一点,所以出现过「指标看着正常其实混了两次 rollout」
的情况不容易发现。
"""
# 最后一帧的 done 属于 episode 正常结束,不算早退
early = trace.done[:-1] > 0.5
per_env = early.sum(axis=0)
affected = int(np.count_nonzero(per_env > 0))
return {
"envs_with_early_reset": affected,
"total_early_resets": int(per_env.sum()),
"max_resets_single_env": int(per_env.max()) if per_env.size else 0,
"polluted": affected > 0,
}